Runtime & View
The view endpoints serve apps to end users and provide a secure proxy for API access.
Architecture Overview
Apps run in a sandboxed environment with:
- Entry Point: HTML served with context injection
- Static Assets: CSS, JS, images served from library
- API Proxy: Whitelisted access to Informer APIs
- Server Route Dispatch: File-convention server-side handlers via
_server/prefix - View Tokens: A stateless
app_viewJWT minted when the host page is served. All claims (appId,username,roles) travel in the token; nothing is written to the database per view, and the token only authorizes the app's render surfaces (see Tokens)
Security Model
Direct API Access Blocked: Apps cannot make direct fetch() calls to /api/ endpoints. This prevents malicious app code from bypassing the whitelist using session cookies.
Proxy Required: All API access must go through /api/apps/{id}/view/api/ which enforces:
- Whitelist validation (informer.yaml)
- Read-only restrictions
- App-specific permissions
Referer Check: The server blocks any /api/ request with a Referer header indicating an app view context (unless it's the proxy route itself).
GET /api/apps/{id}/view
Serve the app's entry point (typically index.html) with injected context.
Authentication: Required (session or token)
Permissions Required: Member role (run permission)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Response:
Returns HTML content with injected JavaScript variables:
<!DOCTYPE html>
<html>
<head>
<title>Sales Dashboard</title>
<script>
// Injected context
window.__INFORMER__ = {
report: {
id: "d4f8a2b1-1234-5678-90ab-cdef12345678",
name: "Sales Dashboard"
},
theme: "light",
roles: ["viewer", "approver"],
viewToken: "abc123def456...",
user: {
username: "john.doe",
displayName: "John Doe"
},
datasets: {
"sales": {
"id": "dataset-uuid-1",
"name": "Sales Data"
}
}
};
</script>
</head>
<body>
<!-- App content -->
</body>
</html>
Context Injection:
The server injects a window.__INFORMER__ object containing:
report- App identity (idandname)theme- Current theme (lightordark)roles- Array of the user's resolved role IDs (see Roles)platform-{ version, originMode, capabilities }: the Informer build version and the app type's capability flags, for feature detection (platform.capabilities.embeddings), plusoriginMode,truewhen Apps serve from their own origins as live channels need. The same object reaches server handlers asplatformon their context.viewToken- The session's statelessapp_viewJWT; authorizes the proxy, handler, and asset routes for this app onlyuser- Current user informationdatasets- Dataset references with metadataopenChat(opts)- Open the AI copilot with context (see Embedded Chat)showCopilot()- Activate the platform copilot button (hidden by default for apps)registerTool(def)- Register a tool the AI copilot can call at runtime
Entry Point:
The entry point is determined by the app's defn.entryPoint field (default: index.html).
Error Responses:
404 Not Found- App doesn't exist or user lacks access403 Forbidden- User lacks run permission
GET /api/apps/{id}/launch
Serve the app as a standalone, home-screen-installable document — the same runtime as /view, but a top-level page (not iframed) with web-clip <head> metadata so an iOS/Android "Add to Home Screen" tile opens the app chrome-less, inheriting its name and icon.
For an App served from its own origin (see Installing an App from its origin), a top-level navigation to this route does not render here. It answers 302 Found and hands off through /_launch to the same path and query on the App origin, which serves the standalone document itself. A client that sends neither Sec-Fetch-Dest: document nor ?origins=1 still gets the render described below.
A tile pinned from this URL on an earlier release, while the App was already served from its own origin, now leaves the tile's scope at that hand-off and opens in the browser with its toolbar. Pin the App again from Informer GO's Add to Home Screen to get a tile on the App origin.
Authentication: Optional (mode: try). Authenticated requests render the app; unauthenticated requests redirect to /go/login and return to this same /launch URL after sign-in.
Permissions Required: Read access to the app.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID (ownerId:slug) |
path | string | Client-side route path (optional, may be empty) — the catch-all that lets an HTML5 history route survive a hard refresh of the tile, the same way /view/-/{path*} does. Ignored by the server; the SPA router picks it up. |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
theme | string | light or dark (optional) |
Response:
Returns the same HTML as /view plus web-clip meta (apple-mobile-web-app-capable, apple-mobile-web-app-title, theme-color, and — when the app ships a favicon.svg — an apple-touch-icon link to /launch/icon-180.png).
The same standalone-context fields are injected on window.__INFORMER__ for both /view and /launch — appDeepLink (informer://go/apps/{id}) and openInApp() (navigate to that deep link). What differs is the value of standalone:
standalone:trueon this/launchentry and on a top-level page on an App origin,falsewhen iframed via/view(or running inside GO). Apps gate their "open in app" affordance on this flag.
standalone means "not framed", so on an App origin it is also true in an ordinary browser tab. To tell whether the App was actually installed to the home screen, check window.matchMedia('(display-mode: standalone)').matches in the page.
The server injects no "open in app" UI; the app decides if and where to surface it using the fields above.
Every App page also carries its addresses on window.__INFORMER__.report, the same values a server route reads from request.app (App Object):
report.origin: the App's own origin (https://<label>.<apps-domain>), ornullwhen the server serves Apps path-mounted or the App has no origin yet. A draft reports its own-draftorigin, where its preview runs.report.apiUrl: the base an outside caller appends a route to, with an API key in theAuthorizationheader.
Error Responses:
404 Not Found— App doesn't exist or user lacks access503 Service Unavailable— the App is served from its own origin but the hand-off to it could not be prepared. The App is never rendered on the Informer origin instead; retry.
GET /api/apps/{id}/icon
Stream the App's gallery / browser-tab icon with its real content type. The
icon lives in the library root as favicon.<ext> in any web image format;
this route is the single client entry point, so the UI never has to know the
extension. Answers 404 when the App has no library or no icon. Auth
strategies: token, session, or session JWT, and ?t= is an ignored
cache-buster. Linked as inf:app-icon.
PUT /api/apps/{id}/icon
Set the icon from base64 image bytes. Requires app:write and the App must
have a library. Payload: content (base64-encoded bytes) and contentType
(e.g. image/png). The format is validated and any other favicon.* variant
is removed, so exactly one icon remains. Answers 409 Conflict while the App
has a staged deploy: the icon is a
library file, so setting it now would be discarded when the staged build is
promoted.
DELETE /api/apps/{id}/icon
Remove every favicon.* variant. Requires app:write and a library. Deleting
from an icon-less App is a no-op success, not a 404. Like PUT, it answers
409 Conflict while the App has a
staged deploy. Reading the icon
(GET /icon) is unaffected and keeps serving the live build's.
GET /api/apps/{id}/launch/icon-180.png
Serve the home-screen tile icon: a 180×180 PNG rasterized from the app's library favicon.svg (iOS ignores SVG apple-touch-icons). The /launch page only emits the icon <link> when a favicon.svg exists.
Authentication: Optional (mode: try). Reads the access token from the ?token= the page appends to the icon URL so iOS can fetch it cookielessly.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Response:
200 OK with Content-Type: image/png (Cache-Control: private, max-age=86400), or 404 Not Found when the app has no favicon, isn't readable, or the SVG can't be rasterized — on 404 the OS draws its own initials tile.
GET /api/apps/{id}/view/-/{path*}
Serve static assets (CSS, JS, images) from the app's library, or fall through to the host index.html when the path is an HTML5 history route rather than a real file.
Authentication: Required (session or token)
Permissions Required: Member role (run permission)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
path | string | Asset path (e.g., css/styles.css, images/logo.png) — may be empty |
Response:
If a file at path exists in the library, returns the file content with the appropriate Content-Type header.
Server-side files are never served here, whatever the caller's access: the manifest (informer.yaml or informer.yml), the handler folders (server/, webhooks/, tools/, mcp/, migrations/, channels/, embeddings/) and _bundles/ return 404 Not Found, even for an HTML navigation.
If the file is not found, the server inspects the Accept header:
| Request shape | Behavior |
|---|---|
Accept includes text/html (top-level navigation, hard refresh, iframe load) | Falls through to the host index.html so the SPA router can pick the path up client-side |
Empty path (request to /view/-/) | Same — serves the host page |
Anything else (<script src>, <link href>, fetch() for JSON, etc.) | 404 Not Found |
This split lets HTML5 history routes survive a hard refresh while genuine missing-asset errors still surface as 404s. SPAs that use a client-side router (React Router, Vue Router, vanilla history.pushState) work without any extra server configuration.
Caching: every file is sent with an ETag and Cache-Control: private, no-cache, so the browser revalidates it on each view. An unchanged file answers 304 Not Modified with no body, and a saved change shows on the next load.
URL Structure:
The /-/ prefix separates assets from API routes and is the value injected as the <base href> on every page render:
/view/-/css/styles.css— Static asset/view/-/orders/123— Client-side history route (falls through to host page on direct load)/view/api/datasets— API proxy
Example — assets:
<!-- In index.html, all of these resolve under <base href="/api/apps/.../view/-/"> -->
<link rel="stylesheet" href="css/styles.css">
<script src="js/app.js"></script>
<img src="images/logo.png">
Example — client-side routing:
// React Router, vanilla history API, etc. all work — the server fall-through
// means a hard refresh of /api/apps/.../view/-/orders/123 still returns the
// host index.html, and the client router takes over from there.
history.pushState({}, '', 'orders/123');
Best practices for client-side routing:
- Prefer HTML5 history routing over hash routing. URLs are cleaner, deep links survive copy/paste, and the server fall-through handles refreshes for you. Hash routing also works (the fragment never reaches the server) but you lose the URL polish for no benefit.
- Use relative paths in your router. The injected
<base href>already points at/api/apps/.../view/-/, sopushState({}, '', 'orders/123')lands at the correct absolute URL. Never hard-code the/api/apps/...prefix in your app — it changes per deployment. - For React Router: read the base from the document and pass it to
BrowserRouter:const basename = new URL(document.querySelector('base').href).pathname;<BrowserRouter basename={basename}>...</BrowserRouter> - For Vue Router: use
createWebHistory(new URL(document.querySelector('base').href).pathname). - Set explicit
Accepton programmatic fetches. The server usesAccept: text/htmlto decide between asset-404 and SPA-fallback, so a strayfetch('data.json')without anAcceptheader is fine (browsers default to*/*which doesn't trigger the fallback), but explicit is better:fetch('data/manifest.json', { headers: { accept: 'application/json' } });
Error Responses:
404 Not Found— App doesn't exist, OR file is missing AND the request is not an HTML navigation403 Forbidden— User lacks run permission
API Proxy Routes
The proxy routes allow apps to make API calls while enforcing whitelist restrictions.
GET /api/apps/{id}/view/api/{path*}
Proxy GET requests to Informer APIs.
Authentication: App view token (from context)
Permissions Required: Whitelist validation
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
path | string | API path to proxy (e.g., datasets/sales-data/data) |
Example:
// In app code
const response = await fetch('/api/apps/my-app-id/view/api/datasets/sales-data/data?limit=100');
const data = await response.json();
Whitelist Enforcement:
The proxy checks the app's informer.yaml file to ensure the requested path is allowed. See the Data Access section for whitelist configuration.
Always-Blocked Endpoints:
A small set of credential-minting and identity-escalation endpoints can never be reached through the proxy (or a server route's fetch()), even if an app whitelists them. Requests to these return 403 Forbidden. They include /api/wsToken, /api/session, the /api/tokens surface, /api/_impersonate, /api/_sync-user, and the OAuth authorization endpoints (/api/oauth/authorize, /api/oauth/authorize_device). Blocked requests answer 403 Forbidden with API endpoint not allowed for this app. This prevents app code from obtaining the viewer's session, websocket, or API tokens.
POST/PUT/PATCH/DELETE /api/apps/{id}/view/api/{path*}
Proxy write requests to Informer APIs.
Authentication: App view token
Permissions Required: Whitelist validation + write permissions
Payload:
Request body is passed through to the target API.
Example:
// In app code
const response = await fetch('/api/apps/my-app-id/view/api/datasets/sales-data/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters: [...] })
});
Restrictions:
- Write operations are subject to stricter whitelist validation
- Runtime view tokens are minted with
readOnly: false(apps legitimately POST through the proxy); external-link tokens arereadOnly: trueand reject non-GET requests - User's actual permissions are checked on the target resource
Staged Uploads & Downloads
An app page moves files through a staging area rather than through its own JSON bodies: the browser uploads in chunks to the endpoints below and hands a server route only the resulting id. The bytes go on to Postgres without ever entering the app's isolate — see The uploads & downloads Objects for the handler side.
These routes live under the app's own /view subtree because that is the origin the app page's content-security policy permits it to reach, and the scope the view-token cookie covers.
Authentication: the app view token (path-scoped cookie, ?token=, or Authorization: Bearer) or a logged-in session. Every stream is scoped to the app and to the user who created it; another app's or another user's stream is a 404, never a 403, so an id cannot be used to probe for streams that are not yours.
Limits come from config.app.streams:
| Setting | Default | Applies to |
|---|---|---|
maxUploadBytes | 100 MB | The declared size of one upload, and the bytes one download may stage |
maxChunkBytes | 8 MB | One chunk body |
maxInlineBytes | 10 MB | Reading an upload into the isolate (text(), json(), base64()) |
ttlSeconds | 3600 | How long a staged stream survives |
The client helper
__INFORMER__.upload() runs the whole chunked protocol — it slices the file, PUTs chunks in parallel, retries the ones that fail, and seals the upload.
const task = __INFORMER__.upload(file, {
chunkSize: 4 * 1024 * 1024, // default 4 MB; the server may clamp it
concurrency: 3, // parallel chunk PUTs
retries: 5, // per chunk, exponential backoff with jitter
onProgress: ({ loaded, total, percent }) => { bar.style.width = percent + '%'; },
onEvent: e => log(e), // each step, as it happens (see below)
signal: controller.signal // abort — the partial upload is discarded
});
const { id, chunks, chunkSize } = await task.created; // the geometry, before a chunk moves
const upload = await task; // { id, filename, size, chunkSize, chunks }
await fetch('/api/_server/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uploadId: upload.id })
});
The task exposes task.id as soon as the upload has been created, task.created (a promise for that moment — it rejects with a create-time refusal such as a 413 over the cap), and task.abort(), so an interrupted upload can be resumed instead of restarted:
const upload = await __INFORMER__.upload(file, { resume: previousUploadId });
Resuming probes the server for the chunks it already holds and sends only the missing ones. It refuses unless the file matches both the size and the fingerprint the upload was created with — size alone would let two same-length files be spliced into one upload that completes without complaint.
A failed upload stays resumable: only an explicit abort() discards the staged chunks. Any other terminal failure tags the error with err.uploadId for exactly this purpose, and the TTL reclaims the bytes if the caller never retries.
Sealing is the authority on what landed. If the seal answers 412 because a chunk the server acknowledged never became durable, the helper resends exactly the chunks it names and seals again; a second 412 fails the upload, resumable.
__INFORMER__.downloadUrl(id, filename) builds the URL for a download a server route staged. Every helper works unchanged in path mode, on an app origin, and under the Vite dev server.
Watching a transfer
onEvent receives one plain object per step, in order. A progress bar hides the fact that three chunks are usually in flight and land out of order; this is how a page shows it.
| Event | Fields | When |
|---|---|---|
created | id, size, chunks, chunkSize, resumed, received | The server has reserved the upload. received lists the chunks a resumed upload already held |
chunk | n, state, attempt?, status? | state is sent when a PUT goes out, landed when it is acknowledged, retry when it failed with a retryable status and will be sent again, or resend when a 412 seal named it missing. attempt is 1-based, and absent on a resend |
sealed | handle | The upload is complete; the same handle the task resolves with |
failed | error, aborted, resumable | Terminal. resumable is true when error.uploadId is set — never for an upload the server already reclaimed (error.code upload_expired), which no resume could find |
A listener that throws does not fail the transfer it is watching; its error is rethrown on a fresh tick so it still reaches the console.
__INFORMER__.streams
What the staging area holds for the current user in this app. The helper owns the base URL for these routes, so a page asks here rather than building the calls itself.
const { uploads, downloads } = await __INFORMER__.streams.list(); // oldest first
// The cap itself is deployment config (maxStreamsPerUser); list() is the
// truthful source for how much of it this viewer is using.
gauge.textContent = `${uploads.length + downloads.length} staged`;
const state = await __INFORMER__.streams.status(staleUploadId); // { received, missing, complete, … }
if (!state.complete) offerToResume(state.missing.length);
await __INFORMER__.streams.discard(uploads[0]); // now, not at expiry
await __INFORMER__.streams.discard(downloadId, 'download'); // or a bare id plus its kind
list() is the truthful source for maxStreamsPerUser: streams an earlier session staged and abandoned count against it until they expire, and this is where a page finds them to clear.
It is not a way to compute how much of maxStagedBytesPerUser is left. A stream that is still open — an unsealed download, an upload being filled from an integration — is held against the byte cap at the ceiling it could still reach, while size reports only what has landed so far (0 until it seals). Summing size therefore reads far below what the cap is actually counting.
POST /api/apps/{id}/view/_uploads
Create an upload. The declared size and chunkSize fix its geometry, so every later chunk can be length-checked without trusting the client about totals.
Payload:
| Field | Type | Description |
|---|---|---|
filename | string | Required. No path separators or control characters |
size | number | Required. Total bytes the client will send |
contentType | string | Optional; inferred from the filename when omitted |
fingerprint | string | Optional but recommended. Opaque file identity; a resuming client must present the same one. __INFORMER__.upload() sends name + size + last-modified |
chunkSize | number | Optional; the server may clamp it |
Response: 201 Created with a Location header and the upload handle — id, filename, contentType, size, chunkSize, chunks, complete, expiresAt. Use the returned chunkSize, not the requested one.
A declared size over maxUploadBytes is refused with 413, whose payload carries data.maxUploadBytes.
GET /api/apps/{id}/view/_uploads
The caller's own staged uploads for this app, oldest first.
Response: an array of upload handles — id, filename, contentType, size, chunkSize, chunks, complete, expiresAt. Chunk-level state (received, missing) is on the per-upload probe below.
An upload whose bytes have already expired is not listed. Another user's uploads are never listed, whatever their app.
PUT /api/apps/{id}/view/_uploads/{uploadId}/{n}
Store chunk n (1-based). The body is the raw chunk bytes — no parsing, no content-type negotiation.
Response: 204 No Content
The write is idempotent: re-sending a chunk after an ambiguous failure overwrites it, which is what makes the client's automatic retry safe. A chunk whose number is outside 1..chunks, or whose length disagrees with the geometry, is 422. A body over maxChunkBytes is 413. A chunk sent after the upload was sealed is 409.
POST /api/apps/{id}/view/_uploads/{uploadId}/_complete
Seal the upload. Chunks arrive in parallel and out of order, so completeness is an explicit step; until it happens, uploads.get(id) in a server route refuses the upload with 409.
Response: 200 OK with the sealed handle (complete: true).
If chunks are missing the response is 412, with data.missing listing their numbers so the client can resend just those.
GET /api/apps/{id}/view/_uploads/{uploadId}
Resume probe: what the server already holds.
Response: the upload handle plus received and missing, both arrays of chunk numbers.
DELETE /api/apps/{id}/view/_uploads/{uploadId}
Abort an upload and free its staged bytes now rather than at expiry. The client helper calls this itself when an upload is aborted.
Response: 204 No Content
GET /api/apps/{id}/view/_downloads
The caller's own staged downloads for this app, oldest first.
Response: an array of download handles — id, filename, contentType, size, complete, expiresAt, and url, the address that serves it (spelled for the app origin when that is how the page reached the server, and for the route below otherwise).
A download that has been served, discarded or expired is not listed — unless it was served with ?keep=true, which leaves it staged and therefore listed.
GET /api/apps/{id}/view/_downloads/{streamId}/{filename?}
Serve a download a server route staged with downloads.create(). The trailing filename segment is cosmetic — it makes the URL bookmarkable and save-as friendly — while Content-Disposition comes from the stream's own filename.
Query Parameters:
| Parameter | Default | Description |
|---|---|---|
keep | false | Leave the staged bytes in place until they expire, instead of destroying them once served |
inline | false | Ask the browser to render the file rather than save it. Ignored for text/html and image/svg+xml, which are always sent as attachments under a restrictive CSP — a handler chooses the content type, so rendering one would run app-authored markup on the Informer origin with the viewer's session |
Response: the staged bytes, with the stream's content type and length.
Downloads are single-use by default: the bytes are destroyed once served, so a stale URL answers 404. Pass ?keep=true when the page may need to fetch the same download more than once.
DELETE /api/apps/{id}/view/_downloads/{streamId}
Discard a staged download without serving it, freeing its bytes and its share of the caller's staging allowance now rather than at expiry. Serving already destroys a download; this is for the one nobody will claim.
Response: 204 No Content. Another user's download, or one already gone, is 404.
Server Route Dispatch
When an app's client code fetches a URL containing the _server/ prefix, the proxy dispatches the request to a registered server route handler instead of forwarding it to an Informer API endpoint.
// Client code — works in both dev mode and production
const orders = await fetch('/api/_server/orders');
const data = await orders.json();
Server routes are defined by files in the app's server/ directory and deployed via POST /api/apps/{id}/_deploy. See Server Routes for full documentation.
Installing an App from its origin
When a deployment serves Apps from their own origins, the App origin is where a home-screen tile or installed App points. Informer GO's Add to Home Screen opens GET /api/apps/{id}/launch, which hands off to the App origin, and the page pinned there opens without browser chrome, named after the App and showing its icon.
A top-level page on the App origin (the browser sends Sec-Fetch-Dest: document) is rendered as the standalone document:
- Web-clip meta (
apple-mobile-web-app-capable,apple-mobile-web-app-title,theme-color) and, when the App has an icon, anapple-touch-iconlink to/_app/icon-180.png?v=…, wherevis the icon's version. <link rel="manifest" href="/_app/manifest.webmanifest">.window.__INFORMER__.standaloneset totrue.- The
_estquery parameter removed from the address bar, so the URL a tile saves is the App's own.
The page's own tags win. A browser uses the first theme-color and manifest link it finds, so each meta tag is added only when the App's index.html doesn't declare its own. The manifest link is left out only when the page's own links /manifest.webmanifest or /_app/manifest.webmanifest: a link to any other file is fetched without the App's session and fails, so the platform's goes first. When the App has an icon, the apple-touch-icon is added even if the page declares one, because iOS fetches it without the App's session and only the platform's tile icon loads that way.
The same App framed inside Informer GO gets none of these and reports standalone: false. Both responses carry Vary: Sec-Fetch-Dest.
Shipping your own manifest
An App that wants its own install (display: fullscreen, an orientation, its own colors, maskable icons) ships manifest.webmanifest at its root (public/manifest.webmanifest in a Vite project) and links it from index.html:
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#07071a">
The App origin serves that file, without a session, at /manifest.webmanifest and /_app/manifest.webmanifest:
- Every member the App sets wins. Members it leaves out are filled in from the generated manifest below, so a manifest without a
start_url,idornamestill installs. - Its
id,start_url,scope, shortcuturls and imagesrcs resolve from the App's root, as they would against the file itself. Other URL members are served as written, so give them as absolute paths. - The images it lists (
icons,screenshots, andshortcutsicons) that are the App's own image files, by extension, point at/_app/manifest-assets/…, which serves exactly those files without a session. Every other App file still needs one. - A leading byte-order mark is ignored. A file that isn't a JSON object is sent as it is, so the browser's manifest errors say what is wrong with it, and the server logs a warning naming the App.
The routes below exist only on the App origin: on the Informer origin they are not served, and the answer is the same as for any unrouted path, with or without a session. Each serves the App whose origin it is on, and only on its published origin: the editor's preview (draft) origin answers 404.
All are anonymous. An installed App outlives its session: browsers re-fetch the manifest to pick up a new name or icon, and the OS can ask for another icon size at any time. An expired session never stops a home-screen tile from updating. They expose only what the tile itself shows: the App's manifest and the images it lists, or, without one, the App's name and icon.
GET /_app/manifest.webmanifest
Also served at GET /manifest.webmanifest. With no manifest.webmanifest of the App's own, the generated web app manifest, served as application/manifest+json: name and short_name from the App name, an id, a start_url and a scope of /, display: standalone, theme_color and background_color matching the page, and, when the App's icon can be rasterized, icons listing the three tile icon sizes. An installed App opens at its root, whatever page it was installed from: Chrome won't install a manifest without a start_url. The id is explicit, so the install's identity never follows start_url: changing where the App opens keeps it the same installed App. Served with Cache-Control: public, no-cache and an ETag, so a browser re-fetching it always sees the current name and icon, and gets 304 Not Modified when nothing changed. Each icon URL carries the icon's version (/_app/icon-192.png?v=…), which changes whenever the icon does.
Authentication: none.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
theme | string | light (default) or dark, matching the page's theme-color |
Error Responses:
404 Not Found: the App no longer exists503 Service Unavailable: the App's icon or its own manifest couldn't be loaded (a database or storage error). Browsers don't cache it, so an installed App keeps the manifest and icon it already has.
GET /_app/icon-{size}.png
The App's icon rasterized to an opaque size by size PNG. size is 180 (the iOS touch icon), 192, or 512; any other size is an ordinary App asset path. A request whose v is the icon's current version is served with Cache-Control: public, max-age=31536000, immutable, since that URL only ever names one icon. Any other request (no v, or one from before the icon changed) gets the current icon with Cache-Control: public, max-age=86400.
Authentication: none.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
v | string | The icon version the manifest or page linked. The current version is cached for a year |
Error Responses:
404 Not Found: the App has no icon or it can't be rasterized, such as an ICO (the OS then draws its own tile), or the App no longer exists. Served withCache-Control: no-store, so an icon added later isn't hidden behind a cached404.503 Service Unavailable: the App's icon couldn't be loaded (a database or storage error). Browsers don't cache it, so an installed App keeps the manifest and icon it already has.
GET /_app/manifest-assets/{path}
An image the App's own manifest.webmanifest lists, read from path in the App's library. Only a file the manifest lists as an image in icons, screenshots or a shortcut's icons is served, and only when its extension is an image type; anything else answers 404. Served with Cache-Control: public, no-cache and X-Content-Type-Options: nosniff, and an SVG with a Content-Security-Policy that lets it run no script. The manifest the platform serves already points at these URLs, so an App never writes them itself.
Authentication: none.
Error Responses:
404 Not Found: the file isn't an image the App's manifest lists, isn't in the library, or the App no longer exists503 Service Unavailable: the App's manifest or the image couldn't be loaded (a database or storage error)
Signing out on an App origin
When a deployment serves Apps from their own origins (app.appsBaseUrl is set, and GET /api/session reports appOrigins: true), the App's session is an HttpOnly cookie on the App's origin carrying a stateless app_view token. It re-mints for as long as the App is used, and signing out of Informer cannot reach it. The App origin has its own sign-out route.
POST /_auth/logout
Clears the App-origin session cookie and answers 204 No Content. The route lives on the App origin only (https://<label>.<apps domain>/_auth/logout) and answers 404 on the Informer origin.
Authentication: None required. Signing out works whether or not the cookie still authenticates. The server never reads the cookie to decide what to clear: the response deletes both shapes the cookie can have, a standalone launch's host-only SameSite=Lax cookie and an embedded launch's SameSite=None; Partitioned cookie, so an expired, unreadable, or duplicated cookie is cleared as well.
CSRF: Like every state-changing request on an App origin other than webhooks under /hooks, the request must carry an Origin header equal to the App's origin, which a same-origin fetch() sends automatically. A cross-site form post answers 403 Forbidden. This is a cross-site-request defense, not authorization: it stops another site's page from signing the user out, not a client that already holds the cookie.
Call it from the App's own code, then send the user wherever sign-out should land:
await fetch('/_auth/logout', { method: 'POST' });
location.replace('https://portal.example.edu/');
Only the session for the context making the request ends. Signing out of an App opened in its own tab does not sign out the same App embedded in Informer, and vice versa.
Share links: sign-out ends the App-origin session only. A visitor who arrived through an External Link keeps the link's recovery credential on the Informer origin for the life of the link, so navigating back to the App origin re-establishes the session without a prompt. That is deliberate: one recipient signing out must not revoke a link other people hold. Revoke the External Link when it should stop working, and prefer sharing Apps that offer sign-out with signed-in users.
Portal integrations: a portal that provisions Informer sessions for its users (for example via POST /api/_sync-user) cannot clear a cookie on the App origin from its own backend. Point the portal's sign-out at an App route (say /signout) whose page posts to /_auth/logout and then navigates back to the portal. The portal's backend still ends the Informer session with POST /api/logout, sending the session's access_token as a bearer token.
Error Responses:
400 Bad Request: malformedCookieheader401 Unauthorized: the App origin's tenant could not be resolved403 Forbidden: missing or mismatchedOriginheader404 Not Found: the request did not arrive on an App origin
Snapshots
Apps can have snapshots for version control and rollback.
GET /api/apps/{id}/snapshots
List all snapshots for an app.
Authentication: Required
Permissions Required: Member+ role (write permission)
Response:
[
{
"id": "snapshot-uuid-1",
"appId": "d4f8a2b1-1234-5678-90ab-cdef12345678",
"name": "Before redesign",
"createdBy": "john.doe",
"createdAt": "2024-02-10T10:00:00.000Z"
}
]
POST /api/apps/{id}/snapshots
Create a snapshot of the current app state.
Authentication: Required
Permissions Required: Member+ role (write permission)
Payload:
{
"name": "Before redesign"
}
Response:
Returns the created snapshot.
{
"id": "snapshot-uuid-1",
"appId": "d4f8a2b1-1234-5678-90ab-cdef12345678",
"name": "Before redesign",
"createdBy": "john.doe",
"createdAt": "2024-02-13T10:30:00.000Z"
}
PUT /api/apps/{id}/snapshots/{snapshotId}
Pin or label a snapshot.
Authentication: Required
Permissions Required: Member+ role (write permission)
| Field | Type | Required | Description |
|---|---|---|---|
pinned | boolean | Yes | Pinned snapshots are exempt from the retention cap. |
label | string | No | Optional label (empty/null clears it). |
{
"pinned": true,
"label": "Baseline"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/snapshots/00000000-0000-4000-8000-000000000002"
}
},
"id": "00000000-0000-4000-8000-000000000002",
"trigger": "manual",
"source": "production",
"label": "Baseline",
"pinned": true,
"createdAt": "2026-01-15T16:20:00.000Z",
"hasWorkspaceBackup": false
}
DELETE /api/apps/{id}/snapshots/{snapshotId}
Delete a snapshot.
Authentication: Required
Permissions Required: Member+ role (write permission)
Response:
Returns 204 No Content on success.
POST /api/apps/{id}/snapshots/{snapshotId}/_restore
Restore an app to a previous snapshot.
Authentication: Required
Permissions Required: Member+ role (write permission)
Response:
Returns the restored app.
Behavior:
- App's defn, settings, and library are restored to snapshot state
- Current state is not automatically snapshotted (create one manually if needed)
- Restoration cannot be undone (unless you have another snapshot)
Error Responses:
409 Conflict- The app has a staged deploy. A restore replaces the live library's files, which the pending promotion would then replace again, so the restored build would never reach anyone. Wait for the deploy, or discard it withDELETE /files/_stage, then restore.
Data Access Whitelist
Apps should include an informer.yaml file in their library root to configure API access:
# informer.yaml
access:
datasets:
- admin:sales-data
- admin:customers
queries:
- admin:monthly-summary
integrations:
- salesforce
apis:
- POST /api/custom/endpoint
Resource types:
| Type | API Access Granted |
|---|---|
datasets | _search, fields |
queries | _execute |
datasources | _query |
integrations | request |
libraries | contents/*, files/*/contents |
apis | Exact method + path match |
Default Behavior:
Without an informer.yaml file (or its access section), the proxy denies all requests.
Common Runtime Patterns
Initialize App with Context
// In your app's JavaScript
const context = window.__INFORMER__;
console.log('Running as:', context.user.displayName);
console.log('Available datasets:', Object.keys(context.datasets));
console.log('User roles:', context.roles);
// Use the view token for API calls
fetch(`/api/apps/${context.report.id}/view/api/datasets/${context.datasets.sales.id}/data`, {
headers: {
'Authorization': `Bearer ${context.viewToken}`
}
});
Load Dataset Data
async function loadSalesData() {
const context = window.__INFORMER__;
const datasetId = context.datasets.sales.id;
const response = await fetch(
`/api/apps/${context.report.id}/view/api/datasets/${datasetId}/data?limit=1000`
);
if (!response.ok) {
throw new Error('Failed to load data');
}
const data = await response.json();
return data.items;
}
Run a Query
async function runQuery(filters) {
const context = window.__INFORMER__;
const datasetId = context.datasets.sales.id;
const response = await fetch(
`/api/apps/${context.report.id}/view/api/datasets/${datasetId}/run`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters })
}
);
return await response.json();
}
Handle Errors
async function fetchWithErrorHandling(path) {
const context = window.__INFORMER__;
const url = `/api/apps/${context.report.id}/view/api/${path}`;
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 403) {
throw new Error('This API endpoint is not whitelisted');
} else if (response.status === 404) {
throw new Error('Resource not found');
} else {
throw new Error(`API error: ${response.status}`);
}
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
Create Snapshot Before Changes
// Create a snapshot
const snapshot = await POST('/api/apps/analytics:sales-dashboard/snapshots', {
name: `Before ${new Date().toLocaleDateString()}`
});
// Make changes...
await PUT('/api/apps/analytics:sales-dashboard/contents/index.html', {
content: newHTML
});
// If something goes wrong, restore
await POST(`/api/apps/analytics:sales-dashboard/snapshots/${snapshot.id}/_restore`);
Security Best Practices
For App Developers
- Use the Proxy: Never try to access
/api/directly - Validate Input: Always validate user input before making API calls
- Handle Errors: API calls may fail due to permissions or whitelist restrictions
- Respect Read-Only: Don't assume write permissions
For App Administrators
- Configure Whitelist: Always include an
informer.yamlfile with anaccesssection - Minimize Access: Only whitelist necessary endpoints
- Review Regularly: Audit app API usage periodically
- Use Snapshots: Create snapshots before major changes
- Test Thoroughly: Test apps with different user roles
For Platform Administrators
- Monitor Usage: Track app API calls for abuse
- Enforce Limits: Rate-limit app API calls if needed
- Review Whitelists: Audit informer.yaml files in apps
- Educate Users: Train developers on security best practices