|
| 1 | +/** |
| 2 | + * @stable Cross-product connect flow. |
| 3 | + * |
| 4 | + * A product app (legal, tax, gtm, creative, agent-builder, sandbox, …) that |
| 5 | + * is already part of the Tangle trusted-app registry on id.tangle.tools |
| 6 | + * routes its users through this flow to obtain an `sk-tan-*` API key bound |
| 7 | + * to the calling user. The shape mirrors the platform's `/cross-site/*` |
| 8 | + * routes one-for-one so consumers can swap a bespoke fetch loop for these |
| 9 | + * helpers without changing the wire protocol. |
| 10 | + * |
| 11 | + * Three stages: |
| 12 | + * |
| 13 | + * 1. start({ appId, returnUrl, state }) → { authorizeUrl } |
| 14 | + * The product redirects the user to `authorizeUrl`. id.tangle.tools |
| 15 | + * checks the session cookie; if absent it punts to the login page |
| 16 | + * with a callback back to /cross-site/authorize. |
| 17 | + * |
| 18 | + * 2. callback({ code, app, state }) → { apiKey, user, workspaceId } |
| 19 | + * id.tangle.tools redirects back to the product's `returnUrl` with |
| 20 | + * `?code=…&app=…&state=…`. The product calls `finish()` with the |
| 21 | + * code; the helper POSTs /cross-site/exchange and returns the minted |
| 22 | + * key + identity. `state` is verified by the caller against its own |
| 23 | + * session (we never see it twice; CSRF is the caller's responsibility |
| 24 | + * per the platform contract — see `cross-site.ts` line 148). |
| 25 | + * |
| 26 | + * 3. revoke({ apiKey }) → void |
| 27 | + * Revoke the credential. Wraps `tangleIdentity().revokeSession`. |
| 28 | + * |
| 29 | + * Storage: this module is stateless. Persistence of the minted key (per |
| 30 | + * user, per workspace) is the caller's job — it goes in whatever |
| 31 | + * encrypted-credentials store the product already runs (sandbox uses Redis, |
| 32 | + * gtm uses Postgres, blueprints uses CF KV). The recipe is identical to |
| 33 | + * sandbox/api/src/lib/platform-client.ts — caller supplies a store, this |
| 34 | + * module hands back the raw key once and never persists it. |
| 35 | + * |
| 36 | + * Why not invent a new wire protocol: tcloud + sandbox already speak this |
| 37 | + * one against the live platform deployment. Diverging breaks the boundary |
| 38 | + * we maintain at the directive level ("DO NOT invent the wire protocol — |
| 39 | + * use what tcloud already does"). Every byte on the wire here matches a |
| 40 | + * test in `products/platform/api/tests/cross-site.test.ts`. |
| 41 | + */ |
| 42 | + |
| 43 | +import { |
| 44 | + createTangleIdentityClient, |
| 45 | + DEFAULT_TANGLE_PLATFORM_URL, |
| 46 | + TangleIdentityUnreachableError, |
| 47 | + type TangleIdentityOptions, |
| 48 | + type TangleUserSummary, |
| 49 | +} from '../connectors/adapters/tangle-id.js' |
| 50 | + |
| 51 | +export interface ConnectFlowOptions extends TangleIdentityOptions { |
| 52 | + /** Base URL of id.tangle.tools (defaults to {@link DEFAULT_TANGLE_PLATFORM_URL}). */ |
| 53 | + baseUrl?: string |
| 54 | +} |
| 55 | + |
| 56 | +export interface StartConnectInput { |
| 57 | + /** Trusted app id (registered on id.tangle.tools — `evals`, `sandbox`, |
| 58 | + * `agent-builder`, `tax-agent`, `legal-agent`, …). */ |
| 59 | + appId: string |
| 60 | + /** Caller-generated CSRF nonce. The caller stashes it in its own |
| 61 | + * session/cookie store; on the callback it MUST be compared against |
| 62 | + * the `state` returned in the redirect. */ |
| 63 | + state: string |
| 64 | + /** Optional exact-match override of the registered callback URI. When |
| 65 | + * omitted, the platform falls back to the app's first registered |
| 66 | + * redirectUri. When provided, MUST equal one of the registered entries |
| 67 | + * (origin + pathname) — otherwise the platform refuses the flow. */ |
| 68 | + redirectUri?: string |
| 69 | +} |
| 70 | + |
| 71 | +export interface StartConnectOutput { |
| 72 | + /** The URL to redirect the user's browser to. */ |
| 73 | + authorizeUrl: string |
| 74 | +} |
| 75 | + |
| 76 | +export interface FinishConnectInput { |
| 77 | + /** Auth code returned by id.tangle.tools on the callback redirect. */ |
| 78 | + code: string |
| 79 | + /** Same `appId` passed to `start()`. */ |
| 80 | + appId: string |
| 81 | +} |
| 82 | + |
| 83 | +export interface FinishConnectOutput { |
| 84 | + /** Newly-minted `sk-tan-*` API key bound to the calling user. Returned |
| 85 | + * ONCE — caller is responsible for stashing it in the product's |
| 86 | + * encrypted credentials store. */ |
| 87 | + apiKey: string |
| 88 | + /** Identity hydrated from the exchange response. */ |
| 89 | + user: TangleUserSummary |
| 90 | + /** Initial balance the platform returns alongside the key. */ |
| 91 | + balance: number |
| 92 | +} |
| 93 | + |
| 94 | +/** Initiate a cross-product connect flow. Returns the URL the product |
| 95 | + * app should redirect the user's browser to. */ |
| 96 | +export function startConnectFlow( |
| 97 | + opts: ConnectFlowOptions, |
| 98 | + input: StartConnectInput, |
| 99 | +): StartConnectOutput { |
| 100 | + if (!input.appId) { |
| 101 | + throw new TangleIdentityUnreachableError('connect/start: appId is required') |
| 102 | + } |
| 103 | + if (!input.state) { |
| 104 | + throw new TangleIdentityUnreachableError( |
| 105 | + 'connect/start: state is required for CSRF protection (matches the platform contract)', |
| 106 | + ) |
| 107 | + } |
| 108 | + const baseUrl = (opts.baseUrl ?? DEFAULT_TANGLE_PLATFORM_URL).replace(/\/+$/, '') |
| 109 | + const url = new URL(`${baseUrl}/cross-site/authorize`) |
| 110 | + url.searchParams.set('app', input.appId) |
| 111 | + url.searchParams.set('state', input.state) |
| 112 | + if (input.redirectUri) url.searchParams.set('redirect', input.redirectUri) |
| 113 | + return { authorizeUrl: url.toString() } |
| 114 | +} |
| 115 | + |
| 116 | +/** Finish a cross-product connect flow. Calls /cross-site/exchange and |
| 117 | + * returns the minted API key + hydrated user identity. */ |
| 118 | +export async function finishConnectFlow( |
| 119 | + opts: ConnectFlowOptions, |
| 120 | + input: FinishConnectInput, |
| 121 | +): Promise<FinishConnectOutput> { |
| 122 | + if (!input.code) { |
| 123 | + throw new TangleIdentityUnreachableError('connect/finish: code is required') |
| 124 | + } |
| 125 | + if (!input.appId) { |
| 126 | + throw new TangleIdentityUnreachableError('connect/finish: appId is required') |
| 127 | + } |
| 128 | + const baseUrl = (opts.baseUrl ?? DEFAULT_TANGLE_PLATFORM_URL).replace(/\/+$/, '') |
| 129 | + const fetchImpl = opts.fetchImpl ?? fetch |
| 130 | + const timeoutMs = opts.timeoutMs ?? 5_000 |
| 131 | + let res: Response |
| 132 | + try { |
| 133 | + res = await fetchImpl(`${baseUrl}/cross-site/exchange`, { |
| 134 | + method: 'POST', |
| 135 | + headers: { 'content-type': 'application/json' }, |
| 136 | + body: JSON.stringify({ code: input.code, app: input.appId }), |
| 137 | + signal: AbortSignal.timeout(timeoutMs), |
| 138 | + }) |
| 139 | + } catch (err) { |
| 140 | + throw new TangleIdentityUnreachableError('connect/finish: exchange request failed', { cause: err }) |
| 141 | + } |
| 142 | + if (res.status === 401) { |
| 143 | + throw new TangleIdentityUnreachableError( |
| 144 | + 'connect/finish: exchange code rejected — replay, expired, or wrong app', |
| 145 | + { status: 401 }, |
| 146 | + ) |
| 147 | + } |
| 148 | + if (!res.ok) { |
| 149 | + const detail = await res.text().catch(() => '') |
| 150 | + throw new TangleIdentityUnreachableError( |
| 151 | + `connect/finish: /cross-site/exchange returned ${res.status}: ${detail.slice(0, 200)}`, |
| 152 | + { status: res.status }, |
| 153 | + ) |
| 154 | + } |
| 155 | + const body = (await res.json().catch(() => null)) as |
| 156 | + | { |
| 157 | + apiKey?: string |
| 158 | + user?: { id?: string; email?: string; name?: string | null; image?: string | null } |
| 159 | + balance?: number |
| 160 | + } |
| 161 | + | null |
| 162 | + if (!body || typeof body.apiKey !== 'string' || !body.user || typeof body.user.id !== 'string') { |
| 163 | + throw new TangleIdentityUnreachableError('connect/finish: exchange response had an invalid shape') |
| 164 | + } |
| 165 | + return { |
| 166 | + apiKey: body.apiKey, |
| 167 | + user: { |
| 168 | + id: body.user.id, |
| 169 | + ...(typeof body.user.email === 'string' ? { email: body.user.email } : {}), |
| 170 | + ...(body.user.name !== undefined ? { name: body.user.name } : {}), |
| 171 | + ...(body.user.image !== undefined ? { image: body.user.image } : {}), |
| 172 | + }, |
| 173 | + balance: typeof body.balance === 'number' && Number.isFinite(body.balance) ? body.balance : 0, |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +/** Revoke a minted API key. Idempotent — re-revoking a stale key is a no-op. */ |
| 178 | +export async function revokeConnectFlow( |
| 179 | + opts: ConnectFlowOptions, |
| 180 | + input: { apiKey: string }, |
| 181 | +): Promise<void> { |
| 182 | + if (!input.apiKey) { |
| 183 | + throw new TangleIdentityUnreachableError('connect/revoke: apiKey is required') |
| 184 | + } |
| 185 | + const client = createTangleIdentityClient(opts) |
| 186 | + await client.revokeSession(input.apiKey) |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * Convenience: build a tiny session manager keyed by `state` for products |
| 191 | + * that don't already have a CSRF store. NOT recommended for production — |
| 192 | + * use your existing session cookie / signed-state mechanism. Exposed for |
| 193 | + * tests and for quick prototyping. In-memory; not shared across workers. |
| 194 | + */ |
| 195 | +export class InMemoryConnectStateStore { |
| 196 | + private readonly entries = new Map<string, { appId: string; expiresAt: number }>() |
| 197 | + |
| 198 | + put(state: string, value: { appId: string; ttlMs?: number }): void { |
| 199 | + this.entries.set(state, { |
| 200 | + appId: value.appId, |
| 201 | + expiresAt: Date.now() + (value.ttlMs ?? 10 * 60_000), |
| 202 | + }) |
| 203 | + } |
| 204 | + |
| 205 | + consume(state: string): { appId: string } | undefined { |
| 206 | + const entry = this.entries.get(state) |
| 207 | + this.entries.delete(state) |
| 208 | + if (!entry || entry.expiresAt <= Date.now()) return undefined |
| 209 | + return { appId: entry.appId } |
| 210 | + } |
| 211 | + |
| 212 | + /** Test-only — drop pending state between unit-test runs. */ |
| 213 | + clear(): void { |
| 214 | + this.entries.clear() |
| 215 | + } |
| 216 | +} |
0 commit comments