diff --git a/docs/archive/notes/artifact-app-action-proxy-design.md b/docs/archive/notes/artifact-app-action-proxy-design.md new file mode 100644 index 00000000..642e2589 --- /dev/null +++ b/docs/archive/notes/artifact-app-action-proxy-design.md @@ -0,0 +1,293 @@ +# Artifact App Action Proxy Design + +Status: design note, updated for Centaur iron-control changes on 2026-07-15. +See also `artifact-apps-centaur-connector-addendum.md` for the Centaur-specific +credential-plane implications behind this revision. + +This document covers the next layer after static artifact apps: allowing a +published artifact app to ask Atrium to perform a narrow, typed action. It is +not the static app hosting path itself. Static hosting should keep the existing +shape: isolated apps origin, signed launch URL, sandboxed iframe, and strict CSP. + +## Decision + +Keep the browser artifact app static and sandboxed. Add an Atrium action proxy +between the iframe and any privileged operation. + +The Centaur changes alter the credential layer, not the browser boundary. Do not +invent an artifact-specific API-key system for outside apps. Use Atrium for app, +version, user, channel, capability, idempotency, confirmation, and audit checks; +then execute the action server-side using Atrium internal adapters or +iron-control-backed credentials. + +```text +artifact iframe + postMessage(action, payload, idempotencyKey) +Atrium web shell + checks frame origin + app launch context +Atrium server + checks app version, viewer, workspace/channel, declared capability, + confirmation policy, idempotency, rate limit, and audit trail +Surface adapter or worker + executes Atrium/internal action directly, or calls external API through + an iron-control-backed grant +``` + +The artifact iframe must not receive raw API keys, OAuth access tokens, Centaur +admin keys, or broad bearer tokens. + +## What Centaur changes + +Centaur now has a useful substrate for credentials that previously looked like +something artifact apps might need to build themselves: + +- principals for users, channels, and execution contexts; +- roles and direct grants; +- OAuth broker credentials with refresh handled outside the app; +- static/header injection, OAuth-token injection, and Postgres DSN routing; +- request rules that bind credentials to specific hosts/headers/paths; +- proxy sync so a sandbox or worker proxy gets only the active principal's + effective config. + +That means artifact app actions should reuse this credential plane when an action +needs an outside service. The action proxy still belongs in Surface because it +knows Atrium users, sessions, channels, app versions, and UI confirmation state. +Centaur should not become the browser-facing authorization layer for artifact +apps. + +## Non-goals + +- Letting generated apps make arbitrary network requests. +- Giving apps API keys for Atrium. +- Letting apps call Centaur or iron-control directly. +- Running generated server code for v1. +- Relaxing static app CSP broadly. Network access should be introduced through a + typed bridge, not `connect-src *`. + +## App Identity + +Every action request must bind to an immutable app version, not just an app name. +Use the existing app registry concepts: + +- `app_id` +- `version` +- `workspace_id` +- optional `channel_id` +- `entry_path` +- launch grant / launch instance id +- current viewer user id + +The server should reject actions for unpublished apps, stale or unknown versions, +and app versions the viewer could not launch. + +## Manifest Contract + +Extend `atrium.app.json` with explicit action declarations. The manifest should +describe intent; it should not contain secrets. + +```jsonc +{ + "name": "member-tools", + "kind": "static", + "entry": "index.html", + "actions": { + "atrium.channel.add_member": { + "title": "Add member to channel", + "description": "Invite a workspace user into the current channel.", + "confirm": "always", + "idempotency": "required", + "input_schema": { + "type": "object", + "required": ["channel_id", "user_id"], + "properties": { + "channel_id": { "type": "string", "format": "uuid" }, + "user_id": { "type": "string", "format": "uuid" } + }, + "additionalProperties": false + } + } + } +} +``` + +Manifest validation should happen at publish time. Launch should expose only the +validated action names and schemas for that frozen version. + +## Browser Bridge + +The iframe should communicate with the Atrium web shell by `postMessage`. + +Request: + +```json +{ + "type": "atrium.action.request", + "request_id": "client-generated-id", + "action": "atrium.channel.add_member", + "payload": { "channel_id": "...", "user_id": "..." }, + "idempotency_key": "uuid-or-stable-operation-id" +} +``` + +Response: + +```json +{ + "type": "atrium.action.response", + "request_id": "client-generated-id", + "ok": true, + "result": { "status": "added" } +} +``` + +The web shell is responsible for: + +- accepting messages only from the iframe it launched; +- attaching app launch context the iframe cannot forge; +- showing confirmation UI when policy requires it; +- sending the request to Surface with same-origin user credentials; +- returning only the result shape allowed for that action. + +## Server Route Shape + +V1 can use one generic route: + +```text +POST /api/apps/:appId/versions/:version/actions/:action +``` + +Request body: + +```json +{ + "launch_id": "...", + "request_id": "...", + "idempotency_key": "...", + "payload": {} +} +``` + +Required server checks: + +- user is authenticated; +- user can still access the app's workspace/channel; +- app and version are published; +- `launch_id` was minted for this user/app/version and is unexpired; +- action exists in the frozen manifest; +- payload validates against the frozen input schema; +- action is permitted in this app scope; +- confirmation requirement is satisfied; +- idempotency key is present for writes and scoped to user/app/version/action; +- rate limits pass; +- full request and outcome are audited without logging secrets. + +## Capability Model + +Use separate namespaces for action capabilities: + +- `atrium.*` for native Atrium operations; +- `connector..*` for outside app operations; +- `db..*` for explicitly configured database actions; +- `custom..*` for later operator-defined adapters. + +For Atrium-native actions, Surface can execute directly after checking the +viewer has the same permission the normal UI/API would require. + +For outside app actions, Surface should resolve the viewer/channel principal and +execute through a server-side adapter or worker that uses iron-control-backed +credentials. The app sees neither the credential nor the upstream host-level +proxy config. + +## Confirmation And Writes + +Read-only actions may be allowed without an interstitial if the manifest and +workspace policy permit it. + +Writes should require: + +- a human-visible preview of the target and effect; +- explicit confirmation for the first version of v1; +- idempotency key; +- action-specific validation; +- audit entry with app id, version, user, channel/workspace, action, payload + summary, result status, and upstream request id when available. + +Dangerous or broad actions should stay out of v1. + +## Credential Handling + +Do not add artifact app API keys. + +When an external credential is needed: + +1. The user or operator connects the upstream account through the normal + connection / iron-control path. +2. The credential is represented as an iron-control broker/static/OAuth/Postgres + secret with request rules. +3. A role or direct grant gives the relevant principal access. +4. The Surface action adapter executes under that principal and receives only the + narrow effective credential path it needs. + +This keeps the static artifact app model intact while reusing Centaur's newer +credential plane. + +## V1 Slice + +Start with one Atrium-native write action and one read action. + +Suggested first actions: + +- `atrium.channel.list_members` +- `atrium.channel.add_member` + +This tests the end-to-end action proxy without external credentials: + +- manifest declaration; +- postMessage bridge; +- same-origin Surface action route; +- user/channel permission check; +- confirmation UI for write; +- idempotency; +- audit event. + +Only after that should we add an external action backed by iron-control, for +example a read-only GitHub or Google Sheets action. + +## Data Model Additions + +Likely tables: + +- `app_action_manifests` + - app id, version, action name, schema, policy, created timestamp +- `app_action_invocations` + - app id, version, user id, workspace/channel, action name, idempotency key, + payload hash, status, result summary, error code, upstream request id, + timestamps + +If the manifest JSON is already frozen in app version metadata by the time this +is built, `app_action_manifests` can be derived instead of stored separately. +The invocation/audit table is still useful. + +## Security Invariants + +- App action authority comes from the current viewer plus the frozen app version, + not from generated code. +- The iframe cannot mint its own app identity. +- The iframe cannot call Surface APIs directly; CSP remains narrow and the web + shell is the bridge. +- The server revalidates everything the web shell claims. +- Secrets stay outside the iframe and outside artifact source files. +- External credentials are scoped by principal and request rules. +- Every write is idempotent and audited. + +## Open Questions + +- Should action availability be granted at publish time, launch time, or both? +- Does app action approval belong to the app version, the app name, or the + workspace/channel policy? +- What is the right UX for recurring trust: always confirm, trust this version, + or trust this action for this channel? +- Should action adapters run inside Surface, a worker, or a Centaur-managed + execution context once external credentials are involved? +- How much of the iron-control principal mapping should Surface own directly vs. + delegate to Centaur APIs? diff --git a/docs/archive/notes/artifact-apps-centaur-connector-addendum.md b/docs/archive/notes/artifact-apps-centaur-connector-addendum.md new file mode 100644 index 00000000..866826b3 --- /dev/null +++ b/docs/archive/notes/artifact-apps-centaur-connector-addendum.md @@ -0,0 +1,130 @@ +# Artifact Apps Centaur Connector Addendum + +Status: addendum to the artifact app action proxy design, written after the +Centaur iron-control / connector changes were reviewed on 2026-07-15. + +## Short Version + +Rethink the credential layer, not the browser action proxy. + +Centaur now has the right substrate for outside-app credentials: principals, +roles, grants, OAuth broker credentials, static/header injection, request rules, +Postgres DSN routing, and proxy sync. Artifact apps should still call typed +Atrium app actions from the browser. Those server-side actions should use +iron-control-backed grants instead of a new artifact-specific API-key system. + +Main implication: keep browser artifacts static and sandboxed. Do not let them +become arbitrary privileged network clients. Use the action proxy for +app/version/user/channel/capability/idempotency/audit checks, then have Surface +or a worker execute against Atrium/internal adapters or external APIs using +Centaur's credential plane. + +## What Changed In Centaur + +The relevant Centaur pieces now exist in the vendored tree: + +- `centaur/services/api-rs/crates/centaur-iron-control` has an admin client for + principals, roles, grants, secrets, broker credentials, Postgres DSNs, and + effective config. +- `centaur/services/console` owns the iron-control UI and persistence model. +- `centaur/docs/public/md/secrets/oauth-apps.md` describes user-connected OAuth + apps and grantable refreshed tokens. +- `centaur/docs/public/md/secrets/advanced-permissioning.md` describes principal + and role grants. +- `surface/server/src/iron-control.ts` and `surface/server/src/github-iron-control.ts` + show Surface already integrating with this plane for GitHub credentials. + +Before this existed, artifact app actions looked like they might need a small +credential system of their own: generate Atrium API keys, hand a scoped key to a +mini-app, and let that app call a generic proxy. That is the wrong direction now. + +## Revised Responsibility Split + +Artifact app browser runtime: + +- renders static files from the apps origin; +- stays inside a sandboxed iframe; +- requests typed actions through the web shell; +- never receives raw credentials. + +Atrium web shell: + +- validates the iframe it launched; +- attaches launch context the iframe cannot forge; +- handles user confirmation; +- calls Surface with normal same-origin user credentials. + +Surface action proxy: + +- validates the frozen app version and declared action; +- checks user/workspace/channel access; +- validates payload schema; +- enforces idempotency and rate limits; +- writes audit rows; +- executes Atrium-native actions directly or dispatches external actions to a + credential-aware adapter/worker. + +Centaur / iron-control: + +- stores and refreshes outside-app credentials; +- models users/channels/execution contexts as principals; +- grants secrets or roles to those principals; +- syncs only the effective config needed by a proxy/worker; +- injects credentials on matching requests instead of exposing them to generated + app code. + +## Design Consequences + +Do not add artifact app API keys. + +Do not let artifact iframes call Centaur or iron-control directly. + +Do not relax the static app CSP into general network access as the first bridge. +Generated apps that can call arbitrary URLs are too hard to reason about and too +easy to turn into data-exfiltration tools. + +Do add a typed app action layer where the generated app says "perform this named +operation with this validated payload" and Surface decides whether that operation +is allowed for the viewer, app version, and workspace/channel. + +## First Implementation Shape + +The first code slice should freeze action declarations at publish time from +`atrium.app.json`. That creates the enforcement surface without yet executing +privileged work: + +```jsonc +{ + "name": "member-tools", + "kind": "static", + "entry": "index.html", + "actions": { + "atrium.channel.add_member": { + "title": "Add member to channel", + "confirm": "always", + "idempotency": "required", + "input_schema": { + "type": "object", + "required": ["channel_id", "user_id"], + "properties": { + "channel_id": { "type": "string", "format": "uuid" }, + "user_id": { "type": "string", "format": "uuid" } + }, + "additionalProperties": false + } + } + } +} +``` + +Later slices can add the browser `postMessage` bridge, the Surface action route, +confirmation UI, audit table, and one Atrium-native action before attempting any +external connector action. + +## Open Design Point + +External action execution may live in Surface for simple server-owned adapters, +or in a worker/proxy path when it needs iron-control effective config. The +important boundary is that generated app code never chooses or receives the +credential. It requests a typed action; trusted server-side code resolves the +principal and credential path. diff --git a/surface/server/migrations/083_app_version_actions.sql b/surface/server/migrations/083_app_version_actions.sql new file mode 100644 index 00000000..f38405ce --- /dev/null +++ b/surface/server/migrations/083_app_version_actions.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS app_version_actions ( + app_id uuid NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + version integer NOT NULL, + action_name text NOT NULL, + title text, + description text, + confirm_policy text NOT NULL DEFAULT 'always', + idempotency_policy text NOT NULL DEFAULT 'required', + input_schema jsonb NOT NULL DEFAULT '{"type":"object","additionalProperties":false}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (app_id, version, action_name), + CONSTRAINT app_version_actions_version_check + CHECK (version > 0), + CONSTRAINT app_version_actions_confirm_policy_check + CHECK (confirm_policy IN ('always', 'never')), + CONSTRAINT app_version_actions_idempotency_policy_check + CHECK (idempotency_policy IN ('required', 'optional')), + CONSTRAINT app_version_actions_name_check + CHECK (action_name ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$') +); + +CREATE INDEX IF NOT EXISTS app_version_actions_lookup_idx + ON app_version_actions (app_id, version); diff --git a/surface/server/src/app-registry.ts b/surface/server/src/app-registry.ts index fb4137a9..cc9f4db1 100644 --- a/surface/server/src/app-registry.ts +++ b/surface/server/src/app-registry.ts @@ -21,6 +21,7 @@ export interface PublishedApp { version: number; files: number; entry: string; + actions: number; } export interface AppRegistryOptions { @@ -54,7 +55,17 @@ export interface ResolvedAppFile { sizeBytes: number; } +export interface AppActionDeclaration { + name: string; + title: string | null; + description: string | null; + confirmPolicy: 'always' | 'never'; + idempotencyPolicy: 'required' | 'optional'; + inputSchema: Record; +} + const APP_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const ACTION_NAME_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/; export class AppRegistry { constructor( @@ -86,6 +97,7 @@ export class AppRegistry { throw new DomainError(400, 'app_entry_missing', 'entry file is missing from app source'); } await this.validateEntryAssets(entry, entryFile.s3_key!, byPath); + const actions = await this.readActionManifest(byPath); const appRow = await findOrCreateApp(client, { workspaceId: args.workspaceId, @@ -114,13 +126,30 @@ export class AppRegistry { ], ); } + for (const action of actions) { + await client.query( + `INSERT INTO app_version_actions + (app_id, version, action_name, title, description, confirm_policy, idempotency_policy, input_schema) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, + [ + appRow.id, + version, + action.name, + action.title, + action.description, + action.confirmPolicy, + action.idempotencyPolicy, + JSON.stringify(action.inputSchema), + ], + ); + } await client.query( `UPDATE apps SET current_version = $2, entry_path = $3, status = 'published', updated_at = now() WHERE id = $1`, [appRow.id, version, entry], ); - return { appId: appRow.id, version, files: frozen.length, entry }; + return { appId: appRow.id, version, files: frozen.length, entry, actions: actions.length }; }); } @@ -164,7 +193,7 @@ export class AppRegistry { appId: string, userId: string, version?: number, - ): Promise<{ url: string; expires: number; version: number }> { + ): Promise<{ url: string; expires: number; version: number; actions: AppActionDeclaration[] }> { const app = await this.pool.query<{ current_version: number; entry_path: string; status: string }>( `SELECT current_version, entry_path, status FROM apps a @@ -194,7 +223,7 @@ export class AppRegistry { ); const base = this.options.appsOrigin.replace(/\/+$/, ''); const url = `${base}/apps/${appId}/v/${launchVersion}/g/${expires}/${encodeURIComponent(sig)}/${encodeRelPath(row.entry_path)}`; - return { url, expires, version: launchVersion }; + return { url, expires, version: launchVersion, actions: await this.listVersionActions(appId, launchVersion) }; } async resolveFile(appId: string, version: number, relPath: string): Promise { @@ -243,6 +272,44 @@ export class AppRegistry { } } } + + private async readActionManifest(files: Map): Promise { + const manifest = files.get('atrium.app.json'); + if (!manifest?.s3_key) return []; + if (!this.options.storage) return []; + let parsed: unknown; + try { + parsed = JSON.parse((await this.options.storage.getObjectBytes(manifest.s3_key)).toString('utf8')); + } catch { + throw new DomainError(400, 'bad_app_manifest', 'atrium.app.json must be valid JSON'); + } + return parseActionDeclarations(parsed); + } + + private async listVersionActions(appId: string, version: number): Promise { + const res = await this.pool.query<{ + action_name: string; + title: string | null; + description: string | null; + confirm_policy: 'always' | 'never'; + idempotency_policy: 'required' | 'optional'; + input_schema: Record; + }>( + `SELECT action_name, title, description, confirm_policy, idempotency_policy, input_schema + FROM app_version_actions + WHERE app_id = $1 AND version = $2 + ORDER BY action_name ASC`, + [appId, version], + ); + return res.rows.map((row) => ({ + name: row.action_name, + title: row.title, + description: row.description, + confirmPolicy: row.confirm_policy, + idempotencyPolicy: row.idempotency_policy, + inputSchema: row.input_schema, + })); + } } interface FrozenFile { @@ -409,6 +476,63 @@ function resolveRelativeAsset(baseDir: string, ref: string): string | null { } } +function parseActionDeclarations(manifest: unknown): AppActionDeclaration[] { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []; + const actions = (manifest as { actions?: unknown }).actions; + if (actions == null) return []; + if (!actions || typeof actions !== 'object' || Array.isArray(actions)) { + throw new DomainError(400, 'bad_app_actions', 'manifest actions must be an object'); + } + return Object.entries(actions as Record).map(([name, value]) => parseActionDeclaration(name, value)); +} + +function parseActionDeclaration(name: string, value: unknown): AppActionDeclaration { + if (!ACTION_NAME_RE.test(name)) { + throw new DomainError(400, 'bad_app_action_name', 'action names must be dotted safe identifiers'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new DomainError(400, 'bad_app_action', `action ${name} must be an object`); + } + const record = value as Record; + const confirmPolicy = optionalEnum(record.confirm, ['always', 'never'] as const, 'always', `action ${name} confirm`); + const idempotencyPolicy = optionalEnum( + record.idempotency, + ['required', 'optional'] as const, + confirmPolicy === 'always' ? 'required' : 'optional', + `action ${name} idempotency`, + ); + const inputSchema = record.input_schema ?? { type: 'object', additionalProperties: false }; + if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema)) { + throw new DomainError(400, 'bad_app_action_schema', `action ${name} input_schema must be an object`); + } + return { + name, + title: optionalString(record.title, `action ${name} title`), + description: optionalString(record.description, `action ${name} description`), + confirmPolicy, + idempotencyPolicy, + inputSchema: inputSchema as Record, + }; +} + +function optionalString(value: unknown, label: string): string | null { + if (value == null) return null; + if (typeof value !== 'string') throw new DomainError(400, 'bad_app_action', `${label} must be a string`); + const trimmed = value.trim(); + return trimmed || null; +} + +function optionalEnum( + value: unknown, + allowed: T, + fallback: T[number], + label: string, +): T[number] { + if (value == null) return fallback; + if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T[number]; + throw new DomainError(400, 'bad_app_action', `${label} must be one of: ${allowed.join(', ')}`); +} + function encodeRelPath(relPath: string): string { return relPath.split('/').map(encodeURIComponent).join('/'); } diff --git a/surface/server/test/apps.test.ts b/surface/server/test/apps.test.ts index 2bcc9a6b..9ccef1fc 100644 --- a/surface/server/test/apps.test.ts +++ b/surface/server/test/apps.test.ts @@ -113,6 +113,114 @@ describe('app registry publish/list/launch', () => { ]); }); + it('freezes action declarations from atrium.app.json and returns them at launch', async () => { + const registry = await appRegistry(); + await capture('apps/actions/index.html', '7'.repeat(64), '

Actions

'); + await capture( + 'apps/actions/atrium.app.json', + '8'.repeat(64), + JSON.stringify({ + name: 'actions', + kind: 'static', + entry: 'index.html', + actions: { + 'atrium.channel.add_member': { + title: 'Add member', + description: 'Invite a member into this channel.', + confirm: 'always', + idempotency: 'required', + input_schema: { + type: 'object', + required: ['channel_id', 'user_id'], + properties: { + channel_id: { type: 'string', format: 'uuid' }, + user_id: { type: 'string', format: 'uuid' }, + }, + additionalProperties: false, + }, + }, + }, + }), + 'application/json', + ); + + const published = await registry.publish({ + sessionId, + workspaceId: fx.workspaceId, + channelId: fx.channelId, + userId: fx.userId, + name: 'actions', + scope: 'channel', + entry: 'index.html', + }); + + expect(published).toMatchObject({ version: 1, actions: 1 }); + + await capture( + 'apps/actions/atrium.app.json', + '9'.repeat(64), + JSON.stringify({ + actions: { + 'atrium.channel.list_members': { + title: 'List members', + confirm: 'never', + idempotency: 'optional', + }, + }, + }), + 'application/json', + ); + + const launch = await registry.launch(published.appId, fx.userId, 1); + expect(launch.actions).toEqual([ + { + name: 'atrium.channel.add_member', + title: 'Add member', + description: 'Invite a member into this channel.', + confirmPolicy: 'always', + idempotencyPolicy: 'required', + inputSchema: { + type: 'object', + required: ['channel_id', 'user_id'], + properties: { + channel_id: { type: 'string', format: 'uuid' }, + user_id: { type: 'string', format: 'uuid' }, + }, + additionalProperties: false, + }, + }, + ]); + }); + + it('rejects malformed action declarations at publish time', async () => { + const registry = await appRegistry(); + await capture('apps/bad-actions/index.html', '0'.repeat(64), '

Bad actions

'); + await capture( + 'apps/bad-actions/atrium.app.json', + 'a1'.padEnd(64, 'a'), + JSON.stringify({ + actions: { + 'not safe': { + input_schema: { type: 'object' }, + }, + }, + }), + 'application/json', + ); + + await expect( + registry.publish({ + sessionId, + workspaceId: fx.workspaceId, + channelId: fx.channelId, + userId: fx.userId, + name: 'bad-actions', + scope: 'channel', + entry: 'index.html', + }), + ).rejects.toMatchObject({ code: 'bad_app_action_name' }); + }); + it('rejects missing durable blobs, missing entries, deletes, and dangling assets', async () => { const registry = await appRegistry(); await capture('apps/bad/index.html', 'd'.repeat(64), ''); diff --git a/surface/web/src/sessions/api.ts b/surface/web/src/sessions/api.ts index b79dbf0c..1d57aaba 100644 --- a/surface/web/src/sessions/api.ts +++ b/surface/web/src/sessions/api.ts @@ -20,6 +20,15 @@ import { ApiError } from '../api'; import { desktopApiOptions } from '../desktop'; import type { SessionListItem, SessionRepoSpec, SessionWire } from './types'; +export interface AppActionDeclaration { + name: string; + title: string | null; + description: string | null; + confirmPolicy: 'always' | 'never'; + idempotencyPolicy: 'required' | 'optional'; + inputSchema: Record; +} + export interface CreateSessionBody { channelId: string; threadRootEventId?: number; @@ -237,8 +246,8 @@ export const sessionsApi = { publishApp( sessionId: string, body: { name: string; entry?: string; scope?: 'channel' | 'workspace' }, - ): Promise<{ appId: string; version: number; files: string[]; entry: string }> { - return reqJson<{ appId: string; version: number; files: string[]; entry: string }>( + ): Promise<{ appId: string; version: number; files: number; entry: string; actions: number }> { + return reqJson<{ appId: string; version: number; files: number; entry: string; actions: number }>( `/api/sessions/${sessionId}/apps`, { method: 'POST', @@ -247,11 +256,17 @@ export const sessionsApi = { ); }, - launchApp(appId: string, version?: number): Promise<{ url: string; expires: string; version: number }> { - return reqJson<{ url: string; expires: string; version: number }>(`/api/apps/${appId}/launch`, { - method: 'POST', - body: JSON.stringify(version === undefined ? {} : { version }), - }); + launchApp( + appId: string, + version?: number, + ): Promise<{ url: string; expires: number; version: number; actions: AppActionDeclaration[] }> { + return reqJson<{ url: string; expires: number; version: number; actions: AppActionDeclaration[] }>( + `/api/apps/${appId}/launch`, + { + method: 'POST', + body: JSON.stringify(version === undefined ? {} : { version }), + }, + ); }, // ---- driver seat (Phase 3) ---- diff --git a/surface/web/test/appsSurface.test.tsx b/surface/web/test/appsSurface.test.tsx index dfd4a90c..db38b699 100644 --- a/surface/web/test/appsSurface.test.tsx +++ b/surface/web/test/appsSurface.test.tsx @@ -55,13 +55,15 @@ describe('AppsSurface', () => { vi.spyOn(sessionsApi, 'publishApp').mockResolvedValue({ appId: 'app-1', version: 1, - files: ['shared/apps/demo/index.html'], + files: 1, entry: 'index.html', + actions: 0, }); vi.spyOn(sessionsApi, 'launchApp').mockResolvedValue({ url: 'https://apps.example/demo', - expires: '2026-06-25T13:00:00Z', + expires: 1_782_391_600, version: 2, + actions: [], }); vi.spyOn(window, 'open').mockImplementation(() => null); });