Skip to main content

Dependencies

Apps can declare typed dependencies on Informer resources (datasets, queries, datasources, integrations) and invoke them through a dependency-injected proxy from server-side handlers. Dependency bindings are install-tenant-local: the publisher's manifest names the slot and target type, the recipient picks the actual entity at install time.

Concept

Two row shapes live in the unified app_entity table:

ShapeManifest sectionSet by
Dependencydependencies:Author at deploy + installer at bind. Has name, target (resource type), and an optional FK to the bound entity. Addressed at runtime via context.<name>.<method>(...) from server handlers.
Whitelist entryaccess:Author at deploy. Has apiPaths (the paths the path-pattern proxy accepts) and an optional FK. Addressed by direct fetch from app code.

Dependency rows persist their declared target (dataset / query / datasource / integration) in the row's target column. The target field is fixed at deploy time; the install/edit UI changes targetId, runAs, and options but not the type.

Manifest

# informer.yaml
dependencies:
northwindOrders:
target: dataset
runAs: user
description: "Northwind orders dataset"
options:
filter:
region: $user.custom.region
reportTemplates:
target: query
runAs: owner

runAs accepts user (default — viewer's credentials) or owner (app owner's credentials, looked up at request time, cached for 5 minutes). options is a per-driver schema validated at deploy time.

Routes

GET /apps/{id}/dependencies

Returns the app's dependency rows + the read-only whitelist (granted access) rows. The install/audit UI consumes both arrays.

Permission: app.lookup (read access on the app).

Response:

{
"items": [
{
"name": "northwindOrders",
"target": "dataset",
"description": "Northwind orders dataset",
"bound": true,
"runAs": "user",
"options": { "filter": { "region": "$user.custom.region" } },
"targetId": "<dataset-uuid>",
"targetName": "Northwind Orders",
"targetUrl": "/api/datasets/<uuid>/_search",
"methodSurface": ["search", "fields"],
"userCanBind": true
}
],
"whitelist": [
{
"configId": "admin:northwind-orders",
"target": "dataset",
"targetId": "<dataset-uuid>",
"targetName": "Northwind Orders",
"targetUrl": "/api/datasets/<uuid>/_search",
"apiPaths": [
"POST /api/datasets/<uuid>/_search",
"GET /api/datasets/<uuid>/fields"
]
}
]
}

bound is true only when the FK target resolves under the requesting user's read-access scope. A row with targetId set but bound: false indicates the target was deleted or read access was revoked since the binding was made — the UI surfaces this as "Target unavailable."

userCanBind reflects whether the requester holds app:edit — view-only users see the rows and binding metadata for transparency but cannot trigger the PUT.

PUT /apps/{id}/dependencies/{name}

Binds or rebinds a dependency to a target.

Permission: permission.app.bind (Member+, level 2). Distinct from app.edit: binding/unbinding dependency slots is allowed on managed apps (origin: deployed | marketplace) because the whole point of an unbound row is for the installer to fill it in. app.edit would lock managed apps and leave them permanently broken at runtime.

Payload:

{
"targetId": "<UUID>",
"runAs": "user",
"options": { "filter": {} }
}

runAs and options are optional; existing values are preserved when omitted. targetId is required and must be a UUID.

The route resolves the driver from row.target (set by extractAppEntities at deploy time), so first-bind on a freshly-deployed unbound row works the same way as rebind. The payload targetId is validated against the driver's read_access scope — a 403 with "not found, or you don't have read access" surfaces both the not-found and access-denied cases.

Response: the updated row.

DELETE /apps/{id}/dependencies/{name}

Removes a dependency row. Idempotent — a second call returns 404 because appDependency.lookup runs before the permission check.

Permission: permission.app.bind (Member+, level 2). Same rationale as the PUT — installers of managed apps can unbind.

Response: 200 with no body on success.

POST /apps/{id}/_modernize-manifest

Migrates a legacy access: block in the app's informer.yaml into the dependencies: shape that the install panel can re-bind without manifest edits. Used when shipping an app whose dependencies were originally declared as raw access entries.

Permission: permission.app.edit (Member+, level 2 + not managed). The mutation rewrites the manifest, so it's gated by edit not bind.

Behavior:

  1. A pre-modernize snapshot is taken (app_snapshot row labeled modernize) so the change is reversible.
  2. Each access: entry that resolves under the requesting user's read_access scope is moved to a dependencies: slot with a pre-filled defaultBinding: <UUID>.
  3. Entries that don't resolve are kept in access: with a skipped: reason in the response summary.
  4. The route deploys the rewritten manifest. If the deploy fails (e.g. because a phantom entry is still in access: and the resource isn't visible), the route returns 400 with the snapshotId so the user can restore.

Response (200, success):

{
"summary": {
"migrated": [{ "target": "dataset", "fromConfigId": "...", "defaultBinding": "<UUID>" }],
"skipped": [{ "section": "datasets", "entry": "...", "reason": "..." }],
"kept": ["apis"],
"unchanged": false
},
"snapshotId": "<UUID>"
}

Response (400, redeploy failed):

{
"statusCode": 400,
"message": "Deploy failed: ...",
"data": { "snapshotId": "<UUID>" }
}

Idempotency: a second call on an already-modernized app returns 200 with unchanged: true and no snapshotId (no-op skips the snapshot to avoid churn from polling clients).

GET /apps/{id}/draft/_diff

Returns a structured diff of app_entity rows between an app and its draft, used by the commit confirmation UI.

Permission: app.lookup.

Response shape:

{
"dependencies": [
{
"name": "northwindOrders",
"status": "changed",
"was": { "target": "dataset", "targetId": "<old>", "targetName": "Old", "runAs": "user", "options": {} },
"now": { "target": "dataset", "targetId": "<new>", "targetName": "New", "runAs": "owner", "options": {} },
"changes": ["targetId", "runAs"]
}
],
"whitelist": {
"added": [ { "configId": "admin:foo", "target": "dataset", "apiPaths": ["..."] } ],
"removed": [ { "configId": "admin:bar", "target": "query", "apiPaths": ["..."] } ]
}
}

status is one of added / removed / changed / preserved. Returns 404 when no draft exists for the app.

Runtime invocation

Server-side handlers receive context as their first parameter, populated with one entry per declared dependency. Each entry is a typed proxy whose methods route through server.inject against the bound target's API endpoints.

// server/orders/list.js
module.exports = async ({ context, query }) => {
const result = await context.northwindOrders.search({
query: { match: { region: query.region } }
});
return result.hits.hits.map(h => h._source);
};

The proxy's method surface comes from the driver:

DriverMethods
datasetsearch(esQuery), fields()
queryexecute(payload)
datasourcequery(payload)
integrationrequest(payload)

Calling a method on an unbound dependency throws a structured 422 with errorCode: 'dependency_unbound'. Calling a method on a bound dependency whose target was deleted throws 422 with errorCode: 'dependency_broken'. Non-2xx responses from the upstream API route propagate as boom errors with the upstream status code attached.

Canonical handler invocation

Apps invoke their own server-side handlers via:

GET /apps/{id}/view/_/{path*}
POST /apps/{id}/view/_/{path*}
PUT /apps/{id}/view/_/{path*}
PATCH /apps/{id}/view/_/{path*}
DELETE /apps/{id}/view/_/{path*}

{path} matches the route filename in the app's server/ directory (e.g. server/orders/list.jsPOST /view/_/orders/list). Authentication flows through the app's view session (cookie or token).

The legacy /view/api/_server/{path*} form continues to work — both routes dispatch through the same handler logic.

Errors

StatusCause
400Manifest references an unregistered driver target (typo in target:) — caught at deploy time before the row is written.
400Manifest dependency name doesn't match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ (lowercase dot-segmented).
400Production deploy with target-type drift (e.g. target: datasettarget: integration on an existing dependency). Drafts permit drift; production rejects.
403PUT target doesn't resolve under read_access for the requester.
404Dependency name not declared in the app's manifest.
412runAs: 'owner' binding on a team-owned app whose owner team has no admins.
422Runtime invocation of an unbound or broken dependency proxy (returned to the app handler as a boom-shaped object with errorCode).