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).
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/dependencies"
}
},
"items": [
{
"name": "orders",
"target": "dataset",
"description": "The orders Dataset this app reads",
"bound": false,
"runAs": "user",
"options": {},
"targetId": null,
"targetName": null,
"targetUrl": null,
"methodSurface": [
"search",
"fields"
],
"userCanBind": true
}
],
"whitelist": [],
"apis": [
{
"method": "GET",
"url": "/api/jobs/*",
"runAs": "user"
}
],
"apisError": null
}
Response Sections:
| Field | Description |
|---|---|
items | Manifest-declared dependency slots (bound or not), with each driver's describeBinding output |
whitelist | Read-only granted-access rows (access: typed entries with apiPaths) |
apis | The manifest's raw access.apis entries, normalized to { method, url, runAs }. Only populated for app types whose driver honors access.apis (full Apps) |
apisError | null, or { kind: "parse" | "load", message } when the manifest could not be read — distinct from "no raw access declared" so the audit panel can show an error instead of a clean empty state |
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:bind (Member+) —
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.
runAs and options are optional; existing values are preserved when
omitted. targetId is required and must be a UUID.
{
"targetId": "00000000-0000-4000-8000-000000000002",
"runAs": "user"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/dependencies/orders"
}
},
"id": "00000000-0000-4000-8000-000000000003",
"tenant": "acme",
"appId": "00000000-0000-4000-8000-000000000001",
"name": "orders",
"target": "dataset",
"configId": null,
"runAs": "user",
"options": {},
"description": "The orders Dataset this app reads",
"apiPaths": null,
"datasetId": "00000000-0000-4000-8000-000000000002",
"queryId": null,
"datasourceId": null,
"integrationId": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
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.
DELETE /apps/{id}/dependencies/{name}
Unbinds a dependency slot: clears the FK and binding-time config
(runAs back to user, options back to {}) but keeps the
declared row, so a subsequent PUT can rebind without a redeploy.
Slots themselves are added/removed only by the deploy pipeline's
manifest reconciliation.
Permission: permission.app.bind (Member+, level 2). Same
rationale as the PUT — installers of managed apps can unbind.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/dependencies/orders"
}
},
"id": "00000000-0000-4000-8000-000000000002",
"tenant": "acme",
"appId": "00000000-0000-4000-8000-000000000001",
"name": "orders",
"target": "dataset",
"configId": null,
"runAs": "user",
"options": {},
"description": "The orders Dataset this app reads",
"apiPaths": null,
"datasetId": null,
"queryId": null,
"datasourceId": null,
"integrationId": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
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). |