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:
| Shape | Manifest section | Set by |
|---|---|---|
| Dependency | dependencies: | 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 entry | access: | 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:
- A pre-modernize snapshot is taken (
app_snapshotrow labeledmodernize) so the change is reversible. - Each
access:entry that resolves under the requesting user'sread_accessscope is moved to adependencies:slot with a pre-filleddefaultBinding: <UUID>. - Entries that don't resolve are kept in
access:with askipped:reason in the response summary. - 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 returns400with thesnapshotIdso 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:
| Driver | Methods |
|---|---|
dataset | search(esQuery), fields() |
query | execute(payload) |
datasource | query(payload) |
integration | request(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.js → POST /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
| Status | Cause |
|---|---|
| 400 | Manifest references an unregistered driver target (typo in target:) — caught at deploy time before the row is written. |
| 400 | Manifest dependency name doesn't match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ (lowercase dot-segmented). |
| 400 | Production deploy with target-type drift (e.g. target: dataset → target: integration on an existing dependency). Drafts permit drift; production rejects. |
| 403 | PUT target doesn't resolve under read_access for the requester. |
| 404 | Dependency name not declared in the app's manifest. |
| 412 | runAs: 'owner' binding on a team-owned app whose owner team has no admins. |
| 422 | Runtime invocation of an unbound or broken dependency proxy (returned to the app handler as a boom-shaped object with errorCode). |