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 / app / pack) 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",
"optional": false,
"bound": false,
"runAs": "user",
"unreachableForViewers": false,
"options": {},
"targetId": null,
"targetName": null,
"targetUrl": null,
"targetImageUrl": null,
"methodSurface": [
"search",
"fields"
],
"owned": false,
"connected": null,
"canConnect": false,
"sharedConnection": null,
"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 when the slot has a resolved binding. For ordinary slots
that means 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, which the UI
surfaces as "Target unavailable." App-owned (BYOI) integration slots always
report bound: true for every viewer: their binding is deploy-managed and
does not depend on the viewer's read access to the integration.
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. It is forced to false on app-owned slots (below),
whose binding belongs to the manifest.
unreachableForViewers is a diagnostic for a slot that dispatches as the
viewer (runAs: "user") but is bound to a target only its owner can read: it
resolves for the App owner and answers 404 for every other non-superuser
viewer outside the owning principal. It is derived per row from the effective
runAs and binding (not the manifest), so the install UI can flag the problem
while the binding is still being chosen.
App-owned (BYOI) integration fields
Slots bound to an Integration the App declares in its manifest's
integrations: block carry these extra fields on each items entry. Each
defaults to its "nothing to connect" value for non-integration or non-oauth2
slots, and all are absent on servers that predate the feature.
| Field | Description |
|---|---|
owned | true when the slot is bound to an app-owned Integration (a projection of the manifest's integrations: block). Owned slots are deploy-managed: userCanBind is forced false and PUT/DELETE are refused. |
optional | true when the author marked the slot (or owned entry) optional: true. The install UI keeps optional slots out of the "needs binding" count and warning state. |
connected | OAuth connection state for oauth2-bound slots: the requesting user's connection for per-user slots, the shared connection for shared-mode slots. null when there is nothing to connect (non-integration target or non-oauth2 auth type). |
canConnect | true when the requester is capable of running the slot's OAuth consent: the binding is oauth2 and the Integration resolves under their read access. Per-user owned slots inherit readability from the App (any viewer); shared-mode owned slots are readable only by the owner or team admins, who are exactly the identities that may establish the shared consent. Drives the App's Connections popover. |
sharedConnection | true when the connection is shared-mode (consenting or disconnecting affects every viewer); false for per-user. null when there is nothing to connect. |
targetImageUrl | Service icon of the bound Integration (an App-library file copied onto the Integration, a well-known registry match, or the plug fallback), or null for resource types without one. |
Pack dependency fields
Slots declared target: pack carry three extra fields; all are absent on other
slot types.
| Field | Description |
|---|---|
pack | The author's pin plus local install state: slug, requires (semver range), the installed version/channel, inRange, and remotePackId. |
packState | One derived word for the connect UI: needsInstall (no local install), outOfRange (installed but outside requires), ambiguous (stable and beta both in range, awaiting an admin's channel pick), or consented. |
consented | true when the slot is usable: either an admin picked a channel, or exactly one in-range install auto-consented. Distinct from bound; a pack pin can resolve (bound: true) while consented is still false. |
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",
"optional": false,
"apiPaths": null,
"datasetId": "00000000-0000-4000-8000-000000000002",
"queryId": null,
"datasourceId": null,
"integrationId": null,
"targetAppId": 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.
Slots bound to an app-owned (BYOI) Integration are deploy-managed and reject
both PUT and DELETE with a 400 ("bound to the app-owned integration ... and
cannot be rebound. Change the manifest and redeploy instead."). Their binding
is declared by the manifest's integrations: block, so it is changed by
editing the manifest and redeploying, not through these routes.
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",
"optional": false,
"apiPaths": null,
"datasetId": null,
"queryId": null,
"datasourceId": null,
"integrationId": null,
"targetAppId": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
GET /apps/{id}/dependencies/{name}/connect
Slot-addressed OAuth connect for oauth2-bound integration slots, including
app-owned (BYOI) ones. Resolves the Integration bound to the named slot and
302-redirects into the existing GET /integrations/{id}/connect flow, so
App code opens a connect popup by slot name and never learns the Integration
UUID up front. Everything downstream is unchanged: the OAuth handler signs the
state JWT, the License Manager relays authorize/callback, and the consent
completion notifies the opener via an informer:integration-connected
postMessage.
Permission: the bound Integration's read_access, the same bar as the
Integrations admin page (enforced again by the redirect target's own lookup).
For an app-owned per-user slot that is any viewer who can read the App
(readability is inherited from the App); for a shared-mode owned slot it is
the owner or team admins, who are exactly the identities that may establish
the shared consent. Slots bound to an ordinary shared Integration let any user
with read access create their own per-user connection without leaving the App.
Query: session (optional) carries a session JWT so the flow stays
authenticated across the redirect in cookie-less popup contexts (Informer GO
web and desktop popups carry no cookie). Any other query params ride through
untouched.
Errors:
| Status | Cause |
|---|---|
| 400 | The slot is not bound to an Integration. |
| 400 | The bound Integration's auth type is not oauth2 (only oauth2 integrations have a connect flow). |
| 404 | The App or the dependency name is not readable, or the name is not declared in the manifest. |
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).
Staged deploys: the route rewrites a library file, so it answers
409 while the app has a
staged deploy. Nothing is
rewritten and no snapshot is taken; wait for the deploy, or discard it
with DELETE /files/_stage.
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) |
app | request(payload) |
pack | 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'.
Invoking an app-owned oauth2 integration slot that has no connection for the
effective identity throws 422 with errorCode: 'integration_not_connected',
which the App catches to prompt the user through the slot's connect flow.
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.
GET /apps/{id}/openapi.json
An OpenAPI 3.0.3 document describing the App's deployed server/ routes, read
live from the deployed route table, with a root-level API.md folded into
info.description. This is the discovery surface for cross-app dependencies
(target: app and target: pack): a depending App, its author's tooling, or
the App Builder fetches this instead of reading the target's source.
Authentication: Required. No permission beyond read access to the App; an API contract is readable by anyone allowed to see the App.
Response: raw application/json. Unlike most Informer endpoints this body
is not HAL-wrapped (a top-level _links would be invalid OpenAPI). The
endpoint is linked as inf:app-openapi; resolve it by rel rather than
building the URL.
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. |
| 400 | PUT binds a target: app slot to the app itself (an app cannot depend on itself). |
| 400 | PUT attempts to change a target: pack slot's author-defined pin; only a redeploy changes it. |
| 400 | PUT options fail the driver's optionsSchema. |
| 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, or an app-owned oauth2 slot with no connection (returned to the app handler as a boom-shaped object with errorCode dependency_unbound / dependency_broken / integration_not_connected). |