From d3fa5fed2a1e9ecc53768c835102b6bd1880e8d4 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Thu, 24 Sep 2026 13:43:15 +0200 Subject: [PATCH 1/2] Plugin frames: capabilities for the integration proxy instead of a relay (atomic-plugins#54) Replaces #1657's `proxy` relay op, which kept LocalThought's rotating connection code in the page and made every call for the frame. The #54 proxy has no tenant login and no connection codes (decision 6, flag day), so the relay is removed rather than kept alongside. Frame (server/src/plugins/assets/view-client.js): - generates an Ed25519 key in memory, non-extractable, on first use; fails with a clear message where WebCrypto has no Ed25519 (#1688); - asks the page for a capability (`proxyCapability` {platform, connectionId, publicKey}), caches it until 60 s before `exp`; - fetches `{aud}/proxy/{connection_id}/{platform}{path}` itself with `Authorization: Capability .` and v2 request-signature headers signed by the frame key; re-mints once on 401 `capability_expired`. `store.proxy.request(...)` keeps its signature and `{status, headers, body}` result. Page (helpers/proxyConnections.ts, AppFrame, ProxyConnectReturn): - every call to the proxy is signed v2 with the user key, as `atomic:agent:`: POST /connect/redeem, GET /connections, POST /connections/{id}/agents, DELETE /connections/{id}[/agents/{agent}]; - /connect without `user_id`; the PKCE verifier is the only thing kept in localStorage, for the handoff window; - after redeem, the connection is delegated to the app's agent (from GET /app-agent); the connect bar also offers an existing connection for the platform, which only adds the delegation; - mints capabilities only for a connection the user owns, of that platform, delegated to this app: claims {v:2, connection_id, platform, aud, app, cnf: atomic:agent:, exp: now + 600 s}, payload base64url(JSON), signature by the user key over "integration-proxy-capability-v2\n" + JSON. - Apps without their own agent (installed catalog plugins) are told they cannot be given a connection yet (#54 decision 2, later). A fixed capability vector (user key of authentication_v2_vectors.json) is pinned in proxyConnections.test.ts for the proxy's tests. Co-Authored-By: Claude Opus 5.5 --- browser/CHANGELOG.md | 1 + .../src/chunks/AppPage/AppFrame.tsx | 173 ++++-- .../src/chunks/AppPage/ProxyConnectReturn.tsx | 57 +- .../src/chunks/AppPage/appAgent.ts | 40 ++ .../src/chunks/AppPage/hostStore.test.ts | 78 ++- .../src/chunks/AppPage/hostStore.ts | 40 +- .../src/helpers/proxyConnections.test.ts | 386 +++++++++--- .../src/helpers/proxyConnections.ts | 579 ++++++++++-------- browser/data-browser/src/locales/de.po | 15 +- browser/data-browser/src/locales/en.po | 19 +- browser/data-browser/src/locales/es.po | 15 +- browser/data-browser/src/locales/fr.po | 15 +- browser/plugin/src/viewProtocol.test.ts | 199 +++++- browser/plugin/src/viewProtocol.ts | 11 +- server/src/plugins/assets/view-client.js | 215 ++++++- 15 files changed, 1382 insertions(+), 461 deletions(-) diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 318307ee61..e0d38f1df8 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol ## UNRELEASED +- App frames call the integration proxy directly, with a capability instead of a relay (ontola/atomic-plugins#54; replaces #1657's `proxy` relay and LocalThought's rotating connection codes, which the new proxy no longer issues). A frame makes its own non-extractable Ed25519 key in memory; the page checks that the connection is delegated to the app and signs a capability bound to that key (`integration-proxy-capability-v2`, 10 minutes); each request is signed by the frame key with a version 2 request signature. `store.proxy.request(...)` keeps its shape; it mints a new capability shortly before expiry and once more on a `capability_expired` refusal. View op `proxy` is gone, `proxyCapability` is new. Connecting no longer logs in at the proxy: the page redeems the handoff signed with the user key, which makes the user the connection's owner, then delegates it to the app (also signed). The connect bar offers an existing connection for the same platform, which only adds a delegation. Only apps with their own agent (made with `createApp`) can be given a connection for now; others are told so. Where WebCrypto has no Ed25519 the frame fails with a message saying so. Existing connections are not migrated: connect again. - `@tomic/lib`: `signRequest(url, agent, headers, { method, body })` makes a version 2 request signature (ontola/atomic-plugins#54), which also covers the method and the SHA-256 of the body and sends `x-atomic-signature-version: 2`. Without `method` it signs version 1 as before, and a string fourth argument is still the legacy subject. New exports `requestSignatureMessageV2`, `sha256Hex` and `SIGNATURE_VERSION_HEADER`. `Agent.generateNonExtractable()` makes an agent whose Ed25519 key is a non-extractable WebCrypto key, with subject `atomic:agent:`; where WebCrypto has no Ed25519 it throws instead of falling back to an extractable key. - Plugins moved to [atomic-plugins](https://github.com/ontola/atomic-plugins). The Integrations page lists what that catalog publishes (`catalog.json`, fetched at runtime; the URL is configurable under Settings > Integration) instead of plugins compiled into the app and server. The bundled GitHub issues, Notion, Pets, MT940/camt.053, Clockify and Google Calendar (Devonian) integrations, the `devonian` dependency and the `/app/devonian-demo` page are removed. The Todoist lens and the Moneybird administration picker are removed too. LocalThought connect and sync, and the "Show API plugins" toggle, are removed from the app: plugins will run in their own iframe and make proxy calls through the host. - Tracker tables get an Issues view: open and closed issues split by the status column (or a boolean column), filtering by title or `#number`, New issue, and Close/Reopen per row. The Issue Tracker template opens it as a tab. diff --git a/browser/data-browser/src/chunks/AppPage/AppFrame.tsx b/browser/data-browser/src/chunks/AppPage/AppFrame.tsx index ffee449358..5bc8be1548 100644 --- a/browser/data-browser/src/chunks/AppPage/AppFrame.tsx +++ b/browser/data-browser/src/chunks/AppPage/AppFrame.tsx @@ -15,7 +15,12 @@ import type { AIAtomicResourceMessageContext } from '@chunks/AI/types'; import resetCss from '../../reset.css?raw'; import { useCreateThemeVars } from '@views/PluginView/useCreateThemeVars'; import { getIntegrationProxy } from '@helpers/integrationProxy'; -import { isPlatformId, ProxyConnections } from '@helpers/proxyConnections'; +import { + isPlatformId, + ProxyConnections, + type ProxyConnection, +} from '@helpers/proxyConnections'; +import { appAgentOf } from './appAgent'; /** Changing installation or destination must discard source tokens and pending replies. */ export function AppFrame(props: Parameters[0]) { @@ -188,7 +193,7 @@ function AppFrameSession({ // One question at a time; a second ask answers the first. const previous = connectAskRef.current; previous?.reply({ id: previous.id, result: { status: 'cancelled' } }); - const ask = { + const ask: ConnectAsk = { id: data.id, platform: data.platform!, reply: session.post, @@ -196,6 +201,17 @@ function AppFrameSession({ connectAskRef.current = ask; setConnectAsk(ask); + // Offer a connection the person already has for this platform, so + // using it for one more app needs no second trip through OAuth. + existingConnections(store, data.platform!) + .then(existing => { + if (connectAskRef.current !== ask || existing.length === 0) return; + const withExisting = { ...ask, existing }; + connectAskRef.current = withExisting; + setConnectAsk(withExisting); + }) + .catch(() => undefined); + return; } @@ -264,35 +280,62 @@ function AppFrameSession({ return ; } + const finishAsk = (reply: HostReply) => { + connectAsk?.reply(reply); + connectAskRef.current = undefined; + setConnectAsk(undefined); + }; + const connect = () => { if (!connectAsk) return; - const actor = store.getAgent()?.subject; - if (!actor) { - connectAsk.reply({ - id: connectAsk.id, - error: 'Sign in to connect an account.', - }); - connectAskRef.current = undefined; - setConnectAsk(undefined); + if (!store.getAgent()) { + finishAsk({ id: connectAsk.id, error: 'Sign in to connect an account.' }); return; } - new ProxyConnections(localStorage, getIntegrationProxy()) - .start({ drive, actor, app }, connectAsk.platform, location.href) + (async () => { + const appAgent = await appAgentOf(store, { drive, app }); + + return proxyConnections(store).start( + { drive, app, appAgent }, + connectAsk.platform, + location.href, + await appLabel(store, app), + ); + })() .then(url => location.assign(url)) - .catch((e: Error) => { - connectAsk.reply({ id: connectAsk.id, error: e.message }); - connectAskRef.current = undefined; - setConnectAsk(undefined); - }); + .catch((e: Error) => finishAsk({ id: connectAsk.id, error: e.message })); + }; + + const pickExisting = (connection: ProxyConnection) => { + if (!connectAsk) return; + + (async () => { + const appAgent = await appAgentOf(store, { drive, app }); + await proxyConnections(store).delegate( + connection.connection_id, + appAgent, + await appLabel(store, app), + ); + })() + .then(() => + finishAsk({ + id: connectAsk.id, + result: { + status: 'connected', + connectionId: connection.connection_id, + platform: connection.platform, + }, + }), + ) + .catch((e: Error) => finishAsk({ id: connectAsk.id, error: e.message })); }; const cancelConnect = () => { - connectAsk?.reply({ id: connectAsk.id, result: { status: 'cancelled' } }); - connectAskRef.current = undefined; - setConnectAsk(undefined); + if (!connectAsk) return; + finishAsk({ id: connectAsk.id, result: { status: 'cancelled' } }); }; const fixIt = () => { @@ -335,13 +378,20 @@ function AppFrameSession({ {connectAsk && ( - This app wants to connect your{' '} + This app wants to use your{' '} {platformName(connectAsk.platform)} account through{' '} - {getIntegrationProxy()}. The connection stays in this browser; the - app can only make requests through it. + {getIntegrationProxy()}. The proxy keeps the connection under your + account; this app may use it until you revoke that. - + {connectAsk.existing?.[0] && ( + + )} + @@ -397,7 +447,7 @@ async function answer( drive, request, table, - proxyRelay(store, app, drive), + proxyHost(store, app, drive), ), }); } catch (e) { @@ -405,23 +455,74 @@ async function answer( } } -/** This app's proxy connections in this page, or none when signed out. */ -function proxyRelay( +/** The configured proxy, managed with the signed-in user's key. */ +function proxyConnections(store: ReturnType) { + return new ProxyConnections(localStorage, getIntegrationProxy(), () => + store.getAgent(), + ); +} + +/** + * Each app's agent, looked up once per page. A failed lookup is forgotten so + * the next request tries again (the app may get an identity meanwhile). + */ +const appAgents = new Map>(); + +function cachedAppAgent( + store: ReturnType, + drive: string, + app: string, +): Promise { + const key = JSON.stringify([store.getServerUrl(), drive, app]); + let found = appAgents.get(key); + + if (!found) { + found = appAgentOf(store, { drive, app }); + found.catch(() => appAgents.delete(key)); + appAgents.set(key, found); + } + + return found; +} + +/** This app's integration-proxy access, or none when signed out. */ +function proxyHost( store: ReturnType, app: string, drive: string, ) { - const actor = store.getAgent()?.subject; - - return actor - ? new ProxyConnections(localStorage, getIntegrationProxy()).relay({ - drive, - actor, - app, - }) + return store.getAgent() + ? proxyConnections(store).host(() => cachedAppAgent(store, drive, app)) : undefined; } +/** The person's own connections for `platform`, most recently used first. */ +async function existingConnections( + store: ReturnType, + platform: string, +): Promise { + if (!store.getAgent()) return []; + const rows = await proxyConnections(store).list(platform); + + return rows.sort((a, b) => + String(b.last_used_at ?? b.created_at ?? '').localeCompare( + String(a.last_used_at ?? a.created_at ?? ''), + ), + ); +} + +/** What a delegation is labelled with at the proxy: the app's name. */ +async function appLabel( + store: ReturnType, + app: string, +): Promise { + try { + return (await store.getResource(app)).title || app; + } catch { + return app; + } +} + /** `pets` -> `Pets`, `github-issues` -> `Github Issues`. */ function platformName(id: string) { return id @@ -434,6 +535,8 @@ interface ConnectAsk { id: number | string; platform: string; reply: (reply: HostReply) => void; + /** Connections the person already has for this platform. */ + existing?: ProxyConnection[]; } type MintResult = { ok: true; token: string } | { ok: false; error: string }; diff --git a/browser/data-browser/src/chunks/AppPage/ProxyConnectReturn.tsx b/browser/data-browser/src/chunks/AppPage/ProxyConnectReturn.tsx index 362e0e8a7c..f1fc1d73d1 100644 --- a/browser/data-browser/src/chunks/AppPage/ProxyConnectReturn.tsx +++ b/browser/data-browser/src/chunks/AppPage/ProxyConnectReturn.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react'; +import { StoreEvents, useStore, type Agent } from '@tomic/react'; import { getIntegrationProxy } from '@helpers/integrationProxy'; import { ProxyConnections } from '@helpers/proxyConnections'; @@ -8,23 +9,28 @@ import { ProxyConnections } from '@helpers/proxyConnections'; * The integration proxy only returns to `/app/integrations`, with * `integration_state`. When that state is a handoff this browser started for * an app (`AppFrame`'s connect bar), this takes over the page before any route - * renders — so no route's own return handling sees it — redeems the code, - * keeps the connection in this page's storage, and goes back to the app, - * which then finds the connection by reference. Any other page load renders - * `children` untouched. + * renders — so no route's own return handling sees it — redeems the code + * signed with the user key (which makes the user the connection's owner), + * delegates the connection to the app, and goes back to the app, which then + * finds the connection by reference. Any other page load renders `children` + * untouched. */ export function ProxyConnectReturn({ children, }: { children: ReactNode; }): React.JSX.Element { + const store = useStore(); const [pending] = useState(() => { const params = new URLSearchParams(location.search); // Settings can hold a proxy value that no longer validates; that is never // a return this browser started. const connections = safely( - () => new ProxyConnections(localStorage, getIntegrationProxy()), + () => + new ProxyConnections(localStorage, getIntegrationProxy(), () => + store.getAgent(), + ), ); return connections?.isReturn(params) ? { connections, params } : undefined; @@ -34,11 +40,23 @@ export function ProxyConnectReturn({ useEffect(() => { if (!pending) return; history.replaceState(history.state, '', location.pathname); - pending.connections - .finish(pending.params) - .then(({ returnTo }) => location.replace(returnTo)) - .catch((e: Error) => setError(e.message)); - }, [pending]); + let cancelled = false; + + // Redeeming is signed with the user key, which may still be loading this + // early in a page load. + waitForAgent(store) + .then(() => pending.connections.finish(pending.params)) + .then(({ returnTo }) => { + if (!cancelled) location.replace(returnTo); + }) + .catch((e: Error) => { + if (!cancelled) setError(e.message); + }); + + return () => { + cancelled = true; + }; + }, [pending, store]); if (!pending) return <>{children}; @@ -49,6 +67,25 @@ export function ProxyConnectReturn({ ); } +/** The signed-in agent, once there is one; gives up after 30 s. */ +function waitForAgent(store: ReturnType): Promise { + const now = store.getAgent(); + if (now) return Promise.resolve(now); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off(); + reject(new Error('Sign in to finish connecting this account.')); + }, 30_000); + const off = store.on(StoreEvents.AgentChanged, agent => { + if (!agent) return; + clearTimeout(timer); + off(); + resolve(agent); + }); + }); +} + function safely(make: () => T): T | undefined { try { return make(); diff --git a/browser/data-browser/src/chunks/AppPage/appAgent.ts b/browser/data-browser/src/chunks/AppPage/appAgent.ts index 6f5861c1f8..ca51620eac 100644 --- a/browser/data-browser/src/chunks/AppPage/appAgent.ts +++ b/browser/data-browser/src/chunks/AppPage/appAgent.ts @@ -43,3 +43,43 @@ export async function handOverAppKey( ); } } + +/** + * The agent an app acts as (`atomic:agent:…` or the older `did:ad:agent:…`), + * as the node that holds its key reports it. Integration-proxy connections + * are delegated to this agent (ontola/atomic-plugins#54). + * + * Only apps made with `createApp` have one today. An installed catalog plugin + * gets its own identity in a later step (#54 decision 2); until then it cannot + * be given a proxy connection, and this says so rather than guessing. + */ +export async function appAgentOf( + store: Store, + options: { drive: string; app: string }, +): Promise { + const agent = store.getAgent(); + + if (!agent) throw new Error('Sign in to use integration connections.'); + + const url = new URL('/app-agent', store.getServerUrl()); + url.searchParams.set('drive', options.drive); + url.searchParams.set('app', options.app); + const headers = await signRequest(url.href, agent, {}); + const response = await fetch(url.href, { headers }); + + if (!response.ok) { + throw new Error( + errorMessageFromResponse(await response.text(), response.status), + ); + } + + const info = (await response.json()) as { agent?: unknown } | null; + + if (typeof info?.agent !== 'string' || !info.agent) { + throw new Error( + 'This app has no identity of its own yet, so it cannot be given an integration connection.', + ); + } + + return info.agent; +} diff --git a/browser/data-browser/src/chunks/AppPage/hostStore.test.ts b/browser/data-browser/src/chunks/AppPage/hostStore.test.ts index 1e3fe57570..d82b50adb9 100644 --- a/browser/data-browser/src/chunks/AppPage/hostStore.test.ts +++ b/browser/data-browser/src/chunks/AppPage/hostStore.test.ts @@ -195,39 +195,42 @@ describe('isHostRequest', () => { }); }); -describe('relaying the integration proxy', () => { - const relay = { - request: vi.fn(async () => ({ status: 200, headers: {}, body: [] })), - connections: vi.fn(() => [{ connectionId: 'c1', platform: 'pets' }]), +describe('integration-proxy capabilities', () => { + const minted = { + capability: 'payload.sig', + aud: 'https://proxy.example', + exp: 1, + connectionId: 'c1', + platform: 'pets', + }; + const proxy = { + capability: vi.fn(async () => minted), + connections: vi.fn(async () => [{ connectionId: 'c1', platform: 'pets' }]), }; - it('passes a call to the relay by reference, never touching the server', async () => { + it('mints a capability for the frame key, never touching the server', async () => { const result = await handleRequest( fakeStore(), APP, DRIVE, - req('proxy', { + req('proxyCapability', { platform: 'pets', connectionId: 'c1', - path: '/pets', - query: { page: '2' }, + publicKey: 'frame-key', }), undefined, - relay, - ); - expect(result).toEqual({ status: 200, headers: {}, body: [] }); - expect(relay.request).toHaveBeenCalledWith( - expect.objectContaining({ - platform: 'pets', - connectionId: 'c1', - path: '/pets', - query: { page: '2' }, - }), + proxy, ); + expect(result).toEqual(minted); + expect(proxy.capability).toHaveBeenCalledWith({ + platform: 'pets', + connectionId: 'c1', + publicKey: 'frame-key', + }); expect(sent).toEqual([]); }); - it('lists connection references, and none without a relay', async () => { + it('lists connection references, and none without a proxy', async () => { expect( await handleRequest( fakeStore(), @@ -235,7 +238,7 @@ describe('relaying the integration proxy', () => { DRIVE, req('proxyConnections', { platform: 'pets' }), undefined, - relay, + proxy, ), ).toEqual([{ connectionId: 'c1', platform: 'pets' }]); expect( @@ -248,13 +251,17 @@ describe('relaying the integration proxy', () => { ).toEqual([]); }); - it('refuses a proxy call without a relay or without a connection', async () => { + it('refuses without a proxy, a connection or a frame key', async () => { await expect( handleRequest( fakeStore(), APP, DRIVE, - req('proxy', { platform: 'pets', connectionId: 'c1', path: '/pets' }), + req('proxyCapability', { + platform: 'pets', + connectionId: 'c1', + publicKey: 'k', + }), ), ).rejects.toThrow('cannot reach the integration proxy'); await expect( @@ -262,10 +269,33 @@ describe('relaying the integration proxy', () => { fakeStore(), APP, DRIVE, - req('proxy', { platform: 'pets', path: '/pets' }), + req('proxyCapability', { platform: 'pets', publicKey: 'k' }), undefined, - relay, + proxy, ), ).rejects.toThrow('connectionId is required'); + await expect( + handleRequest( + fakeStore(), + APP, + DRIVE, + req('proxyCapability', { platform: 'pets', connectionId: 'c1' }), + undefined, + proxy, + ), + ).rejects.toThrow('publicKey is required'); + }); + + it('no longer relays proxy calls through the page', async () => { + await expect( + handleRequest( + fakeStore(), + APP, + DRIVE, + req('proxy', { platform: 'pets', connectionId: 'c1', path: '/pets' }), + undefined, + proxy, + ), + ).rejects.toThrow(); }); }); diff --git a/browser/data-browser/src/chunks/AppPage/hostStore.ts b/browser/data-browser/src/chunks/AppPage/hostStore.ts index ccaa0cf91a..9362cc0201 100644 --- a/browser/data-browser/src/chunks/AppPage/hostStore.ts +++ b/browser/data-browser/src/chunks/AppPage/hostStore.ts @@ -1,6 +1,6 @@ import { canViewAccess } from '@helpers/extensions/viewPolicy'; import type { Store } from '@tomic/react'; -import type { ProxyRelay } from '@helpers/proxyConnections'; +import type { ProxyHost } from '@helpers/proxyConnections'; import { CollectionBuilder, core, @@ -33,14 +33,11 @@ export interface HostRequest { parent?: string; isA?: string[]; propVals?: Record; - // `proxy` / `proxyConnections` + // `proxyCapability` / `proxyConnections` platform?: string; connectionId?: string; - path?: string; - method?: string; - query?: Record; - body?: string; - ifMatch?: string; + /** The frame's own Ed25519 public key, base64url. */ + publicKey?: string; } export interface HostReply { @@ -82,10 +79,10 @@ export async function handleRequest( /** The table this app is a view of, when it is being used as one. */ table?: string, /** - * This app's integration-proxy connections, held by this page. Absent where - * the host cannot relay (no signed-in agent, or a host without the op). + * This app's integration-proxy access, as the signed-in user grants it. + * Absent where the host cannot (no signed-in agent, or a host without it). */ - relay?: ProxyRelay, + proxy?: ProxyHost, ): Promise { switch (request.op) { case 'app': @@ -186,25 +183,26 @@ export async function handleRequest( return { subject }; } - // The frame names a connection; this page holds it and makes the call. - // Only status, a few headers and the body go back — never the code. - case 'proxy': { - if (!relay) + // The frame names a connection and brings its own public key; the user + // signs a capability bound to that key, for that connection only, after + // the page has checked the connection is delegated to this app. The frame + // then calls the proxy itself. Nothing here is a credential on its own: + // every request must also be signed with the frame's key. + case 'proxyCapability': { + if (!proxy) throw new Error('This host cannot reach the integration proxy.'); - return await relay.request({ + return await proxy.capability({ platform: required(request.platform, 'platform'), connectionId: required(request.connectionId, 'connectionId'), - path: required(request.path, 'path'), - method: request.method, - query: request.query, - body: request.body, - ifMatch: request.ifMatch, + publicKey: required(request.publicKey, 'publicKey'), }); } case 'proxyConnections': - return relay?.connections(required(request.platform, 'platform')) ?? []; + return proxy + ? await proxy.connections(required(request.platform, 'platform')) + : []; // Subscriptions are wired by the caller, which owns the frame it has to // post back to. diff --git a/browser/data-browser/src/helpers/proxyConnections.test.ts b/browser/data-browser/src/helpers/proxyConnections.test.ts index c57dbb471f..dfdcd84ee3 100644 --- a/browser/data-browser/src/helpers/proxyConnections.test.ts +++ b/browser/data-browser/src/helpers/proxyConnections.test.ts @@ -1,14 +1,22 @@ // @wc-ignore-file import { describe, expect, it, vi } from 'vitest'; -import { ProxyConnections, proxyUrl } from './proxyConnections'; +import { Agent, JSCryptoProvider, decodeB64 } from '@tomic/react'; +import { + CAPABILITY_TTL_SECONDS, + ProxyConnections, + canonicalAgent, + capabilityClaims, +} from './proxyConnections'; const ORIGIN = 'http://proxy.test'; const PAGE = 'http://atomic.test'; -const SCOPE = { - drive: 'did:ad:drive', - actor: 'did:ad:agent:me', - app: 'did:ad:app', -}; +/** The key of `lib/src/authentication_v2_vectors.json`. */ +const PRIVATE_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const USER_PUBLIC = 'O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik'; +const USER = `atomic:agent:${USER_PUBLIC}`; +const APP_AGENT = `atomic:agent:${'A'.repeat(42)}E`; +const FRAME_KEY = `${'B'.repeat(42)}E`; +const SCOPE = { drive: 'atomic:drive', app: 'atomic:app', appAgent: APP_AGENT }; class MemoryStorage implements Storage { private map = new Map(); @@ -35,46 +43,136 @@ class MemoryStorage implements Storage { } } -const locks = { - request: (_name: string, run: () => Promise) => run(), -} as unknown as Pick; +const user = () => + new Agent(new JSCryptoProvider(PRIVATE_KEY), `did:ad:agent:${USER_PUBLIC}`); -function response( - status: number, - body: string, - headers: Record = {}, -) { - return new Response(body, { status, headers }); +async function verify(publicKey: string, message: Uint8Array, sig: string) { + const key = await crypto.subtle.importKey( + 'raw', + new Uint8Array(decodeB64(publicKey)), + { name: 'Ed25519' }, + false, + ['verify'], + ); + + return crypto.subtle.verify( + { name: 'Ed25519' }, + key, + new Uint8Array(decodeB64(sig)), + new Uint8Array(message), + ); } -/** A proxy that hands out one code per call, like LocalThought's. */ +async function sha256Hex(text: string) { + return Array.from( + new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)), + ), + b => b.toString(16).padStart(2, '0'), + ).join(''); +} + +/** + * A proxy that checks every call's v2 signature the way the real one does, + * and keeps connections and delegations. + */ function fakeProxy() { - let n = 0; - const calls: { url: string; init: RequestInit }[] = []; + const calls: { method: string; url: string; body?: string }[] = []; + const connections: { + connection_id: string; + platform: string; + owner: string; + delegations: { agent: string; label?: string }[]; + }[] = [ + { + connection_id: 'old', + platform: 'pets', + owner: USER, + delegations: [], + }, + ]; const http = vi.fn(async (url: string, init: RequestInit = {}) => { - calls.push({ url, init }); + const method = init.method ?? 'GET'; + const body = init.body as string | undefined; + const h = init.headers as Record; + calls.push({ method, url, body }); + const message = [ + 'atomic-request-v2', + method, + url, + h['x-atomic-timestamp'], + await sha256Hex(body ?? ''), + ].join('\n'); - if (url.endsWith('/connect/redeem')) - return response( - 200, - JSON.stringify({ connection_code: `code-${n++}`, platform: 'pets' }), + if ( + h['x-atomic-signature-version'] !== '2' || + h['x-atomic-agent'] !== `atomic:agent:${h['x-atomic-public-key']}` || + !(await verify( + h['x-atomic-public-key'], + new TextEncoder().encode(message), + h['x-atomic-signature'], + )) + ) + return Response.json( + { error: 'bad_signature', message: 'no' }, + { status: 401 }, ); - return response(200, JSON.stringify([{ id: 1 }]), { - 'x-connection-code': `code-${n++}`, - link: '; rel="next"', - 'set-cookie': 'never=forwarded', - }); + const path = new URL(url).pathname; + + if (path === '/connect/redeem') { + connections.push({ + connection_id: 'new', + platform: 'pets', + owner: h['x-atomic-agent'], + delegations: [], + }); + + return Response.json({ + connection_id: 'new', + platform: 'pets', + owner: h['x-atomic-agent'], + }); + } + + if (path === '/connections' && method === 'GET') + return Response.json({ owner: USER, connections, runtimes: [] }); + + const agents = /^\/connections\/([^/]+)\/agents$/.exec(path); + + if (agents && method === 'POST') { + const row = connections.find(c => c.connection_id === agents[1])!; + row.delegations.push(JSON.parse(body!)); + + return Response.json({ ok: true }); + } + + return Response.json( + { error: 'not_found', message: path }, + { status: 404 }, + ); }); - return { http: http as unknown as typeof fetch, calls }; + return { http: http as unknown as typeof fetch, calls, connections }; } async function connected(storage = new MemoryStorage()) { const proxy = fakeProxy(); - const connections = new ProxyConnections(storage, ORIGIN, proxy.http, locks); + const connections = new ProxyConnections( + storage, + ORIGIN, + user, + proxy.http, + () => 1_790_000_000_000, + ); const url = new URL( - await connections.start(SCOPE, 'pets', `${PAGE}/app/show?subject=x`, PAGE), + await connections.start( + SCOPE, + 'pets', + `${PAGE}/app/show?subject=x`, + 'Pets app', + PAGE, + ), ); const back = new URL(url.searchParams.get('redirect_uri')!); back.searchParams.set('connection_code', 'handoff'); @@ -85,96 +183,208 @@ async function connected(storage = new MemoryStorage()) { } describe('ProxyConnections', () => { - it('starts PKCE towards the proxy and returns via /app/integrations', async () => { + it('starts PKCE towards the proxy without a user id', async () => { const { url, back, finished } = await connected(); expect(url.origin + url.pathname).toBe(`${ORIGIN}/connect`); + expect(url.searchParams.get('user_id')).toBeNull(); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); - expect(url.searchParams.get('user_id')).toBe(SCOPE.actor); - expect(back.pathname).toBe('/app/integrations'); - expect(back.searchParams.get('platform')).toBe('pets'); + expect(back.origin + back.pathname).toBe(`${PAGE}/app/integrations`); expect(finished).toEqual({ returnTo: `${PAGE}/app/show?subject=x`, connected: true, + connectionId: 'new', }); }); - it('lists references only, and only for the app that connected', async () => { - const { connections } = await connected(); - const [ref] = connections.list(SCOPE, 'pets'); - expect(Object.keys(ref).sort()).toEqual(['connectionId', 'platform']); - expect(connections.list({ ...SCOPE, app: 'did:ad:other' }, 'pets')).toEqual( - [], + it('redeems signed by the user, then delegates to the app, signed too', async () => { + const { proxy, storage } = await connected(); + expect(proxy.calls.map(c => [c.method, new URL(c.url).pathname])).toEqual([ + ['POST', '/connect/redeem'], + ['POST', '/connections/new/agents'], + ]); + expect(JSON.parse(proxy.calls[0].body!)).toMatchObject({ + code: 'handoff', + }); + expect(JSON.parse(proxy.calls[1].body!)).toEqual({ + agent: APP_AGENT, + label: 'Pets app', + }); + expect(proxy.connections.find(c => c.connection_id === 'new')?.owner).toBe( + USER, + ); + // The handoff state is spent, and nothing credential-like stays behind. + expect(storage.length).toBe(0); + }); + + it('does not redeem a return twice', async () => { + const { connections, back } = await connected(); + expect(connections.isReturn(back.searchParams)).toBe(false); + await expect(connections.finish(back.searchParams)).rejects.toThrow( + 'not one this browser started', ); - expect(connections.list(SCOPE, 'notion')).toEqual([]); }); - it('relays a call, rotates the code, and never returns it', async () => { - const { connections, proxy, storage } = await connected(); - const [ref] = connections.list(SCOPE, 'pets'); - const result = await connections.relay(SCOPE).request({ - ...ref, - path: '/pets', - query: { page: '2' }, + it('lists only connections delegated to the app', async () => { + const { connections } = await connected(); + expect(await connections.delegated(APP_AGENT, 'pets')).toEqual([ + { connectionId: 'new', platform: 'pets' }, + ]); + expect( + await connections.delegated(`atomic:agent:${FRAME_KEY}`, 'pets'), + ).toEqual([]); + }); + + it('mints a capability bound to the frame key, signed by the user', async () => { + const { connections } = await connected(); + const minted = await connections.mintCapability({ + appAgent: APP_AGENT, + platform: 'pets', + connectionId: 'new', + publicKey: FRAME_KEY, }); - expect(result).toEqual({ - status: 200, - headers: { - link: '; rel="next"', - 'content-type': 'text/plain;charset=UTF-8', - }, - body: [{ id: 1 }], + expect(minted.aud).toBe(ORIGIN); + expect(minted.exp).toBe(1_790_000_000 + CAPABILITY_TTL_SECONDS); + const [payload, sig] = minted.capability.split('.'); + const json = new TextDecoder().decode(decodeB64(payload)); + expect(JSON.parse(json)).toEqual({ + v: 2, + connection_id: 'new', + platform: 'pets', + aud: ORIGIN, + app: APP_AGENT, + cnf: `atomic:agent:${FRAME_KEY}`, + exp: minted.exp, }); - const call = proxy.calls.at(-1)!; - expect(call.url).toBe(`${ORIGIN}/proxy/pets/pets?page=2`); - expect((call.init.headers as Record).Authorization).toBe( - 'Bearer code-0', - ); - expect(JSON.stringify(result)).not.toContain('code-'); - expect(storage.values()).toContain('"code":"code-1"'); + expect( + await verify( + USER_PUBLIC, + new TextEncoder().encode(`integration-proxy-capability-v2\n${json}`), + sig, + ), + ).toBe(true); }); - it('refuses another app, platform or path escape', async () => { + it('refuses to mint for a connection not delegated to the app', async () => { const { connections } = await connected(); - const [ref] = connections.list(SCOPE, 'pets'); await expect( - connections - .relay({ ...SCOPE, app: 'did:ad:other' }) - .request({ ...ref, path: '/pets' }), - ).rejects.toThrow('No pets connection for this app'); + connections.mintCapability({ + appAgent: APP_AGENT, + platform: 'pets', + connectionId: 'old', + publicKey: FRAME_KEY, + }), + ).rejects.toThrow('is delegated to this app'); await expect( - connections - .relay(SCOPE) - .request({ ...ref, platform: 'notion', path: '/pets' }), - ).rejects.toThrow('No notion connection'); - for (const path of ['pets', '//evil.test/x', '/../../connect', '/a#b']) - expect(() => proxyUrl(ORIGIN, 'pets', path)).toThrow(); + connections.mintCapability({ + appAgent: APP_AGENT, + platform: 'github', + connectionId: 'new', + publicKey: FRAME_KEY, + }), + ).rejects.toThrow('is delegated to this app'); await expect( - connections - .relay(SCOPE) - .request({ ...ref, path: '/pets', method: 'GET', body: '{}' }), - ).rejects.toThrow('GET'); + connections.mintCapability({ + appAgent: APP_AGENT, + platform: 'pets', + connectionId: 'new', + publicKey: 'short', + }), + ).rejects.toThrow('Ed25519'); }); - it('forgets a handoff the person refused, and ignores foreign returns', async () => { - const storage = new MemoryStorage(); + it('delegates an existing connection without OAuth', async () => { + const proxy = fakeProxy(); + const connections = new ProxyConnections( + new MemoryStorage(), + ORIGIN, + user, + proxy.http, + ); + await connections.delegate('old', APP_AGENT, 'Pets app'); + expect(await connections.delegated(APP_AGENT, 'pets')).toEqual([ + { connectionId: 'old', platform: 'pets' }, + ]); + }); + + it('needs a signed-in user', async () => { const connections = new ProxyConnections( - storage, + new MemoryStorage(), ORIGIN, + () => undefined, fakeProxy().http, - locks, ); + await expect(connections.list('pets')).rejects.toThrow('Sign in'); + }); + + it('passes the proxy error code along', async () => { + const connections = new ProxyConnections( + new MemoryStorage(), + ORIGIN, + user, + fakeProxy().http, + ); + await expect(connections.revoke('old')).rejects.toMatchObject({ + status: 404, + code: 'not_found', + }); + }); + + it('drops a refused handoff and still goes back', async () => { + const storage = new MemoryStorage(); + const proxy = fakeProxy(); + const connections = new ProxyConnections(storage, ORIGIN, user, proxy.http); const url = new URL( - await connections.start(SCOPE, 'pets', `${PAGE}/app`, PAGE), + await connections.start(SCOPE, 'pets', `${PAGE}/x`, 'Pets app', PAGE), ); const back = new URL(url.searchParams.get('redirect_uri')!); back.searchParams.set('error', 'access_denied'); expect(await connections.finish(back.searchParams)).toEqual({ - returnTo: `${PAGE}/app`, + returnTo: `${PAGE}/x`, connected: false, }); + expect(proxy.calls).toEqual([]); expect(storage.length).toBe(0); - expect( - connections.isReturn(new URLSearchParams('integration_state=nope')), - ).toBe(false); + }); +}); + +describe('canonicalAgent', () => { + it('normalises prefix and alphabet, and refuses anything else', () => { + const standard = 'gJRZVTGPngaG3mSPA/e6LEewKixYpZtuUYQhNg+t7Y4='; + const url = 'gJRZVTGPngaG3mSPA_e6LEewKixYpZtuUYQhNg-t7Y4'; + expect(canonicalAgent(`did:ad:agent:${standard}`)).toBe( + `atomic:agent:${url}`, + ); + expect(canonicalAgent(`atomic:agent:${url}`)).toBe(`atomic:agent:${url}`); + expect(() => canonicalAgent('https://example.com/agents/x')).toThrow(); + expect(() => canonicalAgent('atomic:agent:AAAA')).toThrow('Ed25519'); + }); +}); + +/** + * A fixed capability, for the proxy's tests to verify: the user key of + * `lib/src/authentication_v2_vectors.json`, fixed claims. Ed25519 in noble + * is deterministic, so this is the exact string the page produces. + */ +describe('capability vector', () => { + it('is stable', async () => { + const { json, message } = capabilityClaims({ + connection_id: 'c_123', + platform: 'google-calendar', + aud: 'https://proxy.example', + app: APP_AGENT, + cnf: `atomic:agent:${FRAME_KEY}`, + exp: 1_790_000_600, + }); + const sig = await user().signBytes(message); + const payload = btoa(json) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); + expect(json).toBe( + `{"v":2,"connection_id":"c_123","platform":"google-calendar","aud":"https://proxy.example","app":"${APP_AGENT}","cnf":"atomic:agent:${FRAME_KEY}","exp":1790000600}`, + ); + expect(`${payload}.${sig}`).toMatchInlineSnapshot( + `"eyJ2IjoyLCJjb25uZWN0aW9uX2lkIjoiY18xMjMiLCJwbGF0Zm9ybSI6Imdvb2dsZS1jYWxlbmRhciIsImF1ZCI6Imh0dHBzOi8vcHJveHkuZXhhbXBsZSIsImFwcCI6ImF0b21pYzphZ2VudDpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFFIiwiY25mIjoiYXRvbWljOmFnZW50OkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkUiLCJleHAiOjE3OTAwMDA2MDB9.mKOEe3SnRDDRYFZ94kAqkXl_RnRy-pS-B5KdEzpdQVDPNE3PEHZq1e7aT9dL0GKhv00uZ2WiWmftEvto09xRDw"`, + ); }); }); diff --git a/browser/data-browser/src/helpers/proxyConnections.ts b/browser/data-browser/src/helpers/proxyConnections.ts index 9275f8e43a..f644238fdd 100644 --- a/browser/data-browser/src/helpers/proxyConnections.ts +++ b/browser/data-browser/src/helpers/proxyConnections.ts @@ -1,91 +1,129 @@ // @wc-ignore-file /** - * Integration-proxy connections held by this page on behalf of apps. + * Integration-proxy connections, as the page manages them for apps + * (ontola/atomic-plugins#54). * - * An app's view runs in a null-origin frame with no storage, and a credential - * must never be written into a resource (it would sync, and drives are - * shared). So the page holds the connection — LocalThought's rotating - * connection code, in this origin's `localStorage` — and relays calls for the - * frame, which only ever names a connection by `{ platform, connectionId }`. + * The proxy account is the user's Atomic agent. A **connection** (platform + + * sealed provider token) lives at the proxy and belongs to the agent that + * redeemed it; a **delegation** says "app agent X may use connection C". The + * page signs every call it makes to the proxy with the user key, using + * version 2 request signatures (method and body covered). * - * Each connection is bound to the proxy origin, drive, agent and **app** it - * was made for. Another app on the same drive cannot relay through it. + * An app's view runs in a null-origin frame with no storage and never holds + * the user key. It makes its own key in memory, and the page mints it a + * short-lived **capability**, signed with the user key and bound to that + * frame key, after checking that the connection is delegated to this app. The + * frame then calls the proxy directly; see `view-client.js`. * - * Interim by design: atomic-server#1624 / atomic-plugins#40 replace the - * rotating code with a short-lived capability the parent mints, at which - * point this file shrinks to minting. The transport half is a port of - * atomic-plugins' `integrations/localthought/browser.ts`, which the - * data-browser no longer imports. + * Replaces #1657's relay of LocalThought rotating connection codes (flag day, + * decision 6 of #54): nothing credential-like is kept in this page's storage + * any more, only a PKCE verifier for the ten minutes of a handoff. */ -const KEY = 'atomic-proxy-connection-v1:'; +import { agentSubject, decodeB64, signRequest, type Agent } from '@tomic/react'; + +const PENDING = 'atomic-proxy-connect-v2:'; const MAX_BODY = 10 * 1024 * 1024; const TIMEOUT = 30_000; -/** Response headers a frame may see. Never `x-connection-code`. */ -const EXPOSED_HEADERS = ['link', 'retry-after', 'etag', 'content-type']; -const METHODS = ['GET', 'POST', 'PATCH', 'DELETE'] as const; -export type ProxyMethod = (typeof METHODS)[number]; +/** + * How far ahead a capability's `exp` is set. The proxy allows 15 minutes + * (900 s) by its own clock and ±5 min skew; 10 minutes keeps a capability + * valid at a proxy whose clock is up to 5 minutes behind this device's. + */ +export const CAPABILITY_TTL_SECONDS = 600; +export const CAPABILITY_MESSAGE_PREFIX = 'integration-proxy-capability-v2\n'; -/** Who a connection belongs to. All four must match to use it. */ -export interface ConnectionScope { +/** Who a connection is being made for. */ +export interface ConnectScope { drive: string; - actor: string; app: string; + /** The app's own agent (`atomic:agent:…`), which gets the delegation. */ + appAgent: string; } -interface StoredConnection extends ConnectionScope { +interface PendingConnect extends ConnectScope { origin: string; platform: string; /** Where to go once the proxy sends the person back. */ returnTo: string; - /** Only for the PKCE handoff window. */ expires: number; - codeVerifier?: string; - code?: string; - ready: boolean; + codeVerifier: string; + label: string; } -export interface ProxyRelayRequest { - platform: string; +export interface ConnectionReference { connectionId: string; - path: string; - method?: string; - query?: Record; - /** JSON text. */ - body?: string; - ifMatch?: string; + platform: string; } -export interface ProxyRelayResponse { - status: number; - headers: Record; - /** Parsed JSON when the body is JSON, the raw text otherwise. */ - body: unknown; +/** One row of `GET /connections`, as far as this page reads it. */ +export interface ProxyConnection { + connection_id: string; + platform: string; + owner?: string; + created_at?: string | number; + last_used_at?: string | number; + delegations: { agent: string; label?: string }[]; } -export interface ConnectionReference { +/** What the frame gets: the capability, and where to use it. */ +export interface MintedCapability { + capability: string; + /** The proxy origin, the capability's `aud`. */ + aud: string; + /** Unix seconds. */ + exp: number; connectionId: string; platform: string; } -/** What `hostStore` needs for one app; see `ProxyConnections.relay`. */ -export interface ProxyRelay { - request(request: ProxyRelayRequest): Promise; - connections(platform: string): ConnectionReference[]; +/** What `hostStore` needs for one app's frame. */ +export interface ProxyHost { + capability(request: { + platform: string; + connectionId: string; + publicKey: string; + }): Promise; + connections(platform: string): Promise; } -type Locks = Pick; +/** An error the proxy returned: `{error, message}` and the HTTP status. */ +export class ProxyError extends Error { + constructor( + readonly status: number, + readonly code: string | undefined, + message: string, + ) { + super(message); + } +} export const isPlatformId = (value: unknown): value is string => typeof value === 'string' && /^[a-z0-9-]{1,80}$/.test(value); -const base64url = (bytes: Uint8Array) => +export const base64url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)) .replaceAll('+', '-') .replaceAll('/', '_') .replaceAll('=', ''); +/** + * `atomic:agent:` for an Ed25519 agent id in either + * prefix (`atomic:agent:`, `did:ad:agent:`) and either base64 alphabet, the + * form the proxy stores and compares. Throws for anything else. + */ +export function canonicalAgent(id: string): string { + const match = /^(?:atomic:agent:|did:ad:agent:)([A-Za-z0-9+/_=-]+)$/.exec( + id.trim(), + ); + if (!match) throw new Error(`Not an agent id: ${id}`); + const key = decodeB64(match[1]); + if (key.length !== 32) throw new Error(`Not an Ed25519 agent id: ${id}`); + + return agentSubject(base64url(key)); +} + async function limitedText(response: Response): Promise { if (!response.body) return ''; const reader = response.body.getReader(); @@ -118,94 +156,120 @@ function parseBody(text: string): unknown { } } -/** `path` plus `query`, checked to stay under `/proxy//`. */ -export function proxyUrl( - origin: string, - platform: string, - path: string, - query?: Record, -): URL { - if (!isPlatformId(platform)) throw new Error('Invalid platform'); - - if ( - typeof path !== 'string' || - !path.startsWith('/') || - path.startsWith('//') || - /[\\#]/.test(path) || - path.length > 4096 - ) - throw new Error('Invalid proxy path'); - const url = new URL(`/proxy/${platform}${path}`, origin); - - if (url.origin !== origin || !url.pathname.startsWith(`/proxy/${platform}/`)) - throw new Error('Invalid proxy path'); - - for (const [key, value] of Object.entries(query ?? {})) { - if (typeof value !== 'string') throw new Error('Invalid proxy query'); - url.searchParams.set(key, value); - } - - return url; +/** + * The capability payload and the bytes the user key signs. + * + * Wire form (agreed with the proxy, #54): `Authorization: Capability + * .`, where `payload` is base64url of the JSON claims and `sig` + * is the user key's Ed25519 signature over + * `integration-proxy-capability-v2\n` followed by those same JSON bytes. + */ +export function capabilityClaims(claims: { + connection_id: string; + platform: string; + aud: string; + app: string; + cnf: string; + exp: number; +}): { json: string; message: Uint8Array } { + // Key order as in the proposal; the proxy verifies the bytes, not a + // re-serialization, so it does not depend on it. + const json = JSON.stringify({ + v: 2, + connection_id: claims.connection_id, + platform: claims.platform, + aud: claims.aud, + app: claims.app, + cnf: claims.cnf, + exp: claims.exp, + }); + + return { + json, + message: new TextEncoder().encode(CAPABILITY_MESSAGE_PREFIX + json), + }; } export class ProxyConnections { constructor( private storage: Storage, + /** The proxy origin. */ readonly origin: string, + /** The signed-in user. Every call the page makes is signed with it. */ + private agent: () => Agent | undefined, private http: typeof fetch = (...args) => fetch(...args), - private locks: Locks | undefined = globalThis.navigator?.locks, private now: () => number = Date.now, ) {} - private read(id: string): StoredConnection | undefined { - const raw = this.storage.getItem(KEY + id); - if (!raw) return undefined; - - try { - return JSON.parse(raw) as StoredConnection; - } catch { - return undefined; - } - } + private user(): { agent: Agent; subject: Promise } { + const agent = this.agent(); + if (!agent) throw new Error('Sign in to use integration connections.'); - private write(id: string, connection: StoredConnection) { - this.storage.setItem(KEY + id, JSON.stringify(connection)); + return { + agent, + subject: agent.getPublicKey().then(key => agentSubject(key)), + }; } - private owned( - id: string, - scope: ConnectionScope, - platform: string, - ): StoredConnection { - const c = this.read(id); - - if ( - !c || - c.origin !== this.origin || - c.drive !== scope.drive || - c.actor !== scope.actor || - c.app !== scope.app || - c.platform !== platform - ) - throw new Error(`No ${platform} connection for this app. Connect again.`); + /** One call to the proxy, signed v2 with the user key. */ + private async call( + method: 'GET' | 'POST' | 'DELETE', + path: string, + body?: unknown, + ): Promise { + const { agent, subject } = this.user(); + const url = new URL(path, this.origin); + const text = body === undefined ? undefined : JSON.stringify(body); + const headers = await signRequest( + url.href, + agent, + text === undefined ? {} : { 'Content-Type': 'application/json' }, + { method, body: text, agentSubject: await subject }, + ); + const response = await this.http(url.href, { + method, + body: text, + headers, + credentials: 'omit', + redirect: 'error', + signal: AbortSignal.timeout(TIMEOUT), + }); + const parsed = parseBody(await limitedText(response)); + + if (!response.ok) { + const error = parsed as { error?: unknown; message?: unknown } | null; + const code = typeof error?.error === 'string' ? error.error : undefined; + const message = + typeof error?.message === 'string' + ? error.message + : `HTTP ${response.status}`; + throw new ProxyError( + response.status, + code, + `The integration proxy refused ${method} ${url.pathname}: ${message}`, + ); + } - return c; + return parsed; } /** * Starts the PKCE handoff and returns the proxy URL to send the person to. * The proxy only returns to `/app/integrations`, so `returnTo` is where the - * return handler goes afterwards. + * return handler goes afterwards. No proxy login: the redeem that follows + * is signed with the user key, and that makes the user the owner. */ async start( - scope: ConnectionScope, + scope: ConnectScope, platform: string, returnTo: string, + label: string, pageOrigin = location.origin, ): Promise { if (!isPlatformId(platform)) throw new Error('Invalid platform'); const back = new URL(returnTo, pageOrigin); if (back.origin !== pageOrigin) throw new Error('Invalid return URL'); + const appAgent = canonicalAgent(scope.appAgent); const state = base64url(crypto.getRandomValues(new Uint8Array(32))); const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32))); @@ -225,200 +289,227 @@ export class ProxyConnections { for (const [k, v] of Object.entries({ platform, redirect_uri: callback.href, - user_id: scope.actor, code_challenge: codeChallenge, code_challenge_method: 'S256', - credentials: 'connection', })) url.searchParams.set(k, v); - this.write(state, { + const pending: PendingConnect = { ...scope, + appAgent, origin: this.origin, platform, returnTo: back.href, expires: this.now() + 600_000, codeVerifier, - ready: false, - }); + label, + }; + this.storage.setItem(PENDING + state, JSON.stringify(pending)); return url.href; } + private pending(state: string): PendingConnect | undefined { + const raw = this.storage.getItem(PENDING + state); + if (!raw) return undefined; + + try { + return JSON.parse(raw) as PendingConnect; + } catch { + return undefined; + } + } + /** Whether `params` is a return from a handoff this browser started. */ isReturn(params: URLSearchParams): boolean { const state = params.get('integration_state'); - if (!state) return false; - const c = this.read(state); - return !!c && !c.ready && !!c.codeVerifier; + return !!state && !!this.pending(state); } /** - * Redeems the proxy's handoff code. Returns where to go next. A refusal at - * the proxy (`error=access_denied`) drops the pending handoff and still - * returns there, so the app can offer to connect again. - * - * The agent is not re-checked here: it is recorded at `start`, and every - * relayed request checks it. The signed-in agent may not be loaded yet this - * early in a page load. + * Redeems the proxy's handoff code, signed with the user key (who becomes + * the connection's owner), then delegates the new connection to the app, + * also signed. Returns where to go next. A refusal at the proxy + * (`error=access_denied`) drops the pending handoff and still returns + * there, so the app can offer to connect again. */ async finish( params: URLSearchParams, - ): Promise<{ returnTo: string; connected: boolean }> { + ): Promise<{ returnTo: string; connected: boolean; connectionId?: string }> { const state = params.get('integration_state') ?? ''; - const c = this.read(state); + const c = this.pending(state); + // Consumed before anything else: the verifier is single-use either way. + this.storage.removeItem(PENDING + state); - if (!c || c.ready || !c.codeVerifier || c.origin !== this.origin) + if (!c || c.origin !== this.origin) throw new Error( 'This connection return is not one this browser started.', ); - if (params.get('platform') !== c.platform || c.expires < this.now()) { - this.storage.removeItem(KEY + state); + if (params.get('platform') !== c.platform || c.expires < this.now()) throw new Error('The connection return expired. Connect again.'); - } const code = params.get('connection_code'); if (!code || code.length > 4096) { - this.storage.removeItem(KEY + state); - return { returnTo: c.returnTo, connected: false }; } - const verifier = c.codeVerifier; - // Consumed before dispatch: a lost response may have spent the handoff. - this.write(state, { ...c, codeVerifier: undefined }); - const response = await this.http(`${this.origin}/connect/redeem`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, code_verifier: verifier }), - credentials: 'omit', - redirect: 'error', - signal: AbortSignal.timeout(TIMEOUT), - }); - if (!response.ok) - throw new Error(`The integration proxy returned HTTP ${response.status}`); - const result = parseBody(await limitedText(response)) as { - connection_code?: unknown; - platform?: unknown; - }; + const result = (await this.call('POST', '/connect/redeem', { + code, + code_verifier: c.codeVerifier, + })) as { connection_id?: unknown; platform?: unknown } | null; + if (result?.platform !== c.platform) throw new Error('The proxy connected a different platform than asked.'); + if ( - typeof result.connection_code !== 'string' || - !result.connection_code || - result.connection_code.length > 4096 + typeof result.connection_id !== 'string' || + !result.connection_id || + result.connection_id.length > 512 ) - throw new Error('The proxy returned an invalid connection code.'); - this.write(state, { - ...c, - codeVerifier: undefined, - ready: true, - code: result.connection_code, - }); + throw new Error('The proxy returned an invalid connection id.'); + + await this.delegate(result.connection_id, c.appAgent, c.label); - return { returnTo: c.returnTo, connected: true }; + return { + returnTo: c.returnTo, + connected: true, + connectionId: result.connection_id, + }; } - /** Ready connections for this app and platform. References only. */ - list(scope: ConnectionScope, platform: string): ConnectionReference[] { - const out: ConnectionReference[] = []; - - for (let i = 0; i < this.storage.length; i++) { - const key = this.storage.key(i); - if (!key?.startsWith(KEY)) continue; - const id = key.slice(KEY.length); - const c = this.read(id); - - if ( - c?.ready && - c.origin === this.origin && - c.drive === scope.drive && - c.actor === scope.actor && - c.app === scope.app && - c.platform === platform + /** This user's connections at the proxy, with their delegations. */ + async list(platform?: string): Promise { + const result = (await this.call('GET', '/connections')) as + | { connections?: unknown } + | unknown[] + | null; + const rows = Array.isArray(result) + ? result + : Array.isArray(result?.connections) + ? result.connections + : []; + + return (rows as ProxyConnection[]) + .filter( + row => + typeof row?.connection_id === 'string' && + typeof row.platform === 'string' && + (platform === undefined || row.platform === platform), ) - out.push({ connectionId: id, platform }); - } + .map(row => ({ + ...row, + delegations: Array.isArray(row.delegations) ? row.delegations : [], + })); + } + + /** Connections for `platform` delegated to `appAgent`. References only. */ + async delegated( + appAgent: string, + platform: string, + ): Promise { + const app = canonicalAgent(appAgent); + + return (await this.list(platform)) + .filter(row => row.delegations.some(d => sameAgent(d.agent, app))) + .map(row => ({ connectionId: row.connection_id, platform })); + } + + /** "App agent X may use connection C." Signed by the owner. */ + async delegate(connectionId: string, agent: string, label?: string) { + await this.call( + 'POST', + `/connections/${encodeURIComponent(connectionId)}/agents`, + { agent: canonicalAgent(agent), ...(label ? { label } : {}) }, + ); + } - return out; + /** Takes a delegation away; the proxy refuses the app from then on. */ + async undelegate(connectionId: string, agent: string) { + await this.call( + 'DELETE', + `/connections/${encodeURIComponent(connectionId)}/agents/${encodeURIComponent(canonicalAgent(agent))}`, + ); } - /** One relayed call, serialised per connection because codes rotate. */ - async request( - scope: ConnectionScope, - request: ProxyRelayRequest, - ): Promise { - const method = (request.method ?? 'GET').toUpperCase() as ProxyMethod; - if (!METHODS.includes(method)) throw new Error('Invalid proxy method'); - if (request.body !== undefined && typeof request.body !== 'string') - throw new Error('A proxy request body is JSON text'); - if (request.body !== undefined && method === 'GET') - throw new Error('A GET proxy request has no body'); - if (request.ifMatch !== undefined && typeof request.ifMatch !== 'string') - throw new Error('Invalid If-Match'); - const url = proxyUrl( - this.origin, - request.platform, - request.path, - request.query, + /** Deletes a connection at the proxy, and with it every delegation. */ + async revoke(connectionId: string) { + await this.call( + 'DELETE', + `/connections/${encodeURIComponent(connectionId)}`, ); - const id = request.connectionId; - if (typeof id !== 'string' || !id) + } + + /** + * Mints a capability for a frame's key, after checking that the connection + * is this user's, for `platform`, and delegated to `appAgent`. The proxy + * checks all of that again on every request; checking here as well means a + * frame cannot get a signed statement about a connection it has no business + * with. + */ + async mintCapability(request: { + appAgent: string; + platform: string; + connectionId: string; + publicKey: string; + }): Promise { + const { platform, connectionId } = request; + if (!isPlatformId(platform)) throw new Error('Invalid platform'); + if (typeof connectionId !== 'string' || !connectionId) throw new Error('connectionId is required'); - // Checked before taking the lock, so a foreign id fails fast. - this.owned(id, scope, request.platform); - if (!this.locks) - throw new Error('This browser needs Web Locks for integrations'); - - return this.locks.request(KEY + id, async () => { - const c = this.owned(id, scope, request.platform); - if (!c.ready || !c.code) - throw new Error('Reconnect before retrying an uncertain request'); - const code = c.code; - this.write(id, { ...c, code: undefined }); - const response = await this.http(url.href, { - method, - body: request.body, - headers: { - Authorization: `Bearer ${code}`, - ...(request.body !== undefined - ? { 'Content-Type': 'application/json' } - : {}), - ...(request.ifMatch ? { 'If-Match': request.ifMatch } : {}), - }, - credentials: 'omit', - redirect: 'error', - signal: AbortSignal.timeout(TIMEOUT), - }); - const next = response.headers.get('x-connection-code'); - if (!next) - throw new Error( - 'The proxy did not return a rotated code; reconnect and check its CORS headers', - ); - this.write(id, { ...c, code: next }); - const headers: Record = {}; - - for (const name of EXPOSED_HEADERS) { - const value = response.headers.get(name); - if (value !== null) headers[name] = value; - } - - return { - status: response.status, - headers, - body: parseBody(await limitedText(response)), - }; + if (typeof request.publicKey !== 'string') + throw new Error('publicKey is required'); + const cnf = canonicalAgent(agentSubject(request.publicKey)); + const app = canonicalAgent(request.appAgent); + const { agent } = this.user(); + + const row = (await this.list(platform)).find( + r => r.connection_id === connectionId, + ); + + if (!row || !row.delegations.some(d => sameAgent(d.agent, app))) + throw new Error( + `No ${platform} connection ${connectionId} is delegated to this app. Connect again.`, + ); + + const exp = Math.floor(this.now() / 1000) + CAPABILITY_TTL_SECONDS; + const { json, message } = capabilityClaims({ + connection_id: connectionId, + platform, + aud: this.origin, + app, + cnf, + exp, }); + const sig = await agent.signBytes(message); + + return { + capability: `${base64url(new TextEncoder().encode(json))}.${sig}`, + aud: this.origin, + exp, + connectionId, + platform, + }; } - /** The relay `hostStore` answers one app's frame with. */ - relay(scope: ConnectionScope): ProxyRelay { + /** What `hostStore` answers one app's frame with. */ + host(appAgent: () => Promise): ProxyHost { return { - request: request => this.request(scope, request), - connections: platform => this.list(scope, platform), + capability: async request => + this.mintCapability({ ...request, appAgent: await appAgent() }), + connections: async platform => this.delegated(await appAgent(), platform), }; } } + +function sameAgent(a: unknown, canonical: string): boolean { + if (typeof a !== 'string') return false; + + try { + return canonicalAgent(a) === canonical; + } catch { + return false; + } +} diff --git a/browser/data-browser/src/locales/de.po b/browser/data-browser/src/locales/de.po index 6175af1b39..19aa15b0c4 100644 --- a/browser/data-browser/src/locales/de.po +++ b/browser/data-browser/src/locales/de.po @@ -9416,11 +9416,22 @@ msgstr "" msgid "Sign in to connect an account." msgstr "" +#~ msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +#~ msgstr "" + +#: src/chunks/AppPage/AppFrame.tsx +msgid "Connect an account" +msgstr "" + #. 0: ' '; 1: ' '; 2: getIntegrationProxy() #: src/chunks/AppPage/AppFrame.tsx -msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +msgid "This app wants to use your{0} <0/> account through{1} {2}. The proxy keeps the connection under your account; this app may use it until you revoke that." msgstr "" #: src/chunks/AppPage/AppFrame.tsx -msgid "Connect an account" +msgid "Use existing connection" +msgstr "" + +#: src/chunks/AppPage/ProxyConnectReturn.tsx +msgid "Sign in to finish connecting this account." msgstr "" diff --git a/browser/data-browser/src/locales/en.po b/browser/data-browser/src/locales/en.po index 22960acfe1..65171d49c3 100644 --- a/browser/data-browser/src/locales/en.po +++ b/browser/data-browser/src/locales/en.po @@ -9434,11 +9434,22 @@ msgstr "Finishing the connection…" msgid "Sign in to connect an account." msgstr "Sign in to connect an account." -#. 0: ' '; 1: ' '; 2: getIntegrationProxy() -#: src/chunks/AppPage/AppFrame.tsx -msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." -msgstr "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +#~ msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +#~ msgstr "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." #: src/chunks/AppPage/AppFrame.tsx msgid "Connect an account" msgstr "Connect an account" + +#. 0: ' '; 1: ' '; 2: getIntegrationProxy() +#: src/chunks/AppPage/AppFrame.tsx +msgid "This app wants to use your{0} <0/> account through{1} {2}. The proxy keeps the connection under your account; this app may use it until you revoke that." +msgstr "This app wants to use your{0} <0/> account through{1} {2}. The proxy keeps the connection under your account; this app may use it until you revoke that." + +#: src/chunks/AppPage/AppFrame.tsx +msgid "Use existing connection" +msgstr "Use existing connection" + +#: src/chunks/AppPage/ProxyConnectReturn.tsx +msgid "Sign in to finish connecting this account." +msgstr "Sign in to finish connecting this account." diff --git a/browser/data-browser/src/locales/es.po b/browser/data-browser/src/locales/es.po index 58f207142a..c76aab7d6e 100644 --- a/browser/data-browser/src/locales/es.po +++ b/browser/data-browser/src/locales/es.po @@ -9416,11 +9416,22 @@ msgstr "" msgid "Sign in to connect an account." msgstr "" +#~ msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +#~ msgstr "" + +#: src/chunks/AppPage/AppFrame.tsx +msgid "Connect an account" +msgstr "" + #. 0: ' '; 1: ' '; 2: getIntegrationProxy() #: src/chunks/AppPage/AppFrame.tsx -msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +msgid "This app wants to use your{0} <0/> account through{1} {2}. The proxy keeps the connection under your account; this app may use it until you revoke that." msgstr "" #: src/chunks/AppPage/AppFrame.tsx -msgid "Connect an account" +msgid "Use existing connection" +msgstr "" + +#: src/chunks/AppPage/ProxyConnectReturn.tsx +msgid "Sign in to finish connecting this account." msgstr "" diff --git a/browser/data-browser/src/locales/fr.po b/browser/data-browser/src/locales/fr.po index b43fb625f4..7f6a79d999 100644 --- a/browser/data-browser/src/locales/fr.po +++ b/browser/data-browser/src/locales/fr.po @@ -9416,11 +9416,22 @@ msgstr "" msgid "Sign in to connect an account." msgstr "" +#~ msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +#~ msgstr "" + +#: src/chunks/AppPage/AppFrame.tsx +msgid "Connect an account" +msgstr "" + #. 0: ' '; 1: ' '; 2: getIntegrationProxy() #: src/chunks/AppPage/AppFrame.tsx -msgid "This app wants to connect your{0} <0/> account through{1} {2}. The connection stays in this browser; the app can only make requests through it." +msgid "This app wants to use your{0} <0/> account through{1} {2}. The proxy keeps the connection under your account; this app may use it until you revoke that." msgstr "" #: src/chunks/AppPage/AppFrame.tsx -msgid "Connect an account" +msgid "Use existing connection" +msgstr "" + +#: src/chunks/AppPage/ProxyConnectReturn.tsx +msgid "Sign in to finish connecting this account." msgstr "" diff --git a/browser/plugin/src/viewProtocol.test.ts b/browser/plugin/src/viewProtocol.test.ts index 33f291857c..93493545ec 100644 --- a/browser/plugin/src/viewProtocol.test.ts +++ b/browser/plugin/src/viewProtocol.test.ts @@ -138,10 +138,7 @@ it('lets a generated view wait for host recovery but still rejects a silent host expect(vi.getTimerCount()).toBe(0); }); -it('carries the proxy relay ops, and store.proxy sends them', () => { - for (const op of ['proxy', 'proxyConnections', 'proxyConnect'] as const) - expect(isViewRequest(viewRequest(1, op, { platform: 'pets' }))).toBe(true); - const f = frame(); +function generatedStore(f: ReturnType) { const source = readFileSync( new URL( '../../../server/src/plugins/assets/view-client.js', @@ -149,7 +146,8 @@ it('carries the proxy relay ops, and store.proxy sends them', () => { ), 'utf8', ); - const store = new Function( + + return new Function( 'window', 'setTimeout', 'clearTimeout', @@ -159,20 +157,195 @@ it('carries the proxy relay ops, and store.proxy sends them', () => { () => 0, () => undefined, ); - void store.proxy.request({ - platform: 'pets', - connectionId: 'c1', - path: '/pets', - query: { page: '2' }, - }); +} + +const b64 = (s: string) => + Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), c => + c.charCodeAt(0), + ); + +async function sha256Hex(text: string) { + return Array.from( + new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)), + ), + b => b.toString(16).padStart(2, '0'), + ).join(''); +} + +/** Lets queued promise callbacks (WebCrypto, postMessage replies) run. */ +const settle = async () => { + for (let i = 0; i < 20; i++) await new Promise(r => setTimeout(r, 0)); +}; + +it('carries the proxy ops; the relay op is gone', () => { + for (const op of [ + 'proxyCapability', + 'proxyConnections', + 'proxyConnect', + ] as const) + expect(isViewRequest(viewRequest(1, op, { platform: 'pets' }))).toBe(true); + expect(isViewRequest({ ...viewRequest(1, 'get'), op: 'proxy' })).toBe(false); + + const f = frame(); + const store = generatedStore(f); void store.proxy.connections({ platform: 'pets' }); void store.proxy.connect({ platform: 'pets' }); const sent = f.parent.postMessage.mock.calls.map(([m]) => m); expect(sent.every(isViewRequest)).toBe(true); expect(sent.map(m => [m.op, m.args.platform])).toEqual([ - ['proxy', 'pets'], ['proxyConnections', 'pets'], ['proxyConnect', 'pets'], ]); - expect(sent[0].args).toMatchObject({ connectionId: 'c1', path: '/pets' }); +}); + +it('calls the proxy directly with a capability and a v2 signature by its own key', async () => { + const f = frame(); + const store = generatedStore(f); + const fetches: { url: string; init: RequestInit }[] = []; + let refusals = 1; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit) => { + fetches.push({ url, init }); + + if (refusals-- > 0) + return new Response( + JSON.stringify({ error: 'capability_expired', message: 'expired' }), + { status: 401 }, + ); + + return new Response(JSON.stringify([{ id: 1 }]), { + status: 200, + headers: { + 'content-type': 'application/json', + link: '; rel="next"', + 'set-cookie': 'never=forwarded', + }, + }); + }), + ); + + const pending = store.proxy.request({ + platform: 'pets', + connectionId: 'c 1', + path: '/pets', + method: 'post', + query: { page: '2' }, + body: '{"name":"Rex"}', + }); + + // Two mints: the first capability is refused as expired, once. + for (let mint = 0; mint < 2; mint++) { + await settle(); + const ask = f.parent.postMessage.mock.calls.at(-1)![0]; + expect(isViewRequest(ask)).toBe(true); + expect(ask.op).toBe('proxyCapability'); + expect(ask.args).toMatchObject({ platform: 'pets', connectionId: 'c 1' }); + expect(ask.args.publicKey).toMatch(/^[A-Za-z0-9_-]{43}$/); + f.reply({ + type: 'atomic.view.response', + version: 1, + id: ask.id, + result: { + capability: `cap-${mint}`, + aud: 'https://proxy.example', + exp: Math.floor(Date.now() / 1000) + 600, + }, + }); + } + + const result = await pending; + expect(result).toEqual({ + status: 200, + headers: { + 'content-type': 'application/json', + link: '; rel="next"', + }, + body: [{ id: 1 }], + }); + expect(fetches).toHaveLength(2); + + const { url, init } = fetches[1]; + expect(url).toBe('https://proxy.example/proxy/c%201/pets/pets?page=2'); + expect(init).toMatchObject({ + method: 'POST', + body: '{"name":"Rex"}', + credentials: 'omit', + redirect: 'error', + }); + const headers = init.headers as Record; + expect(headers.Authorization).toBe('Capability cap-1'); + expect(headers['x-atomic-signature-version']).toBe('2'); + const publicKey = headers['x-atomic-public-key']; + expect(headers['x-atomic-agent']).toBe(`atomic:agent:${publicKey}`); + const message = [ + 'atomic-request-v2', + 'POST', + url, + headers['x-atomic-timestamp'], + await sha256Hex('{"name":"Rex"}'), + ].join('\n'); + const key = await crypto.subtle.importKey( + 'raw', + b64(publicKey), + { name: 'Ed25519' }, + false, + ['verify'], + ); + expect( + await crypto.subtle.verify( + { name: 'Ed25519' }, + key, + b64(headers['x-atomic-signature']), + new TextEncoder().encode(message), + ), + ).toBe(true); + // Same frame key for both attempts; it never leaves the frame. + expect( + (fetches[0].init.headers as Record)['x-atomic-public-key'], + ).toBe(publicKey); +}); + +it('refuses a path that would leave the connection, before asking anything', async () => { + const f = frame(); + const store = generatedStore(f); + vi.stubGlobal('fetch', vi.fn()); + const pending = store.proxy.request({ + platform: 'pets', + connectionId: 'c1', + path: '/../../connections', + }); + await settle(); + const ask = f.parent.postMessage.mock.calls.at(-1)![0]; + f.reply({ + type: 'atomic.view.response', + version: 1, + id: ask.id, + result: { + capability: 'cap', + aud: 'https://proxy.example', + exp: Math.floor(Date.now() / 1000) + 600, + }, + }); + await expect(pending).rejects.toThrow('Invalid proxy path'); + expect(fetch).not.toHaveBeenCalled(); +}); + +it('says so plainly where WebCrypto has no Ed25519', async () => { + const f = frame(); + const store = generatedStore(f); + const original = crypto.subtle.generateKey; + (crypto.subtle as { generateKey: unknown }).generateKey = () => + Promise.reject(new DOMException('Unrecognized name', 'NotSupportedError')); + + try { + await expect( + store.proxy.request({ platform: 'pets', connectionId: 'c1', path: '/' }), + ).rejects.toThrow(/cannot make an Ed25519 key/); + } finally { + (crypto.subtle as { generateKey: unknown }).generateKey = original; + } + + expect(f.parent.postMessage).not.toHaveBeenCalled(); }); diff --git a/browser/plugin/src/viewProtocol.ts b/browser/plugin/src/viewProtocol.ts index 02ae427a9f..477b66dc5d 100644 --- a/browser/plugin/src/viewProtocol.ts +++ b/browser/plugin/src/viewProtocol.ts @@ -16,9 +16,12 @@ export type ViewOperation = | 'search' | 'subscribe' | 'unsubscribe' - /** Relay one integration-proxy call; the host holds the connection. */ - | 'proxy' - /** Connection references (never credentials) this app may relay through. */ + /** + * A capability for one integration-proxy connection, bound to the frame's + * own public key and signed by the user (ontola/atomic-plugins#54). + */ + | 'proxyCapability' + /** Connection references (never credentials) delegated to this app. */ | 'proxyConnections' /** Ask the person, in host UI, to connect a proxy platform for this app. */ | 'proxyConnect'; @@ -74,7 +77,7 @@ export function isViewRequest(value: unknown): value is ViewRequest { 'search', 'subscribe', 'unsubscribe', - 'proxy', + 'proxyCapability', 'proxyConnections', 'proxyConnect', ].includes(request.op) && diff --git a/server/src/plugins/assets/view-client.js b/server/src/plugins/assets/view-client.js index c625a89c8c..e048133e1c 100644 --- a/server/src/plugins/assets/view-client.js +++ b/server/src/plugins/assets/view-client.js @@ -155,35 +155,226 @@ export const store = { }, /** - * The integration proxy, reached through the host. + * The integration proxy (ontola/atomic-plugins#54). * - * This frame never holds a credential: it names a connection by its public - * reference (`platform` + `connectionId`) and the host page, which holds the - * connection, makes the call and returns only status, a few headers and the - * body. A connection is usable only by the app it was made for. + * This frame calls the proxy itself, but never holds a credential that + * works anywhere else. It makes its own Ed25519 key, in memory and + * non-extractable, when it first needs one. The host page, which holds the + * user's key, checks that the connection is delegated to this app and signs + * a capability bound to this frame's key, valid for minutes. Each request + * then carries the capability and is signed with the frame's key (a version + * 2 request signature: method, full URL, timestamp and body hash), so a + * copied capability is useless outside this frame. */ proxy: { /** * One provider call. `path` is the provider path (after the proxy's - * `/proxy/` prefix); `body`, when given, is JSON text. Resolves - * to `{ status, headers, body }`, `body` parsed as JSON when it is JSON. + * `/proxy//` prefix); `body`, when given, is JSON + * text. Resolves to `{ status, headers, body }`, `body` parsed as JSON + * when it is JSON. */ - async request({ platform, connectionId, path, method, query, body, ifMatch }) { - return send('proxy', { platform, connectionId, path, method, query, body, ifMatch }); + async request({ platform, connectionId, path, method = 'GET', query, body, ifMatch }) { + return proxyRequest({ platform, connectionId, path, method, query, body, ifMatch }); }, - /** This app's connections for `platform` in this browser: `[{ connectionId, platform }]`. */ + /** Connections for `platform` delegated to this app: `[{ connectionId, platform }]`. */ async connections({ platform }) { return send('proxyConnections', { platform }); }, /** * Asks the person, in the host's own UI, to connect `platform` for this - * app. On consent the page navigates to the proxy and back, reloading - * this view, so the promise only settles when they cancel. + * app. If they pick a connection they already have, resolves to + * `{ status: 'connected', connectionId, platform }`; if they connect a new + * one, the page goes to the proxy and back, reloading this view, so the + * promise does not settle; if they cancel, `{ status: 'cancelled' }`. */ async connect({ platform }) { return send('proxyConnect', { platform }); }, }, }; + +// ---- Integration proxy: frame key, capabilities, signed requests ---- + +const PROXY_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; +const PROXY_HEADERS = ['link', 'retry-after', 'etag', 'content-type']; +const PROXY_MAX_BODY = 10 * 1024 * 1024; +/** Mint a new capability this long before the current one expires. */ +const CAPABILITY_MARGIN_MS = 60_000; + +let frameKey; +const capabilities = new Map(); + +const b64url = bytes => + btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + +const hex = bytes => Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join(''); + +/** + * This frame's key: made once, kept only in this frame's memory, and never + * exportable. Where WebCrypto has no Ed25519 (older Safari and Android + * WebViews, atomic-server#1688) this fails with a clear message instead of + * falling back to a key script could read. + */ +function getFrameKey() { + frameKey ??= (async () => { + if (!globalThis.crypto?.subtle) { + throw new Error('This browser has no WebCrypto here, so this view cannot sign integration requests.'); + } + + let pair; + + try { + pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']); + } catch (e) { + throw new Error(`This browser cannot make an Ed25519 key (WebCrypto Ed25519 is missing, see atomic-server#1688), so this view cannot reach integrations: ${e?.message ?? e}`); + } + + const publicKey = b64url(await crypto.subtle.exportKey('raw', pair.publicKey)); + + return { privateKey: pair.privateKey, publicKey, agent: `atomic:agent:${publicKey}` }; + })(); + frameKey.catch(() => { + frameKey = undefined; + }); + + return frameKey; +} + +async function capabilityFor(platform, connectionId, fresh) { + const key = JSON.stringify([platform, connectionId]); + const cached = capabilities.get(key); + + if (!fresh && cached && cached.exp * 1000 - Date.now() > CAPABILITY_MARGIN_MS) return cached; + + const { publicKey } = await getFrameKey(); + const minted = await send('proxyCapability', { platform, connectionId, publicKey }); + + if (!minted || typeof minted.capability !== 'string' || typeof minted.aud !== 'string') { + throw new Error('The host returned no capability.'); + } + + capabilities.set(key, minted); + + return minted; +} + +/** Version 2 request signature headers, signed with this frame's key. */ +async function signV2(method, url, body) { + const { privateKey, publicKey, agent } = await getFrameKey(); + const timestamp = Date.now(); + const bytes = new TextEncoder().encode(body ?? ''); + const digest = hex(await crypto.subtle.digest('SHA-256', bytes)); + const message = ['atomic-request-v2', method, url, String(timestamp), digest].join('\n'); + const signature = b64url(await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, new TextEncoder().encode(message))); + + return { + 'x-atomic-agent': agent, + 'x-atomic-public-key': publicKey, + 'x-atomic-timestamp': String(timestamp), + 'x-atomic-signature': signature, + 'x-atomic-signature-version': '2', + }; +} + +function proxyTarget(aud, connectionId, platform, path, query) { + if (typeof platform !== 'string' || !/^[a-z0-9-]{1,80}$/.test(platform)) throw new Error('Invalid platform'); + if (typeof connectionId !== 'string' || !connectionId) throw new Error('connectionId is required'); + if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || /[\\#]/.test(path) || path.length > 4096) { + throw new Error('Invalid proxy path'); + } + + const prefix = `/proxy/${encodeURIComponent(connectionId)}/${platform}/`; + const url = new URL(`${prefix.slice(0, -1)}${path}`, aud); + + if (url.origin !== new URL(aud).origin || !url.pathname.startsWith(prefix)) throw new Error('Invalid proxy path'); + + for (const [k, v] of Object.entries(query ?? {})) { + if (typeof v !== 'string') throw new Error('Invalid proxy query'); + url.searchParams.set(k, v); + } + + return url; +} + +async function limitedText(response) { + if (!response.body) return ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ''; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > PROXY_MAX_BODY) throw new Error('Proxy response exceeds 10 MB'); + text += decoder.decode(value, { stream: true }); + } + + return text + decoder.decode(); + } finally { + await reader.cancel().catch(() => undefined); + } +} + +function parseBody(text) { + if (text === '') return null; + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function proxyRequest({ platform, connectionId, path, method, query, body, ifMatch }) { + const verb = String(method).toUpperCase(); + if (!PROXY_METHODS.includes(verb)) throw new Error('Invalid proxy method'); + if (body !== undefined && typeof body !== 'string') throw new Error('A proxy request body is JSON text'); + if (body !== undefined && verb === 'GET') throw new Error('A GET proxy request has no body'); + if (ifMatch !== undefined && typeof ifMatch !== 'string') throw new Error('Invalid If-Match'); + + const attempt = async fresh => { + const cap = await capabilityFor(platform, connectionId, fresh); + const url = proxyTarget(cap.aud, connectionId, platform, path, query).href; + const headers = { + Authorization: `Capability ${cap.capability}`, + ...(await signV2(verb, url, body)), + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(ifMatch ? { 'If-Match': ifMatch } : {}), + }; + + return fetch(url, { + method: verb, + body, + headers, + credentials: 'omit', + redirect: 'error', + signal: AbortSignal.timeout(30000), + }); + }; + + let response = await attempt(false); + let text = await limitedText(response); + let parsed = parseBody(text); + + // The capability ran out between minting and use (a laptop lid, a slow + // tab): mint a new one once. Any other refusal is the answer. + if (response.status === 401 && parsed?.error === 'capability_expired') { + response = await attempt(true); + text = await limitedText(response); + parsed = parseBody(text); + } + + const headers = {}; + + for (const name of PROXY_HEADERS) { + const value = response.headers.get(name); + if (value !== null) headers[name] = value; + } + + return { status: response.status, headers, body: parsed }; +} From c98a30233240fc969eaa33772625b49344e1afce Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Thu, 24 Sep 2026 17:36:07 +0200 Subject: [PATCH 2/2] Register Installation runtimes with the proxy; name the Installation's app id (#1700) Gap 1: the page now calls the integration proxy's runtimes routes (ontola/atomic-plugins#122), signed v2 with the user key: - POST /runtimes {app, agent, label} for each InstallationRuntime child whose genesis the named agent signed, with the Installation's integrationAppAgent as app and the node's name as label. Runs when an Installation page or app frame opens, when a runtime child appears, and after a delegation. Skips runtimes already registered in this page or listed by GET /connections for the same app and label; the proxy upserts anyway. In the background; failures are toasted. - DELETE /runtimes/{agent} on revoke and on uninstall. Gap 2: appAgentOf prefers the Installation's integrationAppAgent and only falls back to GET /app-agent (createApp apps, older Installations), so delegations and frame capabilities name the app id rather than one node's agent. Merges #1697 (claude/frame-proxy-capability), which holds appAgentOf and the v2 ProxyConnections this builds on and is not in #1725's stack. Co-Authored-By: Claude Opus 5.5 --- TESTING_COVERAGE.md | 15 +- browser/CHANGELOG.md | 1 + .../src/chunks/AppPage/AppFrame.tsx | 18 +- .../src/chunks/AppPage/appAgent.test.ts | 76 +++ .../src/chunks/AppPage/appAgent.ts | 22 +- .../src/helpers/installationRuntimes.test.ts | 457 ++++++++++++++++++ .../src/helpers/installationRuntimes.ts | 240 +++++++++ .../src/helpers/proxyConnections.ts | 48 ++ .../src/helpers/useInstallationRuntimes.ts | 104 ++++ .../views/Installation/InstallationPage.tsx | 27 ++ 10 files changed, 995 insertions(+), 13 deletions(-) create mode 100644 browser/data-browser/src/chunks/AppPage/appAgent.test.ts create mode 100644 browser/data-browser/src/helpers/installationRuntimes.test.ts create mode 100644 browser/data-browser/src/helpers/installationRuntimes.ts create mode 100644 browser/data-browser/src/helpers/useInstallationRuntimes.ts diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md index eec866aa5b..3af927dfd2 100644 --- a/TESTING_COVERAGE.md +++ b/TESTING_COVERAGE.md @@ -236,10 +236,17 @@ it; that `integrationConnections` must map platforms to id strings; and that a server-side JS run gets `ctx.app` and `ctx.connections` from the Installation, over whatever the caller sent. `browser/lib/src/plugin-install.test.ts` checks `installRelease` records a fresh `atomic:agent:` in the genesis with no key -material. Not covered: the page reading the runtime child and calling -`POST /runtimes`, the page writing `integrationConnections` when it delegates, -a second node publishing its own runtime child, and syncing those children -between nodes. +material. `data-browser/src/helpers/installationRuntimes.test.ts` checks the +page's side against a fake proxy that mirrors atomic-plugins#122's routes and +verifies every v2 signature: `POST /runtimes {app, agent, label}` for each +runtime child whose genesis the named agent signed, nothing posted again in +the same page or when the proxy already lists it, a re-post when the label +changes, `DELETE /runtimes/{agent}` on revoke, and a retry after a proxy error. +`chunks/AppPage/appAgent.test.ts` checks that `appAgentOf` prefers the +Installation's `integrationAppAgent` and falls back to `GET /app-agent`. Not +covered: the page writing `integrationConnections` when it delegates, a real +proxy accepting the page's calls, a second node publishing its own runtime +child, and syncing those children between nodes. `atomic-proxy:` URLs (#1700, answer 4): the shared fixtures in `testdata/plugin-manifest/` (Rust `shared_manifest_conformance` and the diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index e0d38f1df8..04eb8a4416 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol ## UNRELEASED +- The page registers an Installation's runtimes with the integration proxy (#1700): each node's agent, published on an `InstallationRuntime` child, becomes a runtime of the Installation's app id via `POST /runtimes {app, agent, label}`, signed v2 with the user key. It happens when an Installation page or app frame opens and when a runtime child appears, and after a delegation. It is idempotent and does not hold up the UI; failures show as a toast. Revoking or uninstalling calls `DELETE /runtimes/{agent}`. A child counts only when its genesis was signed by the agent it names. Delegations and frame capabilities now name an Installation's `integrationAppAgent` rather than the node's agent from `GET /app-agent`, which stays the fallback for `createApp` apps and older Installations. - App frames call the integration proxy directly, with a capability instead of a relay (ontola/atomic-plugins#54; replaces #1657's `proxy` relay and LocalThought's rotating connection codes, which the new proxy no longer issues). A frame makes its own non-extractable Ed25519 key in memory; the page checks that the connection is delegated to the app and signs a capability bound to that key (`integration-proxy-capability-v2`, 10 minutes); each request is signed by the frame key with a version 2 request signature. `store.proxy.request(...)` keeps its shape; it mints a new capability shortly before expiry and once more on a `capability_expired` refusal. View op `proxy` is gone, `proxyCapability` is new. Connecting no longer logs in at the proxy: the page redeems the handoff signed with the user key, which makes the user the connection's owner, then delegates it to the app (also signed). The connect bar offers an existing connection for the same platform, which only adds a delegation. Only apps with their own agent (made with `createApp`) can be given a connection for now; others are told so. Where WebCrypto has no Ed25519 the frame fails with a message saying so. Existing connections are not migrated: connect again. - `@tomic/lib`: `signRequest(url, agent, headers, { method, body })` makes a version 2 request signature (ontola/atomic-plugins#54), which also covers the method and the SHA-256 of the body and sends `x-atomic-signature-version: 2`. Without `method` it signs version 1 as before, and a string fourth argument is still the legacy subject. New exports `requestSignatureMessageV2`, `sha256Hex` and `SIGNATURE_VERSION_HEADER`. `Agent.generateNonExtractable()` makes an agent whose Ed25519 key is a non-extractable WebCrypto key, with subject `atomic:agent:`; where WebCrypto has no Ed25519 it throws instead of falling back to an extractable key. - Plugins moved to [atomic-plugins](https://github.com/ontola/atomic-plugins). The Integrations page lists what that catalog publishes (`catalog.json`, fetched at runtime; the URL is configurable under Settings > Integration) instead of plugins compiled into the app and server. The bundled GitHub issues, Notion, Pets, MT940/camt.053, Clockify and Google Calendar (Devonian) integrations, the `devonian` dependency and the `/app/devonian-demo` page are removed. The Todoist lens and the Moneybird administration picker are removed too. LocalThought connect and sync, and the "Show API plugins" toggle, are removed from the app: plugins will run in their own iframe and make proxy calls through the host. diff --git a/browser/data-browser/src/chunks/AppPage/AppFrame.tsx b/browser/data-browser/src/chunks/AppPage/AppFrame.tsx index 5bc8be1548..7c6c129ed3 100644 --- a/browser/data-browser/src/chunks/AppPage/AppFrame.tsx +++ b/browser/data-browser/src/chunks/AppPage/AppFrame.tsx @@ -21,6 +21,7 @@ import { type ProxyConnection, } from '@helpers/proxyConnections'; import { appAgentOf } from './appAgent'; +import { registerRuntimesInBackground } from '@helpers/useInstallationRuntimes'; /** Changing installation or destination must discard source tokens and pending replies. */ export function AppFrame(props: Parameters[0]) { @@ -115,6 +116,14 @@ function AppFrameSession({ }; }, [store, drive, app]); + // An Installation's nodes act for its app id at the proxy only once they + // are registered as its runtimes. Covers coming back from a connect + // handoff, which delegates and then reloads this page. A no-op for + // `createApp` apps. + useEffect(() => { + registerRuntimesInBackground(store, app); + }, [store, app]); + useEffect(() => { if (!entrypoint) return; @@ -320,7 +329,10 @@ function AppFrameSession({ await appLabel(store, app), ); })() - .then(() => + .then(() => { + // An Installation's nodes act for its app id only once registered + // as runtimes; not waited on, failures are toasted. + registerRuntimesInBackground(store, app); finishAsk({ id: connectAsk.id, result: { @@ -328,8 +340,8 @@ function AppFrameSession({ connectionId: connection.connection_id, platform: connection.platform, }, - }), - ) + }); + }) .catch((e: Error) => finishAsk({ id: connectAsk.id, error: e.message })); }; diff --git a/browser/data-browser/src/chunks/AppPage/appAgent.test.ts b/browser/data-browser/src/chunks/AppPage/appAgent.test.ts new file mode 100644 index 0000000000..fa775b7b24 --- /dev/null +++ b/browser/data-browser/src/chunks/AppPage/appAgent.test.ts @@ -0,0 +1,76 @@ +// @wc-ignore-file +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + Agent, + JSCryptoProvider, + server, + type Resource, + type Store, +} from '@tomic/react'; +import { appAgentOf } from './appAgent'; + +const PRIVATE_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const USER_PUBLIC = 'O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik'; +const APP_ID = `atomic:agent:${'A'.repeat(42)}E`; +const NODE_AGENT = `atomic:agent:${'B'.repeat(42)}E`; + +function fakeStore(props: Record) { + return { + getAgent: () => + new Agent( + new JSCryptoProvider(PRIVATE_KEY), + `did:ad:agent:${USER_PUBLIC}`, + ), + getServerUrl: () => 'http://node.test', + getResource: async () => + ({ get: (p: string) => props[p] }) as unknown as Resource, + } as unknown as Store; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('appAgentOf', () => { + it("prefers an Installation's own app id and does not ask the node", async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + expect( + await appAgentOf( + fakeStore({ [server.properties.integrationAppAgent]: APP_ID }), + { drive: 'did:ad:drive', app: 'did:ad:installation' }, + ), + ).toBe(APP_ID); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('falls back to GET /app-agent for createApp apps and older Installations', async () => { + const fetch = vi.fn(async () => Response.json({ agent: NODE_AGENT })); + vi.stubGlobal('fetch', fetch); + + expect( + await appAgentOf(fakeStore({}), { + drive: 'did:ad:drive', + app: 'did:ad:app', + }), + ).toBe(NODE_AGENT); + const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe('http://node.test/app-agent'); + expect(parsed.searchParams.get('app')).toBe('did:ad:app'); + expect(parsed.searchParams.get('drive')).toBe('did:ad:drive'); + expect( + (init.headers as Record)['x-atomic-signature'], + ).toBeTruthy(); + }); + + it('says so when neither has an identity', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({})), + ); + + await expect( + appAgentOf(fakeStore({}), { drive: 'd', app: 'a' }), + ).rejects.toThrow('no identity of its own'); + }); +}); diff --git a/browser/data-browser/src/chunks/AppPage/appAgent.ts b/browser/data-browser/src/chunks/AppPage/appAgent.ts index ca51620eac..69c0b48216 100644 --- a/browser/data-browser/src/chunks/AppPage/appAgent.ts +++ b/browser/data-browser/src/chunks/AppPage/appAgent.ts @@ -1,5 +1,6 @@ import { errorMessageFromResponse, + server, signRequest, type Store, } from '@tomic/react'; @@ -45,13 +46,16 @@ export async function handOverAppKey( } /** - * The agent an app acts as (`atomic:agent:…` or the older `did:ad:agent:…`), - * as the node that holds its key reports it. Integration-proxy connections - * are delegated to this agent (ontola/atomic-plugins#54). + * The agent integration-proxy delegations and frame capabilities name for an + * app (ontola/atomic-plugins#54). * - * Only apps made with `createApp` have one today. An installed catalog plugin - * gets its own identity in a later step (#54 decision 2); until then it cannot - * be given a proxy connection, and this says so rather than guessing. + * An Installation records its own keyless app id (`integrationAppAgent`, + * #1700 answer 1), which is the same on every node; each node's agent is only + * a runtime of it. That id wins. Asking the node (`GET /app-agent`) would + * name this node's runtime agent instead, so a delegation would reach one + * node and not the others. `createApp` apps, and Installations from before + * app ids, have no such property: for them the node that holds the app's + * key reports it. */ export async function appAgentOf( store: Store, @@ -61,6 +65,12 @@ export async function appAgentOf( if (!agent) throw new Error('Sign in to use integration connections.'); + const recorded = (await store.getResource(options.app)).get( + server.properties.integrationAppAgent, + ); + + if (typeof recorded === 'string' && recorded) return recorded; + const url = new URL('/app-agent', store.getServerUrl()); url.searchParams.set('drive', options.drive); url.searchParams.set('app', options.app); diff --git a/browser/data-browser/src/helpers/installationRuntimes.test.ts b/browser/data-browser/src/helpers/installationRuntimes.test.ts new file mode 100644 index 0000000000..3c2fe298e6 --- /dev/null +++ b/browser/data-browser/src/helpers/installationRuntimes.test.ts @@ -0,0 +1,457 @@ +// @wc-ignore-file +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + Agent, + JSCryptoProvider, + core, + decodeB64, + server, + type Resource, + type Store, +} from '@tomic/react'; +import { ProxyConnections } from './proxyConnections'; +import { + asRuntime, + forgetRegisteredRuntimes, + registerInstallationRuntimes, + registerRuntimes, + unregisterRuntimes, +} from './installationRuntimes'; + +const ORIGIN = 'http://proxy.test'; +/** The key of `lib/src/authentication_v2_vectors.json`. */ +const PRIVATE_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const USER_PUBLIC = 'O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik'; +const USER = `atomic:agent:${USER_PUBLIC}`; +const APP = `atomic:agent:${'A'.repeat(42)}E`; +const NODE_A = `atomic:agent:${'B'.repeat(42)}E`; +const NODE_B = `atomic:agent:${'C'.repeat(42)}E`; +const INSTALLATION = 'did:ad:installation'; + +const user = () => + new Agent(new JSCryptoProvider(PRIVATE_KEY), `did:ad:agent:${USER_PUBLIC}`); + +async function sha256Hex(text: string) { + return Array.from( + new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)), + ), + b => b.toString(16).padStart(2, '0'), + ).join(''); +} + +async function verified( + method: string, + url: string, + body: string | undefined, + h: Record, +) { + const message = [ + 'atomic-request-v2', + method, + url, + h['x-atomic-timestamp'], + await sha256Hex(body ?? ''), + ].join('\n'); + const key = await crypto.subtle.importKey( + 'raw', + new Uint8Array(decodeB64(h['x-atomic-public-key'])), + { name: 'Ed25519' }, + false, + ['verify'], + ); + + return ( + h['x-atomic-signature-version'] === '2' && + h['x-atomic-agent'] === `atomic:agent:${h['x-atomic-public-key']}` && + crypto.subtle.verify( + { name: 'Ed25519' }, + key, + new Uint8Array(decodeB64(h['x-atomic-signature'])), + new TextEncoder().encode(message), + ) + ); +} + +/** + * The proxy's runtimes routes as atomic-plugins#122 implements them: every + * call signed v2 by the owner, `POST /runtimes {app, agent, label?}` with no + * other fields, upserted on (owner, agent), and `DELETE /runtimes/{agent}`. + */ +function fakeProxy() { + const calls: { + method: string; + url: string; + body?: string; + headers: Record; + }[] = []; + const runtimes = new Map< + string, + { agent: string; app: string; label: string | null } + >(); + let failNext: number | undefined; + const http = vi.fn(async (url: string, init: RequestInit = {}) => { + const method = init.method ?? 'GET'; + const body = init.body as string | undefined; + const headers = init.headers as Record; + calls.push({ method, url, body, headers }); + + if (!(await verified(method, url, body, headers))) + return Response.json( + { error: 'bad_signature', message: 'no' }, + { status: 401 }, + ); + + if (failNext) { + const status = failNext; + failNext = undefined; + + return Response.json( + { error: 'unavailable', message: 'try later' }, + { status }, + ); + } + + const path = new URL(url).pathname; + + if (path === '/connections' && method === 'GET') + return Response.json({ + owner: headers['x-atomic-agent'], + connections: [], + runtimes: [...runtimes.values()], + }); + + if (path === '/runtimes' && method === 'POST') { + const parsed = JSON.parse(body!); + const keys = Object.keys(parsed).sort(); + if ( + !['agent,app', 'agent,app,label'].includes(keys.join(',')) || + parsed.agent === parsed.app || + parsed.agent === headers['x-atomic-agent'] + ) + return Response.json( + { error: 'bad_request', message: 'body' }, + { status: 400 }, + ); + runtimes.set(parsed.agent, { + agent: parsed.agent, + app: parsed.app, + label: parsed.label ?? null, + }); + + return Response.json(parsed); + } + + const del = /^\/runtimes\/([^/]+)$/.exec(path); + + if (del && method === 'DELETE') { + runtimes.delete(decodeURIComponent(del[1])); + + return new Response(null, { status: 204 }); + } + + return Response.json( + { error: 'not_found', message: path }, + { status: 404 }, + ); + }); + + return { + http: http as unknown as typeof fetch, + calls, + runtimes, + fail(status: number) { + failNext = status; + }, + }; +} + +const connectionsFor = (proxy: ReturnType) => + new ProxyConnections( + new Map() as unknown as Storage, + ORIGIN, + user, + proxy.http, + ); + +const RUNTIMES = [ + { subject: 'did:ad:rt-a', agent: NODE_A, label: 'Laptop' }, + { subject: 'did:ad:rt-b', agent: NODE_B, label: 'Server' }, +]; + +/** A resource as far as these helpers read one. */ +function fakeResource( + subject: string, + props: Record, + createdBy?: string, +): Resource { + return { + subject, + get: (p: string) => props[p], + hasClasses: (...c: string[]) => + c.every(x => + ((props[core.properties.isA] as string[]) ?? []).includes(x), + ), + getCreatedBy: () => createdBy, + } as unknown as Resource; +} + +const runtimeChild = ( + subject: string, + agent: string, + name: string, + createdBy = agent, +) => + fakeResource( + subject, + { + [core.properties.isA]: [server.classes.installationRuntime], + [core.properties.parent]: INSTALLATION, + [server.properties.integrationRuntimeAgent]: agent, + [core.properties.name]: name, + }, + createdBy, + ); + +beforeEach(() => forgetRegisteredRuntimes()); + +describe('registerRuntimes', () => { + it('posts {app, agent, label} signed v2 by the user over method, URL and body', async () => { + const proxy = fakeProxy(); + const posted = await registerRuntimes( + connectionsFor(proxy), + USER, + APP, + RUNTIMES, + ); + + expect(posted.sort()).toEqual([NODE_A, NODE_B].sort()); + expect(proxy.calls.map(c => [c.method, c.url])).toEqual([ + ['GET', `${ORIGIN}/connections`], + ['POST', `${ORIGIN}/runtimes`], + ['POST', `${ORIGIN}/runtimes`], + ]); + // The two POSTs run concurrently, so either may come first. + const post = proxy.calls.find( + c => c.method === 'POST' && JSON.parse(c.body!).agent === NODE_A, + )!; + expect(JSON.parse(post.body!)).toEqual({ + app: APP, + agent: NODE_A, + label: 'Laptop', + }); + expect(post.headers['Content-Type']).toBe('application/json'); + expect(post.headers['x-atomic-agent']).toBe(USER); + // The fake proxy answered 200 only because each signature verified. + expect(await verified(post.method, post.url, post.body, post.headers)).toBe( + true, + ); + // Tampering with the body breaks the signature. + expect( + await verified(post.method, post.url, `${post.body} `, post.headers), + ).toBe(false); + expect(proxy.runtimes.get(NODE_A)).toEqual({ + agent: NODE_A, + app: APP, + label: 'Laptop', + }); + expect(proxy.runtimes.get(NODE_B)).toEqual({ + agent: NODE_B, + app: APP, + label: 'Server', + }); + }); + + it('is idempotent: nothing is posted again in this page or after a reload', async () => { + const proxy = fakeProxy(); + await registerRuntimes(connectionsFor(proxy), USER, APP, RUNTIMES); + proxy.calls.length = 0; + + // Same page: remembered, the proxy is not asked at all. + expect( + await registerRuntimes(connectionsFor(proxy), USER, APP, RUNTIMES), + ).toEqual([]); + expect(proxy.calls).toEqual([]); + + // A reload: the proxy already lists them for this app and label. + forgetRegisteredRuntimes(); + expect( + await registerRuntimes(connectionsFor(proxy), USER, APP, RUNTIMES), + ).toEqual([]); + expect(proxy.calls.map(c => c.method)).toEqual(['GET']); + expect(proxy.runtimes.size).toBe(2); + }); + + it('posts again when the label or the app changed, and only for that one', async () => { + const proxy = fakeProxy(); + await registerRuntimes(connectionsFor(proxy), USER, APP, RUNTIMES); + forgetRegisteredRuntimes(); + proxy.calls.length = 0; + + await registerRuntimes(connectionsFor(proxy), USER, APP, [ + RUNTIMES[0], + { ...RUNTIMES[1], label: 'Renamed server' }, + ]); + expect(proxy.calls.map(c => c.method)).toEqual(['GET', 'POST']); + expect(proxy.runtimes.get(NODE_B)?.label).toBe('Renamed server'); + }); + + it('accepts the app id in its did:ad spelling and sends it canonical', async () => { + const proxy = fakeProxy(); + await registerRuntimes( + connectionsFor(proxy), + USER, + APP.replace('atomic:agent:', 'did:ad:agent:'), + [RUNTIMES[0]], + ); + expect(JSON.parse(proxy.calls[1].body!).app).toBe(APP); + }); + + it('skips a runtime that is the app or the owner, which the proxy refuses', async () => { + const proxy = fakeProxy(); + expect( + await registerRuntimes(connectionsFor(proxy), USER, APP, [ + { subject: 'x', agent: APP }, + { subject: 'y', agent: USER }, + ]), + ).toEqual([]); + expect(proxy.calls).toEqual([]); + }); + + it('rejects with the proxy error, and a later call retries', async () => { + const proxy = fakeProxy(); + await registerRuntimes(connectionsFor(proxy), USER, APP, []); + proxy.fail(503); + await expect( + registerRuntimes(connectionsFor(proxy), USER, APP, [RUNTIMES[0]]), + ).rejects.toThrow( + 'The integration proxy refused GET /connections: try later', + ); + + expect( + await registerRuntimes(connectionsFor(proxy), USER, APP, [RUNTIMES[0]]), + ).toEqual([NODE_A]); + }); +}); + +describe('unregisterRuntimes', () => { + it('sends DELETE /runtimes/{agent}, signed, and forgets the registration', async () => { + const proxy = fakeProxy(); + await registerRuntimes(connectionsFor(proxy), USER, APP, RUNTIMES); + proxy.calls.length = 0; + + await unregisterRuntimes(connectionsFor(proxy), RUNTIMES); + expect(proxy.calls.map(c => [c.method, c.url, c.body]).sort()).toEqual([ + ['DELETE', `${ORIGIN}/runtimes/${encodeURIComponent(NODE_A)}`, undefined], + ['DELETE', `${ORIGIN}/runtimes/${encodeURIComponent(NODE_B)}`, undefined], + ]); + for (const call of proxy.calls) + expect( + await verified(call.method, call.url, call.body, call.headers), + ).toBe(true); + expect(proxy.runtimes.size).toBe(0); + + // Registering again after a revoke goes back to the proxy. + proxy.calls.length = 0; + await registerRuntimes(connectionsFor(proxy), USER, APP, RUNTIMES); + expect(proxy.calls.map(c => c.method)).toEqual(['GET', 'POST', 'POST']); + }); +}); + +describe('asRuntime', () => { + it('reads a runtime child the named agent published itself', () => { + expect( + asRuntime(runtimeChild('did:ad:rt-a', NODE_A, 'Laptop'), INSTALLATION), + ).toEqual({ subject: 'did:ad:rt-a', agent: NODE_A, label: 'Laptop' }); + // The genesis signer may be in the did:ad spelling. + expect( + asRuntime( + runtimeChild( + 'did:ad:rt-a', + NODE_A, + 'Laptop', + NODE_A.replace('atomic:agent:', 'did:ad:agent:'), + ), + INSTALLATION, + )?.agent, + ).toBe(NODE_A); + }); + + it('ignores a child naming an agent that did not publish it', () => { + expect( + asRuntime( + runtimeChild('did:ad:rt-a', NODE_A, 'Laptop', NODE_B), + INSTALLATION, + ), + ).toBeUndefined(); + }); + + it('ignores other children and other parents', () => { + expect( + asRuntime( + fakeResource('did:ad:note', { + [core.properties.parent]: INSTALLATION, + }), + INSTALLATION, + ), + ).toBeUndefined(); + expect( + asRuntime(runtimeChild('did:ad:rt-a', NODE_A, 'Laptop'), 'did:ad:other'), + ).toBeUndefined(); + }); +}); + +describe('registerInstallationRuntimes', () => { + function fakeStore(installation: Resource, children: Resource[]) { + const all = new Map( + [installation, ...children].map(r => [r.subject, r] as const), + ); + + return { + getAgent: user, + getResource: async (s: string) => all.get(s)!, + } as unknown as Store; + } + + it('registers the published runtimes under the Installation app id', async () => { + const proxy = fakeProxy(); + const store = fakeStore( + fakeResource(INSTALLATION, { + [server.properties.integrationAppAgent]: APP, + }), + [ + runtimeChild('did:ad:rt-a', NODE_A, 'Laptop'), + runtimeChild('did:ad:forged', NODE_B, 'Forged', NODE_A), + ], + ); + + expect( + await registerInstallationRuntimes( + store, + connectionsFor(proxy), + INSTALLATION, + ['did:ad:rt-a', 'did:ad:forged'], + ), + ).toEqual([NODE_A]); + expect([...proxy.runtimes.values()]).toEqual([ + { agent: NODE_A, app: APP, label: 'Laptop' }, + ]); + }); + + it('does nothing for an app without an app id', async () => { + const proxy = fakeProxy(); + const store = fakeStore(fakeResource(INSTALLATION, {}), [ + runtimeChild('did:ad:rt-a', NODE_A, 'Laptop'), + ]); + + expect( + await registerInstallationRuntimes( + store, + connectionsFor(proxy), + INSTALLATION, + ['did:ad:rt-a'], + ), + ).toEqual([]); + expect(proxy.calls).toEqual([]); + }); +}); diff --git a/browser/data-browser/src/helpers/installationRuntimes.ts b/browser/data-browser/src/helpers/installationRuntimes.ts new file mode 100644 index 0000000000..1853d0e05d --- /dev/null +++ b/browser/data-browser/src/helpers/installationRuntimes.ts @@ -0,0 +1,240 @@ +// @wc-ignore-file +/** + * Registering an Installation's runtimes with the integration proxy + * (#1700, answers 1 and 2; ontola/atomic-plugins#54 decision 10). + * + * An Installation carries a keyless app id (`integrationAppAgent`). Nobody + * signs as it: delegations and frame capabilities name it. Each node that + * activates the Installation mints its own agent and publishes it on an + * `InstallationRuntime` child. The proxy only lets such an agent use the + * app's delegations once the owner has registered it as a runtime of the app + * (`POST /runtimes {app, agent, label}`), which is what this does, signed with + * the user key. `DELETE /runtimes/{agent}` undoes it when the Installation is + * revoked or uninstalled. + * + * Everything here is idempotent and best effort: the proxy upserts runtimes, + * a runtime already listed for the same app and label is not posted again, + * and callers never wait on it before updating the UI. + */ +import { + agentSubject, + CollectionBuilder, + core, + server, + type Resource, + type Store, +} from '@tomic/react'; +import { canonicalAgent, type ProxyConnections } from './proxyConnections'; + +/** A node's agent, as published on an `InstallationRuntime` child. */ +export interface InstallationRuntime { + subject: string; + /** Canonical `atomic:agent:…`. */ + agent: string; + /** The node's name, which becomes the runtime's label at the proxy. */ + label?: string; +} + +function canonicalOrUndefined(id: unknown): string | undefined { + if (typeof id !== 'string') return undefined; + + try { + return canonicalAgent(id); + } catch { + return undefined; + } +} + +/** The Installation's app id (canonical), if it has one. */ +export function installationAppId(resource: Resource): string | undefined { + return canonicalOrUndefined( + resource.get(server.properties.integrationAppAgent), + ); +} + +/** + * Reads `child` as a runtime of `installation`, or `undefined` when it is + * not one. A runtime counts only when its genesis was signed by the agent it + * names, as the node publishes it: an agent may only publish itself. + */ +export function asRuntime( + child: Resource, + installation: string, +): InstallationRuntime | undefined { + if (!child.hasClasses(server.classes.installationRuntime)) return undefined; + if (child.get(core.properties.parent) !== installation) return undefined; + const agent = canonicalOrUndefined( + child.get(server.properties.integrationRuntimeAgent), + ); + if (!agent || canonicalOrUndefined(child.getCreatedBy()) !== agent) + return undefined; + const name = child.get(core.properties.name); + + return { + subject: child.subject, + agent, + label: typeof name === 'string' && name ? name : undefined, + }; +} + +/** + * The runtimes published under `installation`. `children` skips the query + * when the caller already has them (e.g. from `useChildren`). + */ +export async function readInstallationRuntimes( + store: Store, + installation: string, + children?: readonly string[], +): Promise { + const subjects = + children ?? + (await new CollectionBuilder(store) + .setProperty(core.properties.parent) + .setValue(installation) + .setPageSize(100) + .build() + .getAllMembers()); + const found: InstallationRuntime[] = []; + + for (const subject of subjects) { + try { + const runtime = asRuntime(await store.getResource(subject), installation); + if (runtime) found.push(runtime); + } catch { + // A child that does not load is not a runtime we can register. + } + } + + return found; +} + +/** + * Registrations this page already made (or is making), keyed by proxy, owner, + * app, agent and label, so reopening an Installation does not ask the proxy + * again. A failed registration is forgotten, so the next trigger retries. + */ +const registered = new Map }>(); + +const registrationKey = ( + connections: ProxyConnections, + owner: string, + app: string, + runtime: InstallationRuntime, +) => + JSON.stringify([ + connections.origin, + owner, + app, + runtime.agent, + runtime.label ?? null, + ]); + +/** For tests. */ +export function forgetRegisteredRuntimes() { + registered.clear(); +} + +/** + * Registers every runtime in `runtimes` as a runtime of `app` for the user + * `owner`, skipping those the proxy already lists for the same app and label. + * Returns the agents it posted. Rejects with the first proxy error. + */ +export async function registerRuntimes( + connections: ProxyConnections, + owner: string, + app: string, + runtimes: readonly InstallationRuntime[], +): Promise { + const appId = canonicalAgent(app); + const ownerId = canonicalOrUndefined(owner); + // The proxy refuses a runtime that is the app or the owner. + const todo = runtimes.filter( + r => + r.agent !== appId && + r.agent !== ownerId && + !registered.has(registrationKey(connections, owner, appId, r)), + ); + if (todo.length === 0) return []; + + const listed = await connections.runtimes(); + const posted: string[] = []; + + await Promise.all( + todo.map(runtime => { + const key = registrationKey(connections, owner, appId, runtime); + const inFlight = registered.get(key); + if (inFlight) return inFlight.done; + + const known = listed.find( + row => canonicalOrUndefined(row.agent) === runtime.agent, + ); + const current = + !!known && + canonicalOrUndefined(known.app) === appId && + (known.label ?? undefined) === runtime.label; + const done = current + ? Promise.resolve() + : connections + .registerRuntime(appId, runtime.agent, runtime.label) + .then(() => { + posted.push(runtime.agent); + }); + registered.set(key, { agent: runtime.agent, done }); + done.catch(() => registered.delete(key)); + + return done; + }), + ); + + return posted; +} + +/** + * Removes each runtime at the proxy (`DELETE /runtimes/{agent}`). Tries them + * all, then rejects with the first error. + */ +export async function unregisterRuntimes( + connections: ProxyConnections, + runtimes: readonly InstallationRuntime[], +): Promise { + const agents = new Set(runtimes.map(r => r.agent)); + + for (const [key, entry] of registered) { + if (agents.has(entry.agent)) registered.delete(key); + } + + const results = await Promise.allSettled( + [...agents].map(agent => connections.unregisterRuntime(agent)), + ); + const failed = results.find( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + if (failed) throw failed.reason; +} + +/** + * Registers `installation`'s runtimes with its app id. A no-op for anything + * that is not an Installation with an app id (a `createApp` app, or an + * Installation from before app ids), and when signed out. + */ +export async function registerInstallationRuntimes( + store: Store, + connections: ProxyConnections, + installation: string, + children?: readonly string[], +): Promise { + const agent = store.getAgent(); + if (!agent) return []; + const resource = await store.getResource(installation); + const app = installationAppId(resource); + if (!app) return []; + const runtimes = await readInstallationRuntimes( + store, + installation, + children, + ); + if (runtimes.length === 0) return []; + const owner = agentSubject(await agent.getPublicKey()); + + return registerRuntimes(connections, owner, app, runtimes); +} diff --git a/browser/data-browser/src/helpers/proxyConnections.ts b/browser/data-browser/src/helpers/proxyConnections.ts index f644238fdd..abe42e9ab3 100644 --- a/browser/data-browser/src/helpers/proxyConnections.ts +++ b/browser/data-browser/src/helpers/proxyConnections.ts @@ -67,6 +67,18 @@ export interface ProxyConnection { delegations: { agent: string; label?: string }[]; } +/** One row of `runtimes` in `GET /connections`. */ +export interface ProxyRuntime { + agent: string; + app: string; + label?: string | null; + created_at?: string | number; + last_used_at?: string | number | null; +} + +/** The proxy refuses longer labels. */ +const MAX_LABEL = 200; + /** What the frame gets: the capability, and where to use it. */ export interface MintedCapability { capability: string; @@ -434,6 +446,42 @@ export class ProxyConnections { ); } + /** + * This user's runtimes at the proxy (`runtimes` of `GET /connections`): + * node agents that may act for an installation's app id. + */ + async runtimes(): Promise { + const result = (await this.call('GET', '/connections')) as { + runtimes?: unknown; + } | null; + const rows = Array.isArray(result?.runtimes) ? result.runtimes : []; + + return (rows as ProxyRuntime[]).filter( + row => typeof row?.agent === 'string' && typeof row.app === 'string', + ); + } + + /** + * Registers a node's agent as a runtime of installation `app` + * (`POST /runtimes {app, agent, label?}`), signed by the owner. The proxy + * upserts on (owner, agent), so registering again is harmless. + */ + async registerRuntime(app: string, agent: string, label?: string) { + await this.call('POST', '/runtimes', { + app: canonicalAgent(app), + agent: canonicalAgent(agent), + ...(label ? { label: [...label].slice(0, MAX_LABEL).join('') } : {}), + }); + } + + /** Removes a runtime; the proxy refuses that agent from then on. */ + async unregisterRuntime(agent: string) { + await this.call( + 'DELETE', + `/runtimes/${encodeURIComponent(canonicalAgent(agent))}`, + ); + } + /** Deletes a connection at the proxy, and with it every delegation. */ async revoke(connectionId: string) { await this.call( diff --git a/browser/data-browser/src/helpers/useInstallationRuntimes.ts b/browser/data-browser/src/helpers/useInstallationRuntimes.ts new file mode 100644 index 0000000000..207e8e59d6 --- /dev/null +++ b/browser/data-browser/src/helpers/useInstallationRuntimes.ts @@ -0,0 +1,104 @@ +// @wc-ignore-file +import { useEffect } from 'react'; +import toast from 'react-hot-toast'; +import { useChildren, type Store } from '@tomic/react'; +import { getIntegrationProxy } from './integrationProxy'; +import { ProxyConnections } from './proxyConnections'; +import { + readInstallationRuntimes, + registerInstallationRuntimes, + unregisterRuntimes, + type InstallationRuntime, +} from './installationRuntimes'; + +/** The configured proxy, managed with the signed-in user's key. */ +export function proxyConnectionsFor(store: Store): ProxyConnections { + return new ProxyConnections(localStorage, getIntegrationProxy(), () => + store.getAgent(), + ); +} + +/** Proxy failures are shown, never thrown into a render or a click handler. */ +function report(error: unknown) { + toast.error(error instanceof Error ? error.message : String(error)); +} + +/** + * Registers `installation`'s runtimes with the proxy, in the background. + * A no-op for anything that is not an Installation with an app id. + */ +export function registerRuntimesInBackground( + store: Store, + installation: string, + children?: readonly string[], +): void { + if (!store.getAgent()) return; + let connections: ProxyConnections; + + try { + connections = proxyConnectionsFor(store); + } catch (e) { + return report(e); + } + + registerInstallationRuntimes(store, connections, installation, children) + .then(posted => { + if (posted.length) + console.info( + `Registered ${posted.length} runtime(s) of ${installation} with ${connections.origin}`, + ); + }) + .catch(report); +} + +/** Removes runtimes at the proxy, in the background. */ +export function unregisterRuntimesInBackground( + store: Store, + runtimes: readonly InstallationRuntime[], +): void { + if (!store.getAgent() || runtimes.length === 0) return; + + try { + unregisterRuntimes(proxyConnectionsFor(store), runtimes).catch(report); + } catch (e) { + report(e); + } +} + +/** Reads the runtimes now, then removes them at the proxy in the background. */ +export async function unregisterInstallationRuntimes( + store: Store, + installation: string, +): Promise { + try { + const runtimes = await readInstallationRuntimes(store, installation); + unregisterRuntimesInBackground(store, runtimes); + + return runtimes; + } catch (e) { + report(e); + + return []; + } +} + +/** + * Keeps an open Installation's runtimes registered with the proxy: on open, + * and whenever a runtime child appears. Only for someone who can manage the + * Installation, and not once it is revoked. + */ +export function useInstallationRuntimes( + store: Store, + installation: string, + enabled: boolean, +): void { + const { subjects, loading } = useChildren(enabled ? installation : undefined); + const key = subjects.join('\n'); + + useEffect(() => { + if (!enabled || loading || subjects.length === 0) return; + registerRuntimesInBackground(store, installation, subjects); + // `key` stands for `subjects`, whose identity changes on every re-sort. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store, installation, enabled, loading, key]); +} diff --git a/browser/data-browser/src/views/Installation/InstallationPage.tsx b/browser/data-browser/src/views/Installation/InstallationPage.tsx index 614007405d..f0fb3da460 100644 --- a/browser/data-browser/src/views/Installation/InstallationPage.tsx +++ b/browser/data-browser/src/views/Installation/InstallationPage.tsx @@ -47,6 +47,12 @@ import { AssignRights } from './AssignRights'; import { useInstallationConfigSchema } from './useInstallationConfigSchema'; import { ResourceInline } from '@views/ResourceInline/ResourceInline'; import { useCustomViews } from '@components/CustomViewProvider'; +import { + unregisterInstallationRuntimes, + unregisterRuntimesInBackground, + useInstallationRuntimes, +} from '@helpers/useInstallationRuntimes'; +import { readInstallationRuntimes } from '@helpers/installationRuntimes'; import { InstallationReviewDialog, type PendingInstallation, @@ -124,12 +130,27 @@ export const InstallationPage: React.FC< ? Object.keys(grants) : []; + // Each node that runs this Installation publishes its agent on a child; + // the proxy lets it act for the app once the owner registers it (#1700). + useInstallationRuntimes( + store, + resource.subject, + canWrite && currentStatus !== 'revoked', + ); + const changeStatus = async (next: InstallationStatus) => { setChanging(true); try { await setStatus(next); await resource.save(); + + // Revoking retires the nodes' agents; the proxy should stop letting + // them act for the app too. Runtime children stay, so they can be read. + if (next === 'revoked') { + void unregisterInstallationRuntimes(store, resource.subject); + } + await refreshCustomViews(); toast.success(`Installation ${next}`); } catch (err) { @@ -394,7 +415,13 @@ export const InstallationPage: React.FC< bindShow={open => !open && setConfirm(undefined)} onConfirm={async () => { const parent = resource.props.parent; + // Read before the destroy takes the runtime children with it. + const runtimes = await readInstallationRuntimes( + store, + resource.subject, + ).catch(() => []); await resource.destroy(); + unregisterRuntimesInBackground(store, runtimes); await refreshCustomViews(); navigate(constructOpenURL(parent)); toast.success('Plugin uninstalled');