From 8cef05da37a9fede1a6b341695ba39e1b8a09356 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 28 Oct 2026 13:43:17 +0100 Subject: [PATCH 01/18] feat(proxy): forward cards and shares and name the caller to dfo-apis The public /api/v1 proxy now forwards /presentation/components and /presentation/shares, passing the write-token, ETag and CORS headers the share data route needs. Analysis, anchor, pin and derived reads already sit under the datasets surface. Every forward to dfo-apis carries x-dfo-caller and x-dfo-caller-principal (DFO_CALLER_PRINCIPAL), so dfo-apis can count allowances per client. The caller digest now hashes the folded address (IPv6 to its /64, v4-mapped to IPv4), the same preimage dfo-apis uses for direct callers. --- .env.example | 13 ++- deploy/README.md | 10 ++- docs/analytics.md | 2 +- .../components/[[...path]]/route.ts | 27 ++++++ .../presentation/shares/[[...path]]/route.ts | 29 ++++++ src/lib/proxy/caller.ts | 89 +++++++++++++++++-- src/lib/proxy/forward.ts | 45 ++++++++-- src/lib/proxy/headers.ts | 54 +++++++++-- src/lib/proxy/openapi.ts | 16 ++-- src/lib/proxy/surfaces.ts | 19 ++-- src/lib/proxy/test/caller.test.ts | 58 +++++++++++- src/lib/proxy/test/headers.test.ts | 60 ++++++++++++- src/lib/proxy/test/openapi.test.ts | 13 ++- src/lib/proxy/test/surfaces.test.ts | 35 ++++---- 14 files changed, 402 insertions(+), 68 deletions(-) create mode 100644 src/app/api/v1/presentation/components/[[...path]]/route.ts create mode 100644 src/app/api/v1/presentation/shares/[[...path]]/route.ts diff --git a/.env.example b/.env.example index ff1c1e0..75d7d62 100644 --- a/.env.example +++ b/.env.example @@ -121,10 +121,17 @@ SENTRY_AUTH_TOKEN= # attached by the proxy so outside MCP clients don't need it. Unset: the # caller's own Authorization header is passed through. MCP_API_TOKEN= -# /mcp names each caller to dfo-ai as x-dfo-caller: ip:, an -# HMAC-SHA-256 of the client IP keyed by this secret (e.g. openssl rand -hex -# 32). If unset, no caller is sent and the site logs one warning. +# /mcp names each caller to dfo-ai, and the dfo-apis proxy to dfo-apis, as +# x-dfo-caller: ip:, an HMAC-SHA-256 of the client address (an IPv6 +# address as its /64) keyed by this secret (e.g. openssl rand -hex 32). It +# must equal dfo-apis's DFO_CALLER_HMAC_KEY. If unset, no caller is sent and +# the site logs one warning. CALLER_HMAC_KEY= +# The secret dfo-apis checks before it trusts x-dfo-caller (its +# DFO_CALLER_PRINCIPAL), sent as x-dfo-caller-principal on every dfo-apis +# call. If unset, the site sends dfo-apis neither header and logs one warning, +# and dfo-apis counts the whole site as one caller. +DFO_CALLER_PRINCIPAL= # Which X-Forwarded-For entry is the client, counted from the right. 0 (the # default) is the rightmost, the one Cloud Run's front end appends; add one # for each load balancer in front of the service. diff --git a/deploy/README.md b/deploy/README.md index 07040e0..f17d2d5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -197,7 +197,8 @@ The app defines the definitive contract once it exists. Known so far: | `STUDIO_TOKEN` | Bearer token the studio proxy sends to the harness; secret reference set by infra | | `SEARCH_API_BASE` | The `dfo-apis` Search, Datasets and graph APIs; required, a production server without it refuses to start; plain env var set by infra | | `MCP_API_TOKEN` | Bearer token the `/mcp` proxy sends to the harness; infra mounts it from Secret Manager (`dfo-dev-mcp-api-token`, the harness's own secret) | -| `CALLER_HMAC_KEY` | HMAC key for the client-IP digest `/mcp` sends as `x-dfo-caller`; unset, none is sent; secret set by infra | +| `CALLER_HMAC_KEY` | HMAC key for the client digest sent as `x-dfo-caller` (`/mcp`, and dfo-apis beside the principal); unset, none is sent; secret set by infra | +| `DFO_CALLER_PRINCIPAL` | Secret shared with dfo-apis (its `DFO_CALLER_PRINCIPAL`), sent as `x-dfo-caller-principal` on every dfo-apis call so dfo-apis trusts `x-dfo-caller`; unset, neither is sent | | `RATE_LIMIT_WINDOW_S` | Rate-limit window in seconds; default 60 | | `RATE_LIMIT_API` | Calls per window per address on `/api`; default 120 | | `RATE_LIMIT_PIN` | Calls per window per address on `/api/download-pin`; default 10 | @@ -220,7 +221,12 @@ The proxy forwards `content-type`, `accept` and every `mcp-*` request header (`mcp-method` and `mcp-name` among them, which the harness checks against the request body), and returns `content-type`, `content-disposition`, `content-length`, `www-authenticate` and every `mcp-*` response header. -`x-dfo-caller` is set by the proxy, whatever the client sent. `authorization` +`x-dfo-caller` is set by the proxy, whatever the client sent. A call to +dfo-apis carries `x-dfo-caller` beside `x-dfo-caller-principal` when +`DFO_CALLER_PRINCIPAL` is set, and neither when it is unset. The digest's +input is the client address folded as the rate limiter folds it (an IPv6 +address as its /64), so dfo-apis, which computes the same digest for a direct +caller, keys both on one allowance. `authorization` is set by the proxy only when `MCP_API_TOKEN` is set; otherwise the client's own `authorization` header goes through to the harness. A malformed `TRUSTED_PROXY_HOPS` stops a production server at startup. If one reaches a diff --git a/docs/analytics.md b/docs/analytics.md index c3df018..404f805 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -100,7 +100,7 @@ The site writes one JSON line to stdout for each logged request, which Cloud Log {"severity":"INFO","message":"request","caller":"ip:3f9c0a…","method":"GET","path":"/embed/abc123","host":"developmentfinance.org","kind":"page","status":200,"finished":true,"bot":false,"prerender":false} ``` -`caller` is the same `ip:` the `/mcp` proxy sends upstream as `x-dfo-caller` (`src/lib/proxy/caller.ts`): an HMAC of the client address under `CALLER_HMAC_KEY`, with the address taken from `X-Forwarded-For` at the `TRUSTED_PROXY_HOPS` position. It is null when the key is unset or the request carries no usable address. `path` is the request target as it arrived, captured when the request starts so a later rewrite does not change it. It has no query string or fragment, and dot segments are resolved, so `/a/../mcp` is logged as `/mcp`. A target such as `//mcp` is read as a path and logged as `//mcp`. An http or https absolute-form target such as `http://host/path` is logged as its pathname. Any other target, such as one with another scheme (`ftp://`, `file:`, `mailto:`), an authority-form `host:port`, or an http or https URL that does not parse, is logged as the fixed path `/:unparsable`, since it can hold a user name, a password or an IP address. The asterisk-form target of `OPTIONS *` is logged as `*`, with kind `page`. `kind` is `api` for `/api`, `/mcp`, `/openapi.json` and `/codes` and every path below each of them (`/apiary` is not one). That covers the site's own endpoints under `/api`, such as auth, events and share, as well as the platform proxy's routes. `kind` is `prefetch` for a Next.js prefetch (`Next-Router-Prefetch`) or a browser prefetch (`Sec-Purpose: prefetch`), `rsc` for a client-side navigation (`RSC: 1`) and `page` for everything else. A `Sec-Purpose` header does not change the kind of an RSC fetch or a Next.js prefetch. Embeds under `/embed/` are logged as pages. `/_next/` files, images and fonts are not logged, and are dropped before the line's headers are read or its caller computed. `status` is the response's HTTP status code. `finished` is true when the response ended normally and false when it closed first, so a status of 200 with `finished` false is a visitor who left a stream. A response that closes before the app sent a status has none of its own, so the line carries 499 (client closed request) with `finished` false. The 499 means the app sent no status before the connection closed. It also covers a response Node answered itself, such as a 408 on a request timeout or a 400 on a client error, which never passes through the app. A response that called `writeHead` but wrote nothing to the socket before the close is logged with that status and `finished` false. Infra's parse keeps the line and counts it as a 4xx, outside the readers' 2xx count. Infra's `website_requests_daily` counts a line whose response did not finish like a finished one. `prerender` is true for a document request (no `RSC` and no `Next-Router-Prefetch` header) with `Sec-Purpose: prefetch;prerender`: the browser renders the page ahead of time and shows it on activation with no second request, so that one line is the page view. It has kind `page`, which keeps infra's `website_requests_daily` counting it as a parsed page request. The line is a page view whether or not the visitor opens the page, because the server cannot see activation. `bot` is true when the `User-Agent` header is missing or empty, or when the `isbot` package flags it: crawlers such as Googlebot, bingbot and ClaudeBot, link previews such as Slack's and Discord's, headless browsers and tools such as curl. A client that sends a browser's user agent counts as a reader. The line holds no raw IP address, query string, cookie, `Authorization` header or user agent. +`caller` is the same `ip:` the `/mcp` proxy sends upstream as `x-dfo-caller` (`src/lib/proxy/caller.ts`): an HMAC of the client address (an IPv6 address as its /64) under `CALLER_HMAC_KEY`, with the address taken from `X-Forwarded-For` at the `TRUSTED_PROXY_HOPS` position. It is null when the key is unset or the request carries no usable address. `path` is the request target as it arrived, captured when the request starts so a later rewrite does not change it. It has no query string or fragment, and dot segments are resolved, so `/a/../mcp` is logged as `/mcp`. A target such as `//mcp` is read as a path and logged as `//mcp`. An http or https absolute-form target such as `http://host/path` is logged as its pathname. Any other target, such as one with another scheme (`ftp://`, `file:`, `mailto:`), an authority-form `host:port`, or an http or https URL that does not parse, is logged as the fixed path `/:unparsable`, since it can hold a user name, a password or an IP address. The asterisk-form target of `OPTIONS *` is logged as `*`, with kind `page`. `kind` is `api` for `/api`, `/mcp`, `/openapi.json` and `/codes` and every path below each of them (`/apiary` is not one). That covers the site's own endpoints under `/api`, such as auth, events and share, as well as the platform proxy's routes. `kind` is `prefetch` for a Next.js prefetch (`Next-Router-Prefetch`) or a browser prefetch (`Sec-Purpose: prefetch`), `rsc` for a client-side navigation (`RSC: 1`) and `page` for everything else. A `Sec-Purpose` header does not change the kind of an RSC fetch or a Next.js prefetch. Embeds under `/embed/` are logged as pages. `/_next/` files, images and fonts are not logged, and are dropped before the line's headers are read or its caller computed. `status` is the response's HTTP status code. `finished` is true when the response ended normally and false when it closed first, so a status of 200 with `finished` false is a visitor who left a stream. A response that closes before the app sent a status has none of its own, so the line carries 499 (client closed request) with `finished` false. The 499 means the app sent no status before the connection closed. It also covers a response Node answered itself, such as a 408 on a request timeout or a 400 on a client error, which never passes through the app. A response that called `writeHead` but wrote nothing to the socket before the close is logged with that status and `finished` false. Infra's parse keeps the line and counts it as a 4xx, outside the readers' 2xx count. Infra's `website_requests_daily` counts a line whose response did not finish like a finished one. `prerender` is true for a document request (no `RSC` and no `Next-Router-Prefetch` header) with `Sec-Purpose: prefetch;prerender`: the browser renders the page ahead of time and shows it on activation with no second request, so that one line is the page view. It has kind `page`, which keeps infra's `website_requests_daily` counting it as a parsed page request. The line is a page view whether or not the visitor opens the page, because the server cannot see activation. `bot` is true when the `User-Agent` header is missing or empty, or when the `isbot` package flags it: crawlers such as Googlebot, bingbot and ClaudeBot, link previews such as Slack's and Discord's, headless browsers and tools such as curl. A client that sends a browser's user agent counts as a reader. The line holds no raw IP address, query string, cookie, `Authorization` header or user agent. The line reads the request's raw headers, which keep the `RSC` and `Next-Router-Prefetch` headers that Next.js removes from `request.headers`. Logging leaves request bodies unread, so API and MCP payloads stream to their handlers in full. A Next.js proxy would buffer those bodies and truncate them at its default 10 MB limit. diff --git a/src/app/api/v1/presentation/components/[[...path]]/route.ts b/src/app/api/v1/presentation/components/[[...path]]/route.ts new file mode 100644 index 0000000..9330426 --- /dev/null +++ b/src/app/api/v1/presentation/components/[[...path]]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest } from 'next/server'; + +import { forward } from '@/lib/proxy/forward'; +import { PRESENTATION_HEADERS } from '@/lib/proxy/headers'; +import { searchApiBase } from '@/lib/searchApi'; + +// The Presentation API's cards (create, revise, read, and a revision's data), +// mirrored 1:1 under the site's domain (see src/lib/proxy/forward). The write +// token a card was made with goes upstream (PRESENTATION_HEADERS). +const proxy = (req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) => + params.then(({ path = [] }) => { + const rest = path.map((segment) => `/${encodeURIComponent(segment)}`).join(''); + + return forward(req, { + surface: 'api', + target: `${searchApiBase()}/api/presentation/components${rest}${req.nextUrl.search}`, + pass: PRESENTATION_HEADERS, + unreachable: 'The Presentation API is unreachable.', + }); + }); + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const OPTIONS = proxy; diff --git a/src/app/api/v1/presentation/shares/[[...path]]/route.ts b/src/app/api/v1/presentation/shares/[[...path]]/route.ts new file mode 100644 index 0000000..9216598 --- /dev/null +++ b/src/app/api/v1/presentation/shares/[[...path]]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from 'next/server'; + +import { forward } from '@/lib/proxy/forward'; +import { PRESENTATION_HEADERS } from '@/lib/proxy/headers'; +import { searchApiBase } from '@/lib/searchApi'; + +// The Presentation API's shares (create, replace, delete, read, a slot's +// data at /{id}/slots/{n}/data, and /{id}/history), mirrored 1:1 under the +// site's domain (see src/lib/proxy/forward). The write token, the slot data's +// ETag and If-None-Match, and CORS for pages on other origins pass through +// (PRESENTATION_HEADERS). +const proxy = (req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) => + params.then(({ path = [] }) => { + const rest = path.map((segment) => `/${encodeURIComponent(segment)}`).join(''); + + return forward(req, { + surface: 'api', + target: `${searchApiBase()}/api/presentation/shares${rest}${req.nextUrl.search}`, + pass: PRESENTATION_HEADERS, + unreachable: 'The Presentation API is unreachable.', + }); + }); + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const OPTIONS = proxy; diff --git a/src/lib/proxy/caller.ts b/src/lib/proxy/caller.ts index 5bb543d..878fdd8 100644 --- a/src/lib/proxy/caller.ts +++ b/src/lib/proxy/caller.ts @@ -2,10 +2,12 @@ import { createHmac } from 'node:crypto'; import { isIP } from 'node:net'; /** - * Names the caller for the upstream's logs as `x-dfo-caller: ip:`, - * where the digest is HMAC-SHA-256 of the client IP keyed by - * CALLER_HMAC_KEY, hex, cut to 32 characters. The raw address never leaves - * the site. + * Names the caller upstream as `x-dfo-caller: ip:`, where the digest + * is HMAC-SHA-256 of the client address's bucket (limiterBucket: an IPv6 + * address as its /64, an IPv4-mapped one as its IPv4 address) keyed by + * CALLER_HMAC_KEY, hex, cut to 32 characters. dfo-apis computes the same + * digest for a caller that reaches it directly, so both paths share one key + * per client. The raw address never leaves the site. * * The client IP is the X-Forwarded-For entry TRUSTED_PROXY_HOPS places from * the right. Each proxy appends the address it saw, so entries to the left @@ -15,8 +17,13 @@ import { isIP } from 'node:net'; * one hop. * * Without CALLER_HMAC_KEY no caller is sent, and one warning is logged. + * + * dfo-apis believes x-dfo-caller only beside `x-dfo-caller-principal` + * carrying the secret it shares with the site (DFO_CALLER_PRINCIPAL), and + * resolves any other request from its own view of the client address. */ export const CALLER_HEADER = 'x-dfo-caller'; +export const CALLER_PRINCIPAL_HEADER = 'x-dfo-caller-principal'; const DIGEST_LENGTH = 32; @@ -168,7 +175,10 @@ export const resolveCaller = ( return undefined; } - const digest = createHmac('sha256', key).update(ip).digest('hex').slice(0, DIGEST_LENGTH); + const digest = createHmac('sha256', key) + .update(limiterBucket(ip)) + .digest('hex') + .slice(0, DIGEST_LENGTH); return `ip:${digest}`; }; @@ -182,6 +192,75 @@ export const setCaller = (headers: Headers, caller: string | undefined): void => } }; +/** What forward() sends dfo-apis to name the caller; see apisCallerFor. */ +export type ApisCaller = { principal?: string; caller?: string }; + +/** + * The resolver for what names the caller to dfo-apis, from `env`: the + * DFO_CALLER_PRINCIPAL secret and the caller digest (resolveCaller). + * + * Without DFO_CALLER_PRINCIPAL neither is sent, since dfo-apis would ignore + * the caller, and one warning goes through `log`. With the secret but no + * usable CALLER_HMAC_KEY or TRUSTED_PROXY_HOPS, the secret goes alone and one + * error is logged: dfo-apis refuses a call that carries the secret without a + * caller, which is louder than putting every client of the site on one + * allowance. A request whose client address cannot be read goes the same way. + */ +export const apisCallerFor = ( + env: Record, + log: (message: string) => void = console.warn, +): ((forwardedFor: string | null) => ApisCaller) => { + const principal = env.DFO_CALLER_PRINCIPAL?.trim(); + const config = callerConfig(env); + let logged = false; + const logOnce = (message: string) => { + if (!logged) { + logged = true; + log(message); + } + }; + + if (!principal) { + return () => { + logOnce( + `DFO_CALLER_PRINCIPAL is unset; dfo-apis calls go without ${CALLER_HEADER}, so dfo-apis counts every client of the site as one caller`, + ); + + return {}; + }; + } + + if ('problem' in config) { + return () => { + logOnce( + `${config.problem}; dfo-apis calls carry ${CALLER_PRINCIPAL_HEADER} without ${CALLER_HEADER}, which dfo-apis refuses`, + ); + + return { principal }; + }; + } + + return (forwardedFor) => ({ principal, caller: resolveCaller(forwardedFor, config) }); +}; + +const apisCaller = apisCallerFor(process.env); + +/** + * Name the caller of `incoming` on upstream headers bound for dfo-apis, from + * this process's env (see apisCallerFor), dropping any value already there. + */ +export const setApisCaller = (headers: Headers, incoming: Headers): void => { + const { principal, caller } = apisCaller(incoming.get('x-forwarded-for')); + + headers.delete(CALLER_PRINCIPAL_HEADER); + + if (principal) { + headers.set(CALLER_PRINCIPAL_HEADER, principal); + } + + setCaller(headers, caller); +}; + const config = callerConfig(process.env); let warned = false; diff --git a/src/lib/proxy/forward.ts b/src/lib/proxy/forward.ts index 78fd7d1..68450c1 100644 --- a/src/lib/proxy/forward.ts +++ b/src/lib/proxy/forward.ts @@ -4,9 +4,15 @@ import type { NextRequest } from 'next/server'; import { apisFetch } from '@/lib/apisAuth'; -import { limiterKeyFor, setCaller, trustedHops } from './caller'; +import { limiterKeyFor, setApisCaller, setCaller, trustedHops } from './caller'; import { relayBody } from './compress'; -import { clientResponseHeaders, dropEncodedLength, pick, upstreamRequestHeaders } from './headers'; +import { + clientResponseHeaders, + dropEncodedLength, + pick, + upstreamRequestHeaders, + type HeaderPass, +} from './headers'; import { rateLimitedProblem, unreachableProblem } from './problem'; import { rateHeaders } from './rateHeaders'; import { hit, type Surface } from './rateLimit'; @@ -28,7 +34,8 @@ import { hit, type Surface } from './rateLimit'; * as the upstream sent them. * * Upstreams: - * /api/v1/search|datasets|graph/* → SEARCH_API_BASE (dfo-apis, src/lib/searchApi) + * /api/v1/search|datasets|graph/*, /api/v1/presentation/components|shares/* + * → SEARCH_API_BASE (dfo-apis, src/lib/searchApi) * /openapi.json, /codes/{code} → SEARCH_API_BASE, the same paths; the * document is cut to this host's paths * (src/lib/proxy/openapi) @@ -36,8 +43,10 @@ import { hit, type Surface } from './rateLimit'; * → STUDIO_API_BASE (dfo-ai), STUDIO_TOKEN * * A call to SEARCH_API_BASE carries the site's ID token on Cloud Run - * (src/lib/apisAuth matches on the target's origin). The other upstreams get - * none. + * (src/lib/apisAuth matches on the target's origin), and names the caller in + * x-dfo-caller beside the x-dfo-caller-principal secret (setApisCaller in + * src/lib/proxy/caller), matched on the same origin. The other upstreams get + * neither. * * The unversioned /api/search|datasets|graph|studio/* paths rewrite onto the * same handlers (next.config.ts), so both addresses answer alike. @@ -65,9 +74,12 @@ type ForwardOptions = { // A bearer the proxy attaches on the caller's behalf; when unset, the // caller's own Authorization header goes through instead. bearer?: string; - // The x-dfo-caller value to send (src/lib/proxy/caller). Any value the - // client sent is dropped either way. + // The x-dfo-caller value to send to an upstream other than dfo-apis, + // which gets its own (src/lib/proxy/caller). Any value the client sent is + // dropped either way. caller?: string; + // Headers carried beyond the defaults (src/lib/proxy/headers). + pass?: HeaderPass; // What to answer when the upstream is unreachable. unreachable: string; // False sends every body back unencoded, whatever its type. @@ -81,6 +93,16 @@ type ForwardOptions = { rewrite?: (upstream: Response) => Promise; }; +// Whether `target` is on dfo-apis, read from SEARCH_API_BASE at call time as +// src/lib/apisAuth reads it. +const isApisTarget = (target: string): boolean => { + const base = process.env.SEARCH_API_BASE?.trim(); + + return ( + base !== undefined && URL.canParse(base) && new URL(target).origin === new URL(base).origin + ); +}; + export const forward = async (req: NextRequest, options: ForwardOptions): Promise => { const verdict = options.surface ? await hit(options.surface, clientIp(req)) : undefined; const limitHeaders = verdict ? rateHeaders(verdict) : {}; @@ -95,7 +117,7 @@ export const forward = async (req: NextRequest, options: ForwardOptions): Promis return refusal; } - const headers = upstreamRequestHeaders(req.headers); + const headers = upstreamRequestHeaders(req.headers, new Headers(), options.pass?.request); const callerAuth = req.headers.get('authorization'); @@ -105,7 +127,11 @@ export const forward = async (req: NextRequest, options: ForwardOptions): Promis headers.set('authorization', callerAuth); } - setCaller(headers, options.caller); + if (isApisTarget(options.target)) { + setApisCaller(headers, req.headers); + } else { + setCaller(headers, options.caller); + } const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; let upstream: Response; @@ -156,6 +182,7 @@ export const forward = async (req: NextRequest, options: ForwardOptions): Promis const out = clientResponseHeaders( upstream.headers, new Headers({ 'Cache-Control': 'no-cache', ...limitHeaders }), + options.pass?.response, ); return relayBody({ diff --git a/src/lib/proxy/headers.ts b/src/lib/proxy/headers.ts index 8a312ec..274adfa 100644 --- a/src/lib/proxy/headers.ts +++ b/src/lib/proxy/headers.ts @@ -22,7 +22,9 @@ * when the upstream encoded the body, since fetch has decoded it by then. * Matching `mcp-*` by prefix means a protocol header added later needs no * change here. Of the `x-dfo-*` names only x-dfo-client goes upstream from the - * client; authorization and x-dfo-caller are set by forward.ts. + * client by default; authorization, x-dfo-caller and x-dfo-caller-principal + * are set by forward.ts. A route can carry more in both directions with a + * HeaderPass, as the Presentation API's routes do (PRESENTATION_HEADERS). */ const REQUEST_PREFIXES = ['mcp-']; const RESPONSE_PREFIXES = ['mcp-']; @@ -54,6 +56,36 @@ const RESPONSE_HEADERS = [ 'allow', ]; +/** Headers a route carries beyond the defaults, each way. */ +export type HeaderPass = { request: readonly string[]; response: readonly string[] }; + +/** + * What the Presentation API's card and share routes need beyond the defaults: + * the write token that lets a client revise or delete what it made, the + * conditional read of a share's slot data (ETag one way, If-None-Match the + * other), and CORS, so a page on another origin can read that data and its + * preflight is answered by dfo-apis. + */ +export const PRESENTATION_HEADERS: HeaderPass = { + request: [ + 'x-dfo-write-token', + 'if-none-match', + 'origin', + 'access-control-request-method', + 'access-control-request-headers', + ], + response: [ + 'etag', + 'vary', + 'access-control-allow-origin', + 'access-control-allow-methods', + 'access-control-allow-headers', + 'access-control-allow-credentials', + 'access-control-expose-headers', + 'access-control-max-age', + ], +}; + // Copy the named headers present on `source` onto `into`. export const pick = (source: Headers, names: readonly string[], into: Headers): void => { for (const name of names) { @@ -94,9 +126,13 @@ export const clientLabel = (source: Headers): string => { return [...printable].slice(0, MAX_CLIENT_LABEL_LENGTH).join(''); }; -/** The client's request headers that go upstream. */ -export const upstreamRequestHeaders = (source: Headers, into: Headers = new Headers()): Headers => { - pickWithPrefixes(source, REQUEST_HEADERS, REQUEST_PREFIXES, into); +/** The client's request headers that go upstream, plus the `extra` names. */ +export const upstreamRequestHeaders = ( + source: Headers, + into: Headers = new Headers(), + extra: readonly string[] = [], +): Headers => { + pickWithPrefixes(source, [...REQUEST_HEADERS, ...extra], REQUEST_PREFIXES, into); const label = clientLabel(source); @@ -115,9 +151,13 @@ export const dropEncodedLength = (source: Headers, into: Headers): void => { } }; -/** The upstream's response headers that go back to the client. */ -export const clientResponseHeaders = (source: Headers, into: Headers = new Headers()): Headers => { - pickWithPrefixes(source, RESPONSE_HEADERS, RESPONSE_PREFIXES, into); +/** The upstream's response headers that go back to the client, plus the `extra` names. */ +export const clientResponseHeaders = ( + source: Headers, + into: Headers = new Headers(), + extra: readonly string[] = [], +): Headers => { + pickWithPrefixes(source, [...RESPONSE_HEADERS, ...extra], RESPONSE_PREFIXES, into); dropEncodedLength(source, into); return into; diff --git a/src/lib/proxy/openapi.ts b/src/lib/proxy/openapi.ts index f20a3c3..c3da4d9 100644 --- a/src/lib/proxy/openapi.ts +++ b/src/lib/proxy/openapi.ts @@ -7,10 +7,9 @@ import { SEARCH_API_SURFACES } from './surfaces.ts'; * * A path under one of SEARCH_API_SURFACES moves from /api//* to the * site's public /api/v1//*, and /codes/* (src/app/codes) keeps its - * address. Every other path is dropped: the skills API, which the site does - * not proxy, and the presentation API, whose component reads the site - * proxies for the Studio (src/app/api/presentation/components/[id]) but does - * not publish. The component schemas only those paths used go with them. + * address. Every other path is dropped: the skills API and the rest of the + * presentation API, which the site does not proxy. The component schemas + * only those paths used go with them. * The document carries no `servers`, so a client generated from it calls the * host that served it, whichever environment that is. */ @@ -23,7 +22,6 @@ export const OPENAPI_CACHE_CONTROL = 'private, max-age=300'; // Paths the site serves at the same address as dfo-apis. const ROOT_PATHS = ['/codes/']; -const SURFACE_PATH = /^\/api\/([^/]+)(\/.*)?$/; const SCHEMA_REF = '#/components/schemas/'; const JSON_TYPE = /^application\/json\s*(?:;|$)/i; @@ -48,11 +46,11 @@ export const publicPath = (path: string): string | undefined => { return path; } - const [, surface, rest = ''] = SURFACE_PATH.exec(path) ?? []; + const surface = SEARCH_API_SURFACES.find( + (name) => path === `/api/${name}` || path.startsWith(`/api/${name}/`), + ); - return (SEARCH_API_SURFACES as readonly string[]).includes(surface) - ? `/api/v1/${surface}${rest}` - : undefined; + return surface ? `/api/v1${path.slice('/api'.length)}` : undefined; }; // A $ref's schema name: a URI fragment holding a JSON pointer (RFC 6901). A diff --git a/src/lib/proxy/surfaces.ts b/src/lib/proxy/surfaces.ts index f2cca8f..8f1afff 100644 --- a/src/lib/proxy/surfaces.ts +++ b/src/lib/proxy/surfaces.ts @@ -1,8 +1,15 @@ /** - * The dfo-apis surfaces the site proxies to SEARCH_API_BASE under its public - * address, /api/v1//* (src/app/api/v1/, forwarding to - * /api//*). The site's /openapi.json keeps these surfaces' paths and - * no others (./openapi). src/lib/proxy/test/surfaces.test.ts holds the list - * to the route directories. + * The dfo-apis path prefixes the site proxies to SEARCH_API_BASE under its + * public address: /api/v1//* (src/app/api/v1/) forwards to + * /api//*. The Presentation API is proxied only for its cards and + * shares. The site's /openapi.json keeps these surfaces' paths and no others + * (./openapi). src/lib/proxy/test/surfaces.test.ts holds the list to the + * route directories. */ -export const SEARCH_API_SURFACES = ['search', 'datasets', 'graph'] as const; +export const SEARCH_API_SURFACES = [ + 'search', + 'datasets', + 'graph', + 'presentation/components', + 'presentation/shares', +] as const; diff --git a/src/lib/proxy/test/caller.test.ts b/src/lib/proxy/test/caller.test.ts index b6c3591..87654db 100644 --- a/src/lib/proxy/test/caller.test.ts +++ b/src/lib/proxy/test/caller.test.ts @@ -1,13 +1,14 @@ /** * `caller.ts`: which X-Forwarded-For entry names the client, the digest it - * becomes, the settings it reads, and that a client-sent x-dfo-caller never - * survives. + * becomes, the settings it reads, what dfo-apis is sent, and that a + * client-sent x-dfo-caller never survives. */ import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; import test from 'node:test'; import { + apisCallerFor, assertTrustedHops, CALLER_HEADER, callerConfig, @@ -55,6 +56,19 @@ test('resolveCaller: the digest is 32 hex characters and keyed', () => { assert.notEqual(caller, resolveCaller('2001:db8::1', { key: 'other', hops: 0 })); }); +// The digest's input is the cross-service contract: dfo-apis hashes the same +// string for a caller that reaches it directly. +for (const [address, preimage] of [ + ['2001:db8:1:2:aaaa::1', '2001:db8:1:2::/64'], + ['2001:db8::1', '2001:db8:0:0::/64'], + ['::ffff:203.0.113.7', '203.0.113.7'], + ['203.0.113.7', '203.0.113.7'], +]) { + test(`resolveCaller: ${address} is digested as ${preimage}`, () => { + assert.equal(resolveCaller(address, { key: KEY, hops: 0 }), digest(preimage)); + }); +} + test('resolveCaller: a chain shorter than the hops gives no caller', () => { assert.equal(resolveCaller('3.3.3.3', { key: KEY, hops: 1 }), undefined); }); @@ -166,3 +180,43 @@ for (const [address, bucket] of [ assert.equal(limiterBucket(address), bucket); }); } + +const API_ENV = { DFO_CALLER_PRINCIPAL: 'secret', CALLER_HMAC_KEY: KEY, TRUSTED_PROXY_HOPS: '1' }; + +test('apisCallerFor: sends the principal and the caller digest', () => { + const caller = apisCallerFor(API_ENV, () => assert.fail('logged')); + + assert.deepEqual(caller('1.1.1.1, 2.2.2.2'), { + principal: 'secret', + caller: digest('1.1.1.1'), + }); + assert.deepEqual(caller('2.2.2.2'), { principal: 'secret', caller: undefined }); +}); + +test('apisCallerFor: without DFO_CALLER_PRINCIPAL sends neither and logs once', () => { + for (const principal of [undefined, '', ' ']) { + const logged: string[] = []; + const caller = apisCallerFor({ ...API_ENV, DFO_CALLER_PRINCIPAL: principal }, (message) => + logged.push(message), + ); + + assert.deepEqual(caller('1.1.1.1, 2.2.2.2'), {}); + assert.deepEqual(caller('1.1.1.1, 2.2.2.2'), {}); + assert.equal(logged.length, 1); + assert.match(logged[0] ?? '', /DFO_CALLER_PRINCIPAL is unset/); + } +}); + +test('apisCallerFor: a principal without a usable key or hops goes alone and logs once', () => { + for (const env of [ + { ...API_ENV, CALLER_HMAC_KEY: undefined }, + { ...API_ENV, TRUSTED_PROXY_HOPS: 'two' }, + ]) { + const logged: string[] = []; + const caller = apisCallerFor(env, (message) => logged.push(message)); + + assert.deepEqual(caller('1.1.1.1, 2.2.2.2'), { principal: 'secret' }); + assert.deepEqual(caller('1.1.1.1, 2.2.2.2'), { principal: 'secret' }); + assert.equal(logged.length, 1); + } +}); diff --git a/src/lib/proxy/test/headers.test.ts b/src/lib/proxy/test/headers.test.ts index 8a332ef..785ce9e 100644 --- a/src/lib/proxy/test/headers.test.ts +++ b/src/lib/proxy/test/headers.test.ts @@ -2,13 +2,18 @@ * `headers.ts`: which request and response headers cross the proxy. Every * `mcp-*` header goes either way whatever its case, the cleaned x-dfo-client * label goes up, and the public x-dfo-trace-id and x-dfo-commit come back; - * credentials, the caller header and the other `x-dfo-*` headers are never - * copied. + * credentials, the caller headers and the other `x-dfo-*` headers are never + * copied, bar the write token a route asks for in PRESENTATION_HEADERS. */ import assert from 'node:assert/strict'; import test from 'node:test'; -import { clientResponseHeaders, pick, upstreamRequestHeaders } from '../headers.ts'; +import { + clientResponseHeaders, + pick, + PRESENTATION_HEADERS, + upstreamRequestHeaders, +} from '../headers.ts'; test('upstreamRequestHeaders: carries mcp-method and mcp-name', () => { const out = upstreamRequestHeaders( @@ -86,6 +91,7 @@ test('upstreamRequestHeaders: drops credentials, x-dfo-* and other headers', () new Headers({ authorization: 'Bearer client', 'x-dfo-caller': 'ip:spoofed', + 'x-dfo-caller-principal': 'spoofed', 'x-dfo-trace-id': 'spoofed', 'x-dfo-principal': 'user:42', 'x-dfo-write-token': 'w1', @@ -99,6 +105,54 @@ test('upstreamRequestHeaders: drops credentials, x-dfo-* and other headers', () assert.deepEqual([...out], []); }); +test('upstreamRequestHeaders: carries the PRESENTATION_HEADERS a route asks for', () => { + const client = new Headers({ + 'x-dfo-write-token': 'shw_1', + 'if-none-match': '"v1"', + origin: 'https://example.org', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'if-none-match', + 'x-dfo-caller-principal': 'spoofed', + }); + + assert.deepEqual([...upstreamRequestHeaders(client)], []); + assert.deepEqual( + Object.fromEntries( + upstreamRequestHeaders(client, new Headers(), PRESENTATION_HEADERS.request), + ), + { + 'access-control-request-headers': 'if-none-match', + 'access-control-request-method': 'GET', + 'if-none-match': '"v1"', + origin: 'https://example.org', + 'x-dfo-write-token': 'shw_1', + }, + ); +}); + +test('clientResponseHeaders: carries the PRESENTATION_HEADERS a route asks for', () => { + const upstream = new Headers({ + etag: '"v1"', + vary: 'Origin', + 'access-control-allow-origin': '*', + 'access-control-expose-headers': 'etag', + 'x-dfo-write-token': 'shw_1', + }); + + assert.deepEqual([...clientResponseHeaders(upstream)], []); + assert.deepEqual( + Object.fromEntries( + clientResponseHeaders(upstream, new Headers(), PRESENTATION_HEADERS.response), + ), + { + 'access-control-allow-origin': '*', + 'access-control-expose-headers': 'etag', + etag: '"v1"', + vary: 'Origin', + }, + ); +}); + test('upstreamRequestHeaders: adds to the headers it is given', () => { const into = new Headers({ authorization: 'Bearer proxy' }); diff --git a/src/lib/proxy/test/openapi.test.ts b/src/lib/proxy/test/openapi.test.ts index 38ae824..396d90e 100644 --- a/src/lib/proxy/test/openapi.test.ts +++ b/src/lib/proxy/test/openapi.test.ts @@ -1,6 +1,6 @@ /** - * `openapi.ts`: the site's /openapi.json keeps the search, datasets and - * graph paths under /api/v1 and /codes/* where it is, drops every other path + * `openapi.ts`: the site's /openapi.json keeps the search, datasets, graph + * and presentation card and share paths under /api/v1 and /codes/* where it is, drops every other path * and the schemas only those used, and drops `servers`, so a client calls * the host that served it. Anything but a 200 JSON document passes through * untouched. @@ -35,7 +35,9 @@ const DOCUMENT = { '/api/datasets/{id}/versions': op('Versions'), '/api/graph/node/{dcid}': op('Node'), '/api/skills/{skill_id}': op('Skill'), - '/api/presentation/components': op('Component'), + '/api/presentation/components': op('Manifest'), + '/api/presentation/shares/{share_id}/slots/{n}/data': op('Manifest'), + '/api/presentation/frames/{frame_id}': op('Component'), '/api/searchlight': op('Skill'), '/codes/{code}': op('CodeRegistryEntry'), }, @@ -55,7 +57,7 @@ const DOCUMENT = { }, }; -test('publicOpenApi: keeps the three surfaces under /api/v1 and /codes, and drops the upstream servers', () => { +test('publicOpenApi: keeps the surfaces under /api/v1 and /codes, and drops the upstream servers', () => { const out = publicOpenApi(structuredClone(DOCUMENT)); assert.equal(Object.hasOwn(out, 'servers'), false); @@ -64,6 +66,9 @@ test('publicOpenApi: keeps the three surfaces under /api/v1 and /codes, and drop '/api/v1/datasets': DOCUMENT.paths['/api/datasets'], '/api/v1/datasets/{id}/versions': DOCUMENT.paths['/api/datasets/{id}/versions'], '/api/v1/graph/node/{dcid}': DOCUMENT.paths['/api/graph/node/{dcid}'], + '/api/v1/presentation/components': DOCUMENT.paths['/api/presentation/components'], + '/api/v1/presentation/shares/{share_id}/slots/{n}/data': + DOCUMENT.paths['/api/presentation/shares/{share_id}/slots/{n}/data'], '/codes/{code}': DOCUMENT.paths['/codes/{code}'], }); assert.deepEqual(out.info, DOCUMENT.info); diff --git a/src/lib/proxy/test/surfaces.test.ts b/src/lib/proxy/test/surfaces.test.ts index 5049ea6..6644114 100644 --- a/src/lib/proxy/test/surfaces.test.ts +++ b/src/lib/proxy/test/surfaces.test.ts @@ -5,7 +5,7 @@ */ import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join, sep } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -13,28 +13,29 @@ import { SEARCH_API_SURFACES } from '../surfaces.ts'; const V1 = fileURLToPath(new URL('../../../app/api/v1', import.meta.url)); -// The source of every route handler under `dir`. -const routeSources = (dir: string): string[] => - readdirSync(dir, { recursive: true, encoding: 'utf8' }) - .filter((file) => file.endsWith('route.ts')) - .map((file) => readFileSync(join(dir, file), 'utf8')); +// Every route handler under /api/v1 that forwards to SEARCH_API_BASE, with +// its surface: its directory below /api/v1 less the dynamic segments. +const apisRoutes = readdirSync(V1, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('route.ts')) + .map((file) => ({ + surface: dirname(file) + .split(sep) + .filter((segment) => segment !== '.' && !segment.startsWith('[')) + .join('/'), + source: readFileSync(join(V1, file), 'utf8'), + })) + .filter(({ source }) => source.includes('searchApiBase()')); test('SEARCH_API_SURFACES: lists every /api/v1 surface that forwards to SEARCH_API_BASE', () => { - const forwarding = readdirSync(V1).filter((surface) => - routeSources(join(V1, surface)).some((source) => source.includes('searchApiBase()')), - ); + const surfaces = new Set(apisRoutes.map(({ surface }) => surface)); - assert.deepEqual(forwarding.sort(), [...SEARCH_API_SURFACES].sort()); + assert.deepEqual([...surfaces].sort(), [...SEARCH_API_SURFACES].sort()); }); test("SEARCH_API_SURFACES: each surface's routes forward to its own dfo-apis path", () => { - for (const surface of SEARCH_API_SURFACES) { - const sources = routeSources(join(V1, surface)); + assert.ok(apisRoutes.length >= SEARCH_API_SURFACES.length); - assert.ok(sources.length > 0, surface); - - for (const source of sources) { - assert.ok(source.includes(`\${searchApiBase()}/api/${surface}`), surface); - } + for (const { surface, source } of apisRoutes) { + assert.ok(source.includes(`\${searchApiBase()}/api/${surface}`), surface); } }); From d274da62ae488f6b35d433e154d9f8c686d0c0b1 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 28 Oct 2026 15:08:23 +0100 Subject: [PATCH 02/18] feat(share): export shares for dfo-apis and keep users' write tokens scripts/export-shares.ts --since writes every share as one JSONL line in the format docs/share-export.md sets out: id, kind, mode with today's null-mode defaults applied, owner, created_at, title, a slot per referenced data card, and the website-owned payload that renders what slots cannot (search queries and parts, conversations, inline cards, canvas layout). It reads only, orders by created_at then id, and exits non-zero naming each share it cannot place. The share_token table (migration 0022) keeps a signed-in user's write token for a share held in dfo-apis. --- db/migrations/0022_share_token.sql | 8 + db/migrations/meta/0022_snapshot.json | 1144 +++++++++++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema.ts | 18 + db/share-export.ts | 300 +++++++ db/test/share-export.test.ts | 460 ++++++++++ db/test/share-token.test.ts | 51 ++ docs/share-export.md | 63 ++ scripts/export-shares.ts | 117 +++ src/lib/chat/shareTokens.ts | 49 ++ 10 files changed, 2217 insertions(+) create mode 100644 db/migrations/0022_share_token.sql create mode 100644 db/migrations/meta/0022_snapshot.json create mode 100644 db/share-export.ts create mode 100644 db/test/share-export.test.ts create mode 100644 db/test/share-token.test.ts create mode 100644 docs/share-export.md create mode 100644 scripts/export-shares.ts create mode 100644 src/lib/chat/shareTokens.ts diff --git a/db/migrations/0022_share_token.sql b/db/migrations/0022_share_token.sql new file mode 100644 index 0000000..d860326 --- /dev/null +++ b/db/migrations/0022_share_token.sql @@ -0,0 +1,8 @@ +CREATE TABLE "share_token" ( + "share_id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "write_token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "share_token" ADD CONSTRAINT "share_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/db/migrations/meta/0022_snapshot.json b/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..9e0b19f --- /dev/null +++ b/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,1144 @@ +{ + "id": "a27f5d07-abf1-471c-92b4-36943717ea39", + "prevId": "3a669c64-ea49-434a-9f75-2f090520da67", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.conversation": { + "name": "conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turn_count": { + "name": "turn_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_user_updated_idx": { + "name": "conversation_user_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_user_id_user_id_fk": { + "name": "conversation_user_id_user_id_fk", + "tableFrom": "conversation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "event_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "props": { + "name": "props", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "event_created_at_idx": { + "name": "event_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_name_created_at_idx": { + "name": "event_name_created_at_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "feedback_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "feedback_created_at_idx": { + "name": "feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "feedback_message_length": { + "name": "feedback_message_length", + "value": "char_length(\"feedback\".\"message\") BETWEEN 1 AND 5000" + } + }, + "isRLSEnabled": false + }, + "public.limit_counter": { + "name": "limit_counter", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "limit_counter_window_start_idx": { + "name": "limit_counter_window_start_idx", + "columns": [ + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roadmap_vote": { + "name": "roadmap_vote", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roadmap_vote_entry_idx": { + "name": "roadmap_vote_entry_idx", + "columns": [ + { + "expression": "entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roadmap_vote_user_id_user_id_fk": { + "name": "roadmap_vote_user_id_user_id_fk", + "tableFrom": "roadmap_vote", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "roadmap_vote_user_id_entry_id_pk": { + "name": "roadmap_vote_user_id_entry_id_pk", + "columns": [ + "user_id", + "entry_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share": { + "name": "share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "share_user_idx": { + "name": "share_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_user_id_user_id_fk": { + "name": "share_user_id_user_id_fk", + "tableFrom": "share", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_card_result": { + "name": "share_card_result", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retryable": { + "name": "retryable", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_after_s": { + "name": "retry_after_s", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fresh_until": { + "name": "fresh_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "share_card_result_share_id_share_id_fk": { + "name": "share_card_result_share_id_share_id_fk", + "tableFrom": "share_card_result", + "tableTo": "share", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "share_card_result_share_id_card_id_pk": { + "name": "share_card_result_share_id_card_id_pk", + "columns": [ + "share_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_ref": { + "name": "share_ref", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part_index": { + "name": "part_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entity_groups": { + "name": "entity_groups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "variable_text": { + "name": "variable_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_version": { + "name": "content_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "assumptions": { + "name": "assumptions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "share_ref_share_id_share_id_fk": { + "name": "share_ref_share_id_share_id_fk", + "tableFrom": "share_ref", + "tableTo": "share", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "share_ref_share_id_part_index_pk": { + "name": "share_ref_share_id_part_index_pk", + "columns": [ + "share_id", + "part_index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_token": { + "name": "share_token", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "write_token": { + "name": "write_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "share_token_user_id_user_id_fk": { + "name": "share_token_user_id_user_id_fk", + "tableFrom": "share_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webmail_domain": { + "name": "webmail_domain", + "schema": "", + "columns": { + "domain": { + "name": "domain", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 31bc3a0..1db4fdc 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1792794347981, "tag": "0021_account_daily_before_today", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1793196112763, + "tag": "0022_share_token", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index a773bba..008565d 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -151,6 +151,24 @@ export const shareCardResult = pgTable( export type ShareCardResultRow = typeof shareCardResult.$inferSelect; +/** + * The write token dfo-apis issued for a share a signed-in user made, which + * the website sends on that user's replace and delete requests + * (src/lib/chat/shareTokens.ts). The share itself lives in dfo-apis, so + * `share_id` references no table here. A guest's share has no row: its + * token is never stored, and the share stays as made. + */ +export const shareToken = pgTable('share_token', { + shareId: text('share_id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + writeToken: text('write_token').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type ShareTokenRow = typeof shareToken.$inferSelect; + /** * One row per (user, roadmap entry) vote: the "Under consideration" items * on /data/roadmap, which only signed-in people can vote for. The entry id diff --git a/db/share-export.ts b/db/share-export.ts new file mode 100644 index 0000000..5136b53 --- /dev/null +++ b/db/share-export.ts @@ -0,0 +1,300 @@ +import type { Pool } from 'pg'; + +import { canvasWidgetsOf } from '../src/lib/chat/canvas.ts'; +import type { SavedConversation } from '../src/lib/chat/history.ts'; +import type { ShareMode } from '../src/lib/chat/shareMode.ts'; +import type { ShareKind } from '../src/lib/chat/sharePrune.ts'; + +/** + * The share export (scripts/export-shares.ts): every row of `share` made at + * or after a moment, as the JSON lines docs/share-export.md defines, which + * dfo-apis' `import-shares` reads. `placeShare` decides where each stored + * share lands, and refuses a row it cannot place with the reason. + * `share_card_result` rows are a cache of live refreshes and are not read. + */ + +/** One data card of a share as dfo-apis holds it, in display order. */ +export type ExportSlot = { + component_id: string; + revision: number; + frame: { spec_id: string; content_version: string }; +}; + +/** One line of the export. */ +export type ExportLine = { + id: string; + kind: ShareKind; + mode: ShareMode; + owner: string | null; + created_at: string; + title: string; + slots: ExportSlot[]; + payload: Record; +}; + +/** One `share_ref` row, under its column names. */ +export type StoredPart = { + part_index: number; + spec_id: string; + identity: unknown; + entity_groups: unknown; + variable_text: string | null; + content_version: string | null; + artifact_digest: string | null; + operations: unknown; + assumptions: unknown; +}; + +/** One `share` row with its `share_ref` rows in part order. `created_at` + * is the stored moment in UTC to the microsecond. */ +export type StoredShare = { + id: string; + kind: string; + mode: string | null; + user_id: string | null; + created_at: string; + title: string; + data: unknown; + parts: StoredPart[]; +}; + +export type Placement = { line: ExportLine } | { refusal: string }; + +type LineBase = Pick; + +type StoredWidget = SavedConversation['widgets'][number]; + +const COMPONENT_ID = /^cmp_[A-Za-z0-9_-]{1,64}$/; +const FRAME_SPEC_ID = /^(spec|ops)_[A-Za-z0-9_-]+$/; +const SHARE_ID = /^[A-Za-z0-9]{8}$/; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +// The slot of a data card whose `ref` names a component revision in +// dfo-apis' form and whose `frame` names a dataset or derived frame, or +// null when either is absent or malformed. +const slotOf = (widget: StoredWidget): ExportSlot | null => { + if (widget.kind === 'text') { + return null; + } + + const { ref, frame } = widget; + + if ( + !isRecord(ref) || + typeof ref.component_id !== 'string' || + !COMPONENT_ID.test(ref.component_id) || + !Number.isSafeInteger(ref.revision) || + ref.revision < 1 || + !isRecord(frame) || + typeof frame.spec_id !== 'string' || + !FRAME_SPEC_ID.test(frame.spec_id) || + typeof frame.content_version !== 'string' || + frame.content_version === '' + ) { + return null; + } + + return { + component_id: ref.component_id, + revision: ref.revision, + frame: { spec_id: frame.spec_id, content_version: frame.content_version }, + }; +}; + +// A data card in the payload with none of what it showed: where it sits, +// what shape it takes, and the index of the slot that draws it, if any. +const heldCard = (widget: StoredWidget, slot: number | undefined): Record => ({ + kind: widget.kind, + id: widget.id, + turn: widget.turn, + ...(widget.kind === 'chart' + ? { chartType: widget.chartType, horizontal: widget.horizontal } + : {}), + slot, +}); + +const isCanvasState = (data: unknown): data is SavedConversation => + isRecord(data) && + Array.isArray(data.widgets) && + Array.isArray(data.pinnedIds) && + Array.isArray(data.removedIds); + +// A stored copy as a viewer reads it: no owner's harness session and no +// inherited refs (src/lib/chat/sharePrune.ts, shareForViewer). +const viewerCopy = (data: Record): Record => ({ + ...data, + sessionId: null, + componentRefs: [], +}); + +const placeSearch = (row: StoredShare, base: LineBase): Placement => { + if (row.mode !== null && row.mode !== 'live' && row.mode !== 'snapshot') { + return { refusal: `mode ${JSON.stringify(row.mode)} is not live or snapshot` }; + } + + // A search share with no stored mode is live (searchShareMode). + const mode: ShareMode = row.mode ?? 'live'; + const unpinned = row.parts.find((part) => part.content_version === null); + + if (mode === 'snapshot' && unpinned) { + return { refusal: `snapshot part ${unpinned.part_index} has no content_version` }; + } + + const query = isRecord(row.data) && typeof row.data.query === 'string' ? row.data.query : null; + + return { + line: { + ...base, + kind: 'search', + mode, + slots: [], + payload: { query: query ?? row.title, parts: row.parts }, + }, + }; +}; + +const placeStudio = ( + row: StoredShare, + kind: 'conversation' | 'canvas' | 'card', + base: LineBase, +): Placement => { + if (row.parts.length > 0) { + return { refusal: `a ${kind} share has ${row.parts.length} share_ref rows` }; + } + + if (row.mode !== null && row.mode !== 'live' && row.mode !== 'snapshot') { + return { refusal: `mode ${JSON.stringify(row.mode)} is not live or snapshot` }; + } + + // A Studio share with no stored mode is a snapshot (isLiveStudioShare). + const mode: ShareMode = row.mode ?? 'snapshot'; + + if (kind === 'conversation') { + if (mode === 'live') { + return { refusal: 'a conversation share cannot be live' }; + } + + if (!isRecord(row.data)) { + return { refusal: 'data is not a saved conversation' }; + } + + return { line: { ...base, kind, mode, slots: [], payload: viewerCopy(row.data) } }; + } + + if (!isCanvasState(row.data)) { + return { refusal: 'data has no widgets, pinnedIds and removedIds arrays' }; + } + + const saved = row.data; + const slots: ExportSlot[] = []; + const slotIndex = new Map(); + + // canvasWidgetsOf hands back the stored widget objects, so each slot is + // tied to its own widget even when two cards draw the same revision. + for (const widget of canvasWidgetsOf(saved)) { + if (widget.kind === 'text') { + continue; + } + + const slot = slotOf(widget as StoredWidget); + + if (slot) { + slotIndex.set(widget as StoredWidget, slots.length); + slots.push(slot); + } else if (mode === 'live') { + return { + refusal: `live card ${widget.id} has no component ref and frame in dfo-apis' form`, + }; + } + } + + // A live share shows none of the numbers stored with it, as the live + // share pages withhold them today (withholdCardData), so a data card + // the canvas does not show is held too, with no slot. + const widgets = saved.widgets.map((widget) => { + const slot = slotIndex.get(widget); + const held = slot !== undefined || (mode === 'live' && widget.kind !== 'text'); + + return held ? heldCard(widget, slot) : widget; + }); + + return { line: { ...base, kind, mode, slots, payload: { ...viewerCopy(saved), widgets } } }; +}; + +/** Where one stored share lands in the export, or why it cannot. */ +export const placeShare = (row: StoredShare): Placement => { + if (!SHARE_ID.test(row.id)) { + return { refusal: 'the id is not 8 letters and digits' }; + } + + const base: LineBase = { + id: row.id, + owner: row.user_id, + created_at: row.created_at, + title: row.title, + }; + + switch (row.kind) { + case 'search': + return placeSearch(row, base); + case 'conversation': + case 'canvas': + case 'card': + return placeStudio(row, row.kind, base); + default: + return { refusal: `kind ${JSON.stringify(row.kind)} is not a share kind` }; + } +}; + +/** + * Every share made at or after `since` (an ISO timestamp with its offset), + * by `created_at` then id, each with its `share_ref` rows in part order. + */ +export const readShares = async (pool: Pool, since: string): Promise => { + const shares = await pool.query>( + `SELECT id, kind, mode, user_id, title, data, + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS created_at + FROM share + WHERE created_at >= $1::timestamptz + ORDER BY share.created_at, id`, + [since], + ); + const parts = await pool.query( + `SELECT r.share_id, r.part_index, r.spec_id, r.identity, r.entity_groups, + r.variable_text, r.content_version, r.artifact_digest, r.operations, + r.assumptions + FROM share_ref r JOIN share s ON s.id = r.share_id + WHERE s.created_at >= $1::timestamptz + ORDER BY r.share_id, r.part_index`, + [since], + ); + const partsOf = new Map(); + + for (const { share_id: shareId, ...part } of parts.rows) { + partsOf.set(shareId, [...(partsOf.get(shareId) ?? []), part]); + } + + return shares.rows.map((row) => ({ ...row, parts: partsOf.get(row.id) ?? [] })); +}; + +/** The export of `rows`: one JSON line each, or every refusal by share id. */ +export const exportShares = ( + rows: StoredShare[], +): { lines: ExportLine[]; refusals: { id: string; reason: string }[] } => { + const lines: ExportLine[] = []; + const refusals: { id: string; reason: string }[] = []; + + for (const row of rows) { + const placement = placeShare(row); + + if ('line' in placement) { + lines.push(placement.line); + } else { + refusals.push({ id: row.id, reason: placement.refusal }); + } + } + + return { lines, refusals }; +}; diff --git a/db/test/share-export.test.ts b/db/test/share-export.test.ts new file mode 100644 index 0000000..ea5c6c5 --- /dev/null +++ b/db/test/share-export.test.ts @@ -0,0 +1,460 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { applyMigrations } from '../apply-migrations.ts'; +import { exportShares, placeShare, readShares, type StoredShare } from '../share-export.ts'; +import { createTestDatabase } from './harness.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); +const SCRIPT = path.resolve(import.meta.dirname, '../../scripts/export-shares.ts'); +const run = promisify(execFile); + +const FRAME = { spec_id: 'spec_aid', content_version: 'cv_1' }; +const DERIVED = { spec_id: 'ops_growth', content_version: 'ocv_2' }; +const ref = (n: number) => ({ component_id: `cmp_card${n}`, revision: n }); + +const chart = (id: string, turn: number, extra: Record = {}) => ({ + kind: 'chart', + id, + turn, + chartType: 'line', + title: `Chart ${id}`, + categories: ['2023', '2024'], + series: [{ label: 'Kenya', values: [1, 2] }], + ...extra, +}); +const table = (id: string, turn: number, extra: Record = {}) => ({ + kind: 'table', + id, + turn, + title: `Table ${id}`, + columns: ['Year', 'Value'], + rows: [['2024', '3']], + ...extra, +}); +const note = (id: string, turn: number) => ({ kind: 'text', id, turn, title: 'Note', body: 'B' }); + +const canvas = (widgets: unknown[], rest: Record = {}) => ({ + id: 'conv-1', + sessionId: 'harness-session', + componentRefs: [ref(9)], + title: 'Aid to Kenya', + updatedAt: 1, + messages: [], + widgets, + activity: [], + turnCount: 2, + pinnedIds: [], + removedIds: [], + layout: { c1: { col: 0, row: 0, w: 4, h: 3 } }, + theme: 'paper', + textCards: [{ kind: 'text', id: 'tc1', title: 'Mine', body: 'Words' }], + titles: { c1: 'Renamed' }, + notes: { c1: 'A note' }, + views: { c1: 'table' }, + ...rest, +}); + +const SEARCH_PART = { + spec_id: 'spec_aid', + identity: { variable: 'aid' }, + entity_groups: null, + variable_text: 'aid to Kenya', + operations: [], + assumptions: null, +}; + +// One row per legacy case, inserted in this order with these moments. +const ROWS = [ + // A guest's search share with no mode: live, its parts unpinned. + { + id: 'srchLiv1', + kind: 'search', + mode: null, + user_id: null, + created_at: '2026-09-01T10:00:00.000001Z', + title: 'aid to Kenya', + data: { query: 'aid to Kenya' }, + parts: [ + { ...SEARCH_PART, part_index: 1, content_version: null, artifact_digest: null }, + { ...SEARCH_PART, part_index: 0, content_version: null, artifact_digest: null }, + ], + }, + // A search snapshot made before artifact_digest was stored. + { + id: 'srchSnp1', + kind: 'search', + mode: 'snapshot', + user_id: 'user0001', + created_at: '2026-09-02T10:00:00Z', + title: 'debt in Ghana', + data: { query: 'debt in Ghana' }, + parts: [{ ...SEARCH_PART, part_index: 0, content_version: 'cv_7', artifact_digest: null }], + }, + // A snapshot canvas with no mode: a pinned card from turn 1, a removed + // card, a ref-less card with inline numbers, a card on a derived frame, + // and an agent note. + { + id: 'canvSnp1', + kind: 'canvas', + mode: null, + user_id: 'user0001', + created_at: '2026-09-03T10:00:00Z', + title: 'Aid to Kenya', + data: canvas( + [ + chart('c0', 1, { ref: ref(1), frame: FRAME }), + chart('gone', 2, { ref: ref(2), frame: FRAME }), + table('c1', 2), + chart('c2', 2, { ref: ref(3), frame: DERIVED, horizontal: true }), + note('n1', 2), + ], + { pinnedIds: ['c0'], removedIds: ['gone'] }, + ), + parts: [], + }, + // A live canvas: each data card it shows has its ref and frame, and an + // older turn's unpinned card is not shown. + { + id: 'canvLiv1', + kind: 'canvas', + mode: 'live', + user_id: 'user0001', + created_at: '2026-09-04T10:00:00Z', + title: 'Live aid', + data: canvas([ + table('old', 1, { ref: ref(7), frame: FRAME }), + table('c1', 2, { ref: ref(4), frame: FRAME }), + table('c2', 2, { ref: ref(4), frame: FRAME }), + ]), + parts: [], + }, + // Two shares made at the same moment leave by id. + { + id: 'cardSnp2', + kind: 'card', + mode: 'snapshot', + user_id: null, + created_at: '2026-09-05T10:00:00Z', + title: 'Debt', + data: canvas([table('c1', 1)], { pinnedIds: ['c1'], textCards: [] }), + parts: [], + }, + { + id: 'cardLiv1', + kind: 'card', + mode: 'live', + user_id: 'user0001', + created_at: '2026-09-05T10:00:00Z', + title: 'Live card', + data: canvas([chart('c1', 1, { ref: ref(5), frame: FRAME })], { + pinnedIds: ['c1'], + textCards: [], + }), + parts: [], + }, + // A conversation share with no mode: a snapshot, carried whole. + { + id: 'convSnp1', + kind: 'conversation', + mode: null, + user_id: 'user0001', + created_at: '2026-09-06T10:00:00Z', + title: 'Aid to Kenya', + data: canvas([chart('c1', 1, { ref: ref(6), frame: FRAME })], { + messages: [{ role: 'user', text: 'aid to Kenya' }], + }), + parts: [], + }, +] satisfies StoredShare[]; + +// The stored copy of a Studio row of ROWS. +const canvasOf = (index: number) => ROWS[index]!.data as ReturnType; + +const held = (data: ReturnType, widgets: unknown[]) => ({ + ...data, + sessionId: null, + componentRefs: [], + widgets, +}); + +const EXPECTED = [ + { + id: 'srchLiv1', + kind: 'search', + mode: 'live', + owner: null, + created_at: '2026-09-01T10:00:00.000001Z', + title: 'aid to Kenya', + slots: [], + payload: { + query: 'aid to Kenya', + parts: [ROWS[0].parts[1], ROWS[0].parts[0]], + }, + }, + { + id: 'srchSnp1', + kind: 'search', + mode: 'snapshot', + owner: 'user0001', + created_at: '2026-09-02T10:00:00.000000Z', + title: 'debt in Ghana', + slots: [], + payload: { query: 'debt in Ghana', parts: ROWS[1].parts }, + }, + { + id: 'canvSnp1', + kind: 'canvas', + mode: 'snapshot', + owner: 'user0001', + created_at: '2026-09-03T10:00:00.000000Z', + title: 'Aid to Kenya', + slots: [ + { ...ref(1), frame: FRAME }, + { ...ref(3), frame: DERIVED }, + ], + payload: held(canvasOf(2), [ + { kind: 'chart', id: 'c0', turn: 1, chartType: 'line', slot: 0 }, + canvasOf(2).widgets[1], + canvasOf(2).widgets[2], + { kind: 'chart', id: 'c2', turn: 2, chartType: 'line', horizontal: true, slot: 1 }, + canvasOf(2).widgets[4], + ]), + }, + { + id: 'canvLiv1', + kind: 'canvas', + mode: 'live', + owner: 'user0001', + created_at: '2026-09-04T10:00:00.000000Z', + title: 'Live aid', + slots: [ + { ...ref(4), frame: FRAME }, + { ...ref(4), frame: FRAME }, + ], + payload: held(canvasOf(3), [ + { kind: 'table', id: 'old', turn: 1 }, + { kind: 'table', id: 'c1', turn: 2, slot: 0 }, + { kind: 'table', id: 'c2', turn: 2, slot: 1 }, + ]), + }, + { + id: 'cardLiv1', + kind: 'card', + mode: 'live', + owner: 'user0001', + created_at: '2026-09-05T10:00:00.000000Z', + title: 'Live card', + slots: [{ ...ref(5), frame: FRAME }], + payload: held(canvasOf(5), [ + { kind: 'chart', id: 'c1', turn: 1, chartType: 'line', slot: 0 }, + ]), + }, + { + id: 'cardSnp2', + kind: 'card', + mode: 'snapshot', + owner: null, + created_at: '2026-09-05T10:00:00.000000Z', + title: 'Debt', + slots: [], + payload: held(canvasOf(4), canvasOf(4).widgets), + }, + { + id: 'convSnp1', + kind: 'conversation', + mode: 'snapshot', + owner: 'user0001', + created_at: '2026-09-06T10:00:00.000000Z', + title: 'Aid to Kenya', + slots: [], + payload: { ...canvasOf(6), sessionId: null, componentRefs: [] }, + }, +]; + +/** + * The export over a migrated database holding one share per legacy case, + * read as the runtime role: each lands with its mode, owner, slots and + * payload, in `created_at` then id order, from `--since` on; and the + * script writes the same lines, or refuses with nothing written. + */ +test('every legacy share case lands in the export', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const pool = database.runtimePool(); + + await pool.query(`insert into "user" (id, name, email) values ('user0001', 'A', 'a@one.org')`); + + for (const row of ROWS) { + await pool.query( + `insert into share (id, user_id, kind, mode, title, data, created_at) + values ($1, $2, $3, $4, $5, $6::jsonb, $7)`, + [row.id, row.user_id, row.kind, row.mode, row.title, row.data, row.created_at], + ); + + for (const part of row.parts) { + await pool.query( + `insert into share_ref (share_id, part_index, spec_id, identity, entity_groups, + variable_text, content_version, artifact_digest, operations, assumptions) + values ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9::jsonb, $10::jsonb)`, + [ + row.id, + part.part_index, + part.spec_id, + JSON.stringify(part.identity), + JSON.stringify(part.entity_groups), + part.variable_text, + part.content_version, + part.artifact_digest, + JSON.stringify(part.operations), + JSON.stringify(part.assumptions), + ], + ); + } + } + + // A live refresh cache row, which the export never reads. + await pool.query( + `insert into share_card_result (share_id, card_id, status) values ('canvLiv1', 'c1', 'current')`, + ); + + const all = exportShares(await readShares(pool, '2026-08-01T00:00:00Z')); + + // The lines are JSON, where a member left undefined is absent. + assert.deepEqual(JSON.parse(JSON.stringify(all)), { lines: EXPECTED, refusals: [] }); + + const later = exportShares(await readShares(pool, '2026-09-05T10:00:00+00:00')); + + assert.deepEqual( + later.lines.map(({ id }) => id), + ['cardLiv1', 'cardSnp2', 'convSnp1'], + ); + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-export-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const out = path.join(dir, 'shares.jsonl'); + const env = { + ...process.env, + DATABASE_URL: `postgres://${database.runtimeUser}:${database.runtimePassword}@${process.env.PGHOST ?? 'localhost'}:${process.env.PGPORT ?? 5433}/${database.databaseName}`, + }; + const exported = await run( + process.execPath, + [SCRIPT, '--since', '2026-08-01T00:00:00Z', '--out', out], + { env }, + ); + + const written = await fs.readFile(out, 'utf8'); + + assert.ok(written.endsWith('\n')); + assert.deepEqual( + written + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)), + EXPECTED, + ); + assert.match(exported.stderr, /^7 shares exported since 2026-08-01T00:00:00Z: /m); + + await pool.query( + `insert into share (id, kind, title, data, created_at) + values ('gallery1', 'gallery', 'G', '{}'::jsonb, '2026-09-07T10:00:00Z')`, + ); + + const refusedOut = path.join(dir, 'refused.jsonl'); + const refused = await run( + process.execPath, + [SCRIPT, '--since', '2026-08-01T00:00:00Z', '--out', refusedOut], + { env }, + ).catch((error: { code: number; stderr: string }) => error); + + assert.equal('code' in refused && refused.code, 1); + assert.match(refused.stderr, /share gallery1: kind "gallery" is not a share kind/); + await assert.rejects(fs.access(refusedOut)); + + const noOffset = await run(process.execPath, [SCRIPT, '--since', '2026-08-01T00:00:00'], { + env, + }).catch((error: { code: number }) => error); + + assert.equal('code' in noOffset && noOffset.code, 2); +}); + +const stored = (overrides: Partial): StoredShare => ({ + id: 'abcd1234', + kind: 'canvas', + mode: 'live', + user_id: null, + created_at: '2026-09-01T00:00:00.000000Z', + title: 'T', + data: canvas([]), + parts: [], + ...overrides, +}); + +test('a share the export cannot place is refused with its reason', () => { + const cases: [Partial, string][] = [ + [{ id: 'short' }, 'the id is not 8 letters and digits'], + [{ mode: 'pinned' }, 'mode "pinned" is not live or snapshot'], + [{ kind: 'conversation', mode: 'live' }, 'a conversation share cannot be live'], + [{ data: { widgets: [] } }, 'data has no widgets, pinnedIds and removedIds arrays'], + [ + { data: canvas([table('c1', 1)]) }, + "live card c1 has no component ref and frame in dfo-apis' form", + ], + [ + { data: canvas([table('c1', 1, { ref: ref(1) })]) }, + "live card c1 has no component ref and frame in dfo-apis' form", + ], + [ + { + data: canvas([ + table('c1', 1, { ref: { component_id: '../x', revision: 1 }, frame: FRAME }), + ]), + }, + "live card c1 has no component ref and frame in dfo-apis' form", + ], + [ + { + parts: [ + { ...SEARCH_PART, part_index: 0, content_version: null, artifact_digest: null }, + ], + }, + 'a canvas share has 1 share_ref rows', + ], + [ + { + kind: 'search', + mode: 'snapshot', + data: { query: 'q' }, + parts: [ + { ...SEARCH_PART, part_index: 0, content_version: null, artifact_digest: null }, + ], + }, + 'snapshot part 0 has no content_version', + ], + ]; + + for (const [overrides, refusal] of cases) { + assert.deepEqual(placeShare(stored(overrides)), { refusal }, JSON.stringify(overrides)); + } + + // A snapshot keeps a card whose ref has no frame as stored, inline. + const snapshot = placeShare( + stored({ mode: 'snapshot', data: canvas([table('c1', 1, { ref: ref(1) })]) }), + ); + + assert.ok('line' in snapshot); + assert.deepEqual(snapshot.line.slots, []); +}); diff --git a/db/test/share-token.test.ts b/db/test/share-token.test.ts new file mode 100644 index 0000000..66c21d5 --- /dev/null +++ b/db/test/share-token.test.ts @@ -0,0 +1,51 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drizzle } from 'drizzle-orm/node-postgres'; + +import { putShareToken, readShareToken } from '../../src/lib/chat/shareTokens.ts'; +import { applyMigrations } from '../apply-migrations.ts'; +import { user } from '../schema.ts'; +import { createTestDatabase } from './harness.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); + +/** + * `shareTokens.ts` against the migrated tables as the runtime role: a user + * reads back the token they put and replaces it, another user neither reads + * nor overwrites it, and deleting the user deletes their tokens. + */ +test('share write tokens', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const db = drizzle(database.runtimePool()); + + await db.insert(user).values([ + { id: 'owner', name: 'O', email: 'o@one.org' }, + { id: 'other', name: 'X', email: 'x@one.org' }, + ]); + + const owner = { shareId: 'Ab3dEf7h', userId: 'owner' }; + const other = { shareId: 'Ab3dEf7h', userId: 'other' }; + + assert.equal(await readShareToken(db, owner), null); + assert.equal(await putShareToken(db, { ...owner, writeToken: 'shw_one' }), true); + assert.equal(await readShareToken(db, owner), 'shw_one'); + assert.equal(await putShareToken(db, { ...owner, writeToken: 'shw_two' }), true); + assert.equal(await readShareToken(db, owner), 'shw_two'); + + assert.equal(await readShareToken(db, other), null); + assert.equal(await putShareToken(db, { ...other, writeToken: 'shw_theirs' }), false); + assert.equal(await readShareToken(db, owner), 'shw_two'); + + await database.runtimePool().query(`delete from "user" where id = 'owner'`); + assert.equal(await putShareToken(db, { ...other, writeToken: 'shw_theirs' }), true); + assert.equal(await readShareToken(db, other), 'shw_theirs'); +}); diff --git a/docs/share-export.md b/docs/share-export.md new file mode 100644 index 0000000..1987046 --- /dev/null +++ b/docs/share-export.md @@ -0,0 +1,63 @@ +# Share export + +`scripts/export-shares.ts` writes the website's shares as JSON lines, one share per line, and dfo-apis' `maintenance.py import-shares` reads them. The import keeps each 8-character id, so every `/s/` and `/embed/` link keeps working. The queries and the rule that places each share are in `db/share-export.ts`. + +``` +node scripts/export-shares.ts --since 2026-10-01T00:00:00Z --out shares.jsonl +``` + +The script reads the database from `DATABASE_URL`, or from the `WEBSITE_DB_*` Cloud SQL variables the runtime uses, and writes nothing to it. `--since` takes an ISO timestamp with a `Z` or `+hh:mm` offset and exports every share whose `created_at` is at or after it. Without `--out` the lines go to stdout. A count by kind and mode goes to stderr. A bad `--since` exits 2. + +A share the script cannot place fails the run: it prints `share : ` for each such share to stderr, exits 1, and writes no output, so a file on disk is always a whole export. Runs overlap safely, because the import is idempotent and a later run with an earlier `--since` writes the same lines for the same shares. + +## Lines + +Each line is one JSON object with these members, and lines are ordered by `created_at`, then by `id`. + +- `id`: the share id, 8 characters from `[A-Za-z0-9]`, case-sensitive. +- `kind`: `search`, `conversation`, `canvas`, or `card`. +- `mode`: `live` or `snapshot`. A share stored with no mode gets the default the website reads today: `live` for a search share (`searchShareMode` in `src/lib/chat/shareMode.ts`) and `snapshot` for the other kinds (`isLiveStudioShare`). +- `owner`: the website user id of the share's maker, or null for a guest's share. A guest's share imports with no write token, and nobody can change or delete it. +- `created_at`: the stored moment in UTC to the microsecond, as `2026-09-01T10:00:00.000001Z`. +- `title`: the stored title. +- `slots`: the data cards dfo-apis holds, in display order, each `{"component_id": "cmp_…", "revision": 3, "frame": {"spec_id": "spec_…" or "ops_…", "content_version": "…"}}`. Only canvas and card shares have slots. Search and conversation shares have `[]`. +- `payload`: website-owned JSON that dfo-apis stores as given and does not read. It holds what the website needs to render the share beyond its slots. + +## Where each stored share lands + +| Stored share | `mode` | `slots` | `payload` | +| --------------------------------------- | ---------- | ------------------------------------------------------- | ------------------------------------------------------------------- | +| Search, mode `live` or null | `live` | `[]` | `query` and `parts` | +| Search, mode `snapshot` | `snapshot` | `[]` | `query` and `parts`, each part with its `content_version` | +| Canvas or card, mode `live` | `live` | every data card on the canvas | the stored copy, slotted cards held | +| Canvas or card, mode `snapshot` or null | `snapshot` | every data card on the canvas with a complete reference | the stored copy, slotted cards held, the other data cards as stored | +| Conversation, mode `snapshot` or null | `snapshot` | `[]` | the stored copy | + +A search share's `query` is `share.data.query`, or the title when the row has none, as `getSearchShare` reads it. Its `parts` are the share's `share_ref` rows in `part_index` order, each with the members `part_index`, `spec_id`, `identity`, `entity_groups`, `variable_text`, `content_version`, `artifact_digest`, `operations`, and `assumptions`. A live search share's parts have null `content_version` and `artifact_digest` and replay their definitions. A snapshot made before the website stored `artifact_digest` has a null digest and keeps its `content_version`. + +The stored copy of a canvas, card, or conversation share is its `share.data`, a pruned saved conversation (`pruneForShare` in `src/lib/chat/sharePrune.ts`), with `sessionId` set to null and `componentRefs` to `[]`, as every share read already does. It keeps the layout, the user's text cards, the titles, notes and views by card id, the theme, pins and removals, and, for a conversation share, the messages. + +## Slots + +The data cards on a canvas are the cards `canvasWidgetsOf` (`src/lib/chat/canvas.ts`) shows, other than text cards, in that order: the stored `widgets` the canvas displays, in stored order. A card share holds one. A data card has a complete reference when its `ref` names a component revision as `cmp_` plus up to 64 letters, digits, `_` or `-`, with a whole revision of 1 or more, and its `frame` names a `spec_` or `ops_` frame with a non-empty content version. + +Each such card becomes one slot. In the payload's `widgets` the card is held: it keeps `kind`, `id`, `turn`, and for a chart `chartType` and `horizontal`, and gains `slot`, the index of its slot in `slots`. Its numbers, title, source and description come from the slot's component revision. Two cards drawing the same revision get a slot each. + +A snapshot's data card without a complete reference, which includes every card stored before cards carried a `ref`, stays in `widgets` as stored, with the numbers it showed inline, and has no slot. Widgets the canvas does not show, such as removed cards and older turns' unpinned cards, stay in `widgets` with no slot, so the payload shows the same cards as the stored copy. In a snapshot they stay as stored. In a live share every data card is held, shown or not, because live share pages show none of the numbers stored with the share. + +## Refused shares + +The script refuses a share whose: + +- id is not 8 letters and digits; +- kind is not one of the four; +- mode is set to a value other than `live` or `snapshot`; +- kind is `conversation` and mode is `live`; +- kind is `conversation`, `canvas`, or `card` and it has `share_ref` rows; +- data, for a canvas or card, lacks `widgets`, `pinnedIds`, or `removedIds` arrays; +- canvas or card is live and a data card on it has no complete reference; +- search snapshot has a part with no `content_version`. + +## Not exported + +`share_card_result` rows are not exported. They cache live refreshes, and dfo-apis refreshes live slots itself. The `conversation_id` and `card_id` columns are not exported either, because no share page reads them. diff --git a/scripts/export-shares.ts b/scripts/export-shares.ts new file mode 100644 index 0000000..d881b6b --- /dev/null +++ b/scripts/export-shares.ts @@ -0,0 +1,117 @@ +// Writes every share made at or after --since as the JSON lines +// docs/share-export.md defines, for dfo-apis' `import-shares`. +// db/share-export.ts holds the queries and the placement of each share. +// +// node scripts/export-shares.ts --since 2026-10-01T00:00:00Z # to stdout +// node scripts/export-shares.ts --since 2026-10-01T00:00:00Z --out shares.jsonl +// +// It reads the database from DATABASE_URL, or from the WEBSITE_DB_* Cloud +// SQL variables the runtime uses, and writes nothing to it. A share it +// cannot place fails the run with every such share named on stderr, and +// no output is written. +import fs from 'node:fs/promises'; +import { parseArgs } from 'node:util'; + +import type { Pool } from 'pg'; + +import { + buildRuntimePool, + connectCloudSqlRuntime, + resolveRuntimeConnection, + setCloudSqlRuntimeStream, +} from '../db/connection.ts'; +import { exportShares, readShares } from '../db/share-export.ts'; + +const USAGE = `usage: node scripts/export-shares.ts --since [--out ] + + --since export shares created at or after this moment, e.g. 2026-10-01T00:00:00Z + --out write the lines to this file instead of stdout`; + +// An offset is required, so the moment does not depend on the session's +// time zone. +const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; + +const openPool = async (): Promise<{ pool: Pool; close: () => Promise }> => { + const connection = resolveRuntimeConnection(); + + if (connection.kind === 'url') { + const pool = buildRuntimePool(process.env, { max: 1 }); + + return { pool, close: () => pool.end() }; + } + + const { connector, stream } = await connectCloudSqlRuntime(connection); + + setCloudSqlRuntimeStream(stream); + + const pool = buildRuntimePool(process.env, { max: 1 }); + + return { + pool, + close: async () => { + await pool.end(); + connector.close(); + }, + }; +}; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + since: { type: 'string' }, + out: { type: 'string' }, + help: { type: 'boolean', default: false }, + }, + }); + const { since } = values; + + if (values.help || !since || !WITH_OFFSET.test(since) || Number.isNaN(Date.parse(since))) { + console.error(USAGE); + process.exitCode = values.help ? 0 : 2; + + return; + } + + const { pool, close } = await openPool(); + + try { + const { lines, refusals } = exportShares(await readShares(pool, since)); + + if (refusals.length) { + for (const { id, reason } of refusals) { + console.error(`share ${id}: ${reason}`); + } + + console.error( + `${refusals.length} shares cannot be placed; nothing written. ` + + 'docs/share-export.md says where each case lands.', + ); + process.exitCode = 1; + + return; + } + + const text = lines.map((line) => `${JSON.stringify(line)}\n`).join(''); + + if (values.out) { + await fs.writeFile(values.out, text); + } else { + process.stdout.write(text); + } + + const counts = Object.entries( + Object.groupBy(lines, ({ kind, mode }) => `${kind} ${mode}`), + ).map(([key, group]) => `${key} ${group?.length ?? 0}`); + + console.error( + `${lines.length} shares exported since ${since}${counts.length ? `: ${counts.join(', ')}` : ''}`, + ); + } finally { + await close(); + } +}; + +main().catch((error: unknown) => { + console.error('ERROR:', error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/src/lib/chat/shareTokens.ts b/src/lib/chat/shareTokens.ts new file mode 100644 index 0000000..a56ef99 --- /dev/null +++ b/src/lib/chat/shareTokens.ts @@ -0,0 +1,49 @@ +import { and, eq, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { shareToken } from '../../../db/schema.ts'; + +/** + * The write tokens dfo-apis issues for signed-in users' shares, in the + * `share_token` table, each function taking the database handle first. With + * no path-alias dependency, they run under `node --test` against a migrated + * test database. + */ + +export type ShareTokenDb = Pick; + +/** + * Store `writeToken` as `userId`'s token for `shareId`, replacing the token + * that user stored for it before. False when the share's token belongs to + * another user, whose row is left as it was. + */ +export const putShareToken = async ( + db: ShareTokenDb, + { shareId, userId, writeToken }: { shareId: string; userId: string; writeToken: string }, +): Promise => { + const written = await db + .insert(shareToken) + .values({ shareId, userId, writeToken }) + .onConflictDoUpdate({ + target: shareToken.shareId, + set: { writeToken, createdAt: sql`now()` }, + setWhere: eq(shareToken.userId, userId), + }) + .returning({ shareId: shareToken.shareId }); + + return written.length > 0; +}; + +/** `userId`'s write token for `shareId`, or null when that user stored none. */ +export const readShareToken = async ( + db: ShareTokenDb, + { shareId, userId }: { shareId: string; userId: string }, +): Promise => { + const [row] = await db + .select({ writeToken: shareToken.writeToken }) + .from(shareToken) + .where(and(eq(shareToken.shareId, shareId), eq(shareToken.userId, userId))) + .limit(1); + + return row?.writeToken ?? null; +}; From f8414f5626e76e894af7b7d8459c387aa358184b Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 28 Oct 2026 17:20:30 +0100 Subject: [PATCH 03/18] feat(share): keep shares in dfo-apis and render them from its records Creating, fixing and deleting a share goes through dfo-apis' share routes with the caller named on every write. A signed-in owner's write token is kept in share_token and sent on later writes; guests may make live shares, which they cannot change afterwards. /s/ and /embed/ render the dfo-apis record: card shares from their slots, search, canvas and conversation shares from the stored payload. sharePayload places cards in slots for new shares and the exporter alike, and the exporter writes frame: null for a card whose frame the site does not hold. scripts/import-share-tokens.ts loads the token file dfo-apis' importer writes, idempotently. The per-guest share allowance moves to dfo-apis, so spendGuestShare is gone. --- db/share-export.ts | 103 +---- db/share-token-import.ts | 97 +++++ db/test/apis-shares.test.ts | 174 ++++++++ db/test/share-export.test.ts | 47 +- db/test/share-mode.test.ts | 16 +- db/test/share-token.test.ts | 125 +++++- docs/share-export.md | 22 +- scripts/export-shares.ts | 33 +- scripts/import-share-tokens.ts | 80 ++++ scripts/open-pool.ts | 37 ++ .../share/[id]/cards/[cardId]/data/route.ts | 17 +- .../[id]/cards/[cardId]/refresh/route.ts | 18 +- src/app/api/share/[id]/route.ts | 64 +-- src/app/api/share/route.ts | 104 +++-- src/app/embed/[id]/page.tsx | 11 +- src/app/s/[id]/page.tsx | 28 +- src/app/studio/[id]/page.tsx | 2 +- src/app/studio/page.tsx | 2 +- .../organisms/Studio/modals/ShareModal.tsx | 31 +- .../organisms/Studio/modals/StudioModals.tsx | 2 +- src/lib/chat/apisShares.ts | 378 ++++++++++++++++ src/lib/chat/shareMode.ts | 4 - src/lib/chat/sharePayload.ts | 214 +++++++++ src/lib/chat/shareStore.ts | 108 ++--- src/lib/chat/shareTokens.ts | 13 +- src/lib/chat/shareWrites.ts | 28 +- src/lib/chat/test/apisShares.test.ts | 177 ++++++++ src/lib/chat/test/sharePayload.test.ts | 116 +++++ src/lib/search/searchShare.ts | 87 +--- src/lib/search/searchSharePayload.ts | 106 +++++ .../search/test/searchSharePayload.test.ts | 90 ++++ src/lib/studio/shareDialog.ts | 11 +- src/lib/studio/slotCards.ts | 407 ++++++++++++++++++ src/lib/studio/test/shareDialog.test.ts | 6 +- src/lib/studio/test/slotCards.test.ts | 229 ++++++++++ 35 files changed, 2533 insertions(+), 454 deletions(-) create mode 100644 db/share-token-import.ts create mode 100644 db/test/apis-shares.test.ts create mode 100644 scripts/import-share-tokens.ts create mode 100644 scripts/open-pool.ts create mode 100644 src/lib/chat/apisShares.ts create mode 100644 src/lib/chat/sharePayload.ts create mode 100644 src/lib/chat/test/apisShares.test.ts create mode 100644 src/lib/chat/test/sharePayload.test.ts create mode 100644 src/lib/search/searchSharePayload.ts create mode 100644 src/lib/search/test/searchSharePayload.test.ts create mode 100644 src/lib/studio/slotCards.ts create mode 100644 src/lib/studio/test/slotCards.test.ts diff --git a/db/share-export.ts b/db/share-export.ts index 5136b53..0e80f45 100644 --- a/db/share-export.ts +++ b/db/share-export.ts @@ -1,8 +1,8 @@ import type { Pool } from 'pg'; -import { canvasWidgetsOf } from '../src/lib/chat/canvas.ts'; import type { SavedConversation } from '../src/lib/chat/history.ts'; import type { ShareMode } from '../src/lib/chat/shareMode.ts'; +import { slotCanvas, viewerCopy, type ShareSlot } from '../src/lib/chat/sharePayload.ts'; import type { ShareKind } from '../src/lib/chat/sharePrune.ts'; /** @@ -14,11 +14,7 @@ import type { ShareKind } from '../src/lib/chat/sharePrune.ts'; */ /** One data card of a share as dfo-apis holds it, in display order. */ -export type ExportSlot = { - component_id: string; - revision: number; - frame: { spec_id: string; content_version: string }; -}; +export type ExportSlot = ShareSlot; /** One line of the export. */ export type ExportLine = { @@ -62,73 +58,17 @@ export type Placement = { line: ExportLine } | { refusal: string }; type LineBase = Pick; -type StoredWidget = SavedConversation['widgets'][number]; - -const COMPONENT_ID = /^cmp_[A-Za-z0-9_-]{1,64}$/; -const FRAME_SPEC_ID = /^(spec|ops)_[A-Za-z0-9_-]+$/; const SHARE_ID = /^[A-Za-z0-9]{8}$/; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -// The slot of a data card whose `ref` names a component revision in -// dfo-apis' form and whose `frame` names a dataset or derived frame, or -// null when either is absent or malformed. -const slotOf = (widget: StoredWidget): ExportSlot | null => { - if (widget.kind === 'text') { - return null; - } - - const { ref, frame } = widget; - - if ( - !isRecord(ref) || - typeof ref.component_id !== 'string' || - !COMPONENT_ID.test(ref.component_id) || - !Number.isSafeInteger(ref.revision) || - ref.revision < 1 || - !isRecord(frame) || - typeof frame.spec_id !== 'string' || - !FRAME_SPEC_ID.test(frame.spec_id) || - typeof frame.content_version !== 'string' || - frame.content_version === '' - ) { - return null; - } - - return { - component_id: ref.component_id, - revision: ref.revision, - frame: { spec_id: frame.spec_id, content_version: frame.content_version }, - }; -}; - -// A data card in the payload with none of what it showed: where it sits, -// what shape it takes, and the index of the slot that draws it, if any. -const heldCard = (widget: StoredWidget, slot: number | undefined): Record => ({ - kind: widget.kind, - id: widget.id, - turn: widget.turn, - ...(widget.kind === 'chart' - ? { chartType: widget.chartType, horizontal: widget.horizontal } - : {}), - slot, -}); - const isCanvasState = (data: unknown): data is SavedConversation => isRecord(data) && Array.isArray(data.widgets) && Array.isArray(data.pinnedIds) && Array.isArray(data.removedIds); -// A stored copy as a viewer reads it: no owner's harness session and no -// inherited refs (src/lib/chat/sharePrune.ts, shareForViewer). -const viewerCopy = (data: Record): Record => ({ - ...data, - sessionId: null, - componentRefs: [], -}); - const placeSearch = (row: StoredShare, base: LineBase): Placement => { if (row.mode !== null && row.mode !== 'live' && row.mode !== 'snapshot') { return { refusal: `mode ${JSON.stringify(row.mode)} is not live or snapshot` }; @@ -187,40 +127,19 @@ const placeStudio = ( return { refusal: 'data has no widgets, pinnedIds and removedIds arrays' }; } - const saved = row.data; - const slots: ExportSlot[] = []; - const slotIndex = new Map(); - - // canvasWidgetsOf hands back the stored widget objects, so each slot is - // tied to its own widget even when two cards draw the same revision. - for (const widget of canvasWidgetsOf(saved)) { - if (widget.kind === 'text') { - continue; - } - - const slot = slotOf(widget as StoredWidget); + const slotted = slotCanvas(row.data, mode); - if (slot) { - slotIndex.set(widget as StoredWidget, slots.length); - slots.push(slot); - } else if (mode === 'live') { - return { - refusal: `live card ${widget.id} has no component ref and frame in dfo-apis' form`, - }; - } + if ('unslotted' in slotted) { + return { + refusal: `live card ${slotted.unslotted[0]} has no component ref in dfo-apis' form`, + }; } - // A live share shows none of the numbers stored with it, as the live - // share pages withhold them today (withholdCardData), so a data card - // the canvas does not show is held too, with no slot. - const widgets = saved.widgets.map((widget) => { - const slot = slotIndex.get(widget); - const held = slot !== undefined || (mode === 'live' && widget.kind !== 'text'); - - return held ? heldCard(widget, slot) : widget; - }); + const { slots, widgets } = slotted; - return { line: { ...base, kind, mode, slots, payload: { ...viewerCopy(saved), widgets } } }; + return { + line: { ...base, kind, mode, slots, payload: { ...viewerCopy(row.data), widgets } }, + }; }; /** Where one stored share lands in the export, or why it cannot. */ diff --git a/db/share-token-import.ts b/db/share-token-import.ts new file mode 100644 index 0000000..b12f89c --- /dev/null +++ b/db/share-token-import.ts @@ -0,0 +1,97 @@ +import { inArray } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { putShareToken } from '../src/lib/chat/shareTokens.ts'; +import { user } from './schema.ts'; + +/** + * The token import (scripts/import-share-tokens.ts): the write tokens + * dfo-apis' `import-shares` minted for signed-in owners' shares, one JSON + * line `{id, owner, write_token}` each, into `share_token`. A line whose + * token is already stored stores it again, so a second run of the same file + * changes nothing. + */ + +export type TokenLine = { id: string; owner: string; write_token: string }; + +const SHARE_ID = /^[A-Za-z0-9]{8}$/; +const WRITE_TOKEN = /^shw_\S+$/; + +const isTokenLine = (value: unknown): value is TokenLine => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const { id, owner, write_token: token } = value as Record; + + return ( + typeof id === 'string' && + SHARE_ID.test(id) && + typeof owner === 'string' && + owner !== '' && + typeof token === 'string' && + WRITE_TOKEN.test(token) + ); +}; + +/** The lines of a token file, or the 1-based numbers of the lines that are + * not `{id, owner, write_token}` with an 8-character id and a `shw_` token. + * Blank lines are skipped. */ +export const readTokenLines = (text: string): { lines: TokenLine[]; invalid: number[] } => { + const lines: TokenLine[] = []; + const invalid: number[] = []; + + text.split('\n').forEach((raw, index) => { + if (raw.trim() === '') { + return; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + + if (isTokenLine(parsed)) { + lines.push(parsed); + } else { + invalid.push(index + 1); + } + }); + + return { lines, invalid }; +}; + +/** What an import came to: how many tokens it stored, the shares whose + * owner is no longer a website user, and the shares whose token another + * user holds, which are left as they were. */ +export type TokenImport = { stored: number; ownerGone: string[]; heldByOther: string[] }; + +export const importShareTokens = async ( + db: Pick, + lines: TokenLine[], +): Promise => { + const owners = [...new Set(lines.map((line) => line.owner))]; + const present = owners.length + ? new Set( + (await db.select({ id: user.id }).from(user).where(inArray(user.id, owners))).map( + ({ id }) => id, + ), + ) + : new Set(); + const result: TokenImport = { stored: 0, ownerGone: [], heldByOther: [] }; + + for (const { id, owner, write_token: writeToken } of lines) { + if (!present.has(owner)) { + result.ownerGone.push(id); + } else if (await putShareToken(db, { shareId: id, userId: owner, writeToken })) { + result.stored += 1; + } else { + result.heldByOther.push(id); + } + } + + return result; +}; diff --git a/db/test/apis-shares.test.ts b/db/test/apis-shares.test.ts new file mode 100644 index 0000000..553df04 --- /dev/null +++ b/db/test/apis-shares.test.ts @@ -0,0 +1,174 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drizzle } from 'drizzle-orm/node-postgres'; + +import { + createOwnedShare, + ownerShareWrite, + type SharesBackend, +} from '../../src/lib/chat/apisShares.ts'; +import { putShareToken, readShareToken } from '../../src/lib/chat/shareTokens.ts'; +import { applyMigrations } from '../apply-migrations.ts'; +import { user } from '../schema.ts'; +import { createTestDatabase } from './harness.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); + +type Call = { url: string; method: string; token: string | null }; + +// A dfo-apis that answers each call with `answer`, recording the method, +// path, and the write token it was sent. +const backendOf = (answer: (call: Call) => Response) => { + const calls: Call[] = []; + const backend: SharesBackend = { + base: 'https://apis.example', + nameCaller: () => {}, + fetch: async (input, init) => { + const call = { + url: String(input).replace('https://apis.example', ''), + method: init?.method ?? 'GET', + token: new Headers(init?.headers).get('x-dfo-write-token'), + }; + + calls.push(call); + + return answer(call); + }, + }; + + return { backend, calls }; +}; + +const created = () => + Response.json({ id: 'Ab3dEf7h', write_token: 'shw_made', revision: 1 }, { status: 201 }); + +/** + * `createOwnedShare` and `ownerShareWrite` against the migrated tables as + * the runtime role and a stubbed dfo-apis: a signed-in maker's token is kept + * and sent back on their writes, a guest's is not kept, and a caller with no + * token, or whose token dfo-apis refuses, gets `not_found`. + */ +test('owner writes to shares held in dfo-apis', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const db = drizzle(database.runtimePool()); + + await db.insert(user).values([ + { id: 'owner', name: 'O', email: 'o@one.org' }, + { id: 'other', name: 'X', email: 'x@one.org' }, + ]); + + const body = { kind: 'canvas' as const, latest: true, title: 'T', cards: [], payload: {} }; + + await t.test("a signed-in maker's token is kept, and a guest's is not", async () => { + const { backend } = backendOf(created); + + assert.deepEqual(await createOwnedShare({ db, backend }, 'owner', body), { + id: 'Ab3dEf7h', + }); + assert.equal( + await readShareToken(db, { shareId: 'Ab3dEf7h', userId: 'owner' }), + 'shw_made', + ); + + await database.runtimePool().query('delete from share_token'); + await createOwnedShare({ db, backend }, null, body); + assert.equal( + (await database.runtimePool().query('select count(*)::int as n from share_token')) + .rows[0].n, + 0, + ); + }); + + await t.test("a switch to snapshot sends the owner's token and keeps it", async () => { + await putShareToken(db, { shareId: 'Ab3dEf7h', userId: 'owner', writeToken: 'shw_made' }); + + const { backend, calls } = backendOf(() => Response.json({ id: 'Ab3dEf7h' })); + const write = { shareId: 'Ab3dEf7h', userId: 'owner' }; + + assert.equal( + await ownerShareWrite({ db, backend }, { ...write, action: 'snapshot' }), + 'done', + ); + assert.deepEqual(calls, [ + { + url: '/api/presentation/shares/Ab3dEf7h/snapshot', + method: 'POST', + token: 'shw_made', + }, + ]); + assert.equal(await readShareToken(db, write), 'shw_made'); + }); + + await t.test('a caller with no token gets not_found and calls nothing', async () => { + const { backend, calls } = backendOf(() => new Response(null, { status: 204 })); + + assert.equal( + await ownerShareWrite( + { db, backend }, + { shareId: 'Ab3dEf7h', userId: 'other', action: 'delete' }, + ), + 'not_found', + ); + assert.equal(calls.length, 0); + }); + + await t.test('a delete sends the token, then drops it', async () => { + const { backend, calls } = backendOf(() => new Response(null, { status: 204 })); + const write = { shareId: 'Ab3dEf7h', userId: 'owner' }; + + assert.equal( + await ownerShareWrite({ db, backend }, { ...write, action: 'delete' }), + 'done', + ); + assert.deepEqual(calls, [ + { url: '/api/presentation/shares/Ab3dEf7h', method: 'DELETE', token: 'shw_made' }, + ]); + assert.equal(await readShareToken(db, write), null); + }); + + await t.test('a token dfo-apis refuses, or a share it no longer has, is dropped', async () => { + for (const [status, code] of [ + [403, 'write_token_invalid'], + [404, 'not_found'], + ] as const) { + await putShareToken(db, { + shareId: 'Ab3dEf7h', + userId: 'owner', + writeToken: 'shw_old', + }); + + const { backend } = backendOf(() => Response.json({ code }, { status })); + const write = { shareId: 'Ab3dEf7h', userId: 'owner' }; + + assert.equal( + await ownerShareWrite({ db, backend }, { ...write, action: 'snapshot' }), + 'not_found', + ); + assert.equal(await readShareToken(db, write), null, code); + } + }); + + await t.test('another refusal throws and keeps the token', async () => { + await putShareToken(db, { shareId: 'Ab3dEf7h', userId: 'owner', writeToken: 'shw_made' }); + + const { backend } = backendOf(() => + Response.json({ code: 'service_unavailable', retryable: true }, { status: 503 }), + ); + const write = { shareId: 'Ab3dEf7h', userId: 'owner' }; + + await assert.rejects(ownerShareWrite({ db, backend }, { ...write, action: 'delete' }), { + name: 'SharesError', + code: 'service_unavailable', + }); + assert.equal(await readShareToken(db, write), 'shw_made'); + }); +}); diff --git a/db/test/share-export.test.ts b/db/test/share-export.test.ts index ea5c6c5..304db01 100644 --- a/db/test/share-export.test.ts +++ b/db/test/share-export.test.ts @@ -409,21 +409,14 @@ test('a share the export cannot place is refused with its reason', () => { [{ mode: 'pinned' }, 'mode "pinned" is not live or snapshot'], [{ kind: 'conversation', mode: 'live' }, 'a conversation share cannot be live'], [{ data: { widgets: [] } }, 'data has no widgets, pinnedIds and removedIds arrays'], - [ - { data: canvas([table('c1', 1)]) }, - "live card c1 has no component ref and frame in dfo-apis' form", - ], - [ - { data: canvas([table('c1', 1, { ref: ref(1) })]) }, - "live card c1 has no component ref and frame in dfo-apis' form", - ], + [{ data: canvas([table('c1', 1)]) }, "live card c1 has no component ref in dfo-apis' form"], [ { data: canvas([ table('c1', 1, { ref: { component_id: '../x', revision: 1 }, frame: FRAME }), ]), }, - "live card c1 has no component ref and frame in dfo-apis' form", + "live card c1 has no component ref in dfo-apis' form", ], [ { @@ -449,12 +442,36 @@ test('a share the export cannot place is refused with its reason', () => { for (const [overrides, refusal] of cases) { assert.deepEqual(placeShare(stored(overrides)), { refusal }, JSON.stringify(overrides)); } +}); - // A snapshot keeps a card whose ref has no frame as stored, inline. - const snapshot = placeShare( - stored({ mode: 'snapshot', data: canvas([table('c1', 1, { ref: ref(1) })]) }), - ); +test('a card with a ref and no frame the website holds gets a slot with a null frame', () => { + for (const mode of ['live', 'snapshot']) { + const placed = placeShare( + stored({ + mode, + data: canvas([ + table('c1', 1, { ref: ref(1) }), + table('c2', 1, { ref: ref(2), frame: { spec_id: 'spec_aid' } }), + ]), + }), + ); - assert.ok('line' in snapshot); - assert.deepEqual(snapshot.line.slots, []); + assert.ok('line' in placed, mode); + assert.deepEqual( + placed.line.slots, + [ + { ...ref(1), frame: null }, + { ...ref(2), frame: null }, + ], + mode, + ); + assert.deepEqual( + placed.line.payload.widgets, + [ + { kind: 'table', id: 'c1', turn: 1, slot: 0 }, + { kind: 'table', id: 'c2', turn: 1, slot: 1 }, + ], + mode, + ); + } }); diff --git a/db/test/share-mode.test.ts b/db/test/share-mode.test.ts index dde778f..81f16fc 100644 --- a/db/test/share-mode.test.ts +++ b/db/test/share-mode.test.ts @@ -4,13 +4,12 @@ import test from 'node:test'; import { eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/node-postgres'; import type { SavedConversation } from '../../src/lib/chat/history.ts'; -import { GUEST_SHARES, isLiveStudioShare, searchShareMode } from '../../src/lib/chat/shareMode.ts'; +import { LIVE_CARD_TITLE } from '../../src/lib/chat/sharePayload.ts'; +import { isLiveStudioShare, searchShareMode } from '../../src/lib/chat/shareMode.ts'; import { deleteOwnedShare, insertStudioShare, - LIVE_CARD_TITLE, snapshotOwnedShare, - spendGuestShare, } from '../../src/lib/chat/shareWrites.ts'; import { applyMigrations } from '../apply-migrations.ts'; import { share, user } from '../schema.ts'; @@ -200,17 +199,6 @@ test('Studio share writes', async (t) => { assert.equal((await stored(id))?.mode, 'live'); }); - await t.test('a guest network gets GUEST_SHARES a day, counted by /64', async () => { - const spends = await Promise.all( - Array.from({ length: GUEST_SHARES + 1 }, (_, i) => - spendGuestShare(db, `2001:db8:1:1::${i + 1}`), - ), - ); - - assert.equal(spends.filter(Boolean).length, GUEST_SHARES); - assert.equal(await spendGuestShare(db, '2001:db8:1:2::1'), true); - }); - await t.test('a search share has no owner writes', async () => { await db.insert(share).values([ { diff --git a/db/test/share-token.test.ts b/db/test/share-token.test.ts index 66c21d5..489ba1c 100644 --- a/db/test/share-token.test.ts +++ b/db/test/share-token.test.ts @@ -1,14 +1,21 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; import assert from 'node:assert/strict'; import test from 'node:test'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { putShareToken, readShareToken } from '../../src/lib/chat/shareTokens.ts'; +import { deleteShareToken, putShareToken, readShareToken } from '../../src/lib/chat/shareTokens.ts'; import { applyMigrations } from '../apply-migrations.ts'; +import { readTokenLines } from '../share-token-import.ts'; import { user } from '../schema.ts'; import { createTestDatabase } from './harness.ts'; const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); +const SCRIPT = path.resolve(import.meta.dirname, '../../scripts/import-share-tokens.ts'); +const run = promisify(execFile); /** * `shareTokens.ts` against the migrated tables as the runtime role: a user @@ -45,7 +52,123 @@ test('share write tokens', async (t) => { assert.equal(await putShareToken(db, { ...other, writeToken: 'shw_theirs' }), false); assert.equal(await readShareToken(db, owner), 'shw_two'); + await deleteShareToken(db, other); + assert.equal(await readShareToken(db, owner), 'shw_two'); + await deleteShareToken(db, owner); + assert.equal(await readShareToken(db, owner), null); + assert.equal(await putShareToken(db, { ...owner, writeToken: 'shw_two' }), true); + await database.runtimePool().query(`delete from "user" where id = 'owner'`); assert.equal(await putShareToken(db, { ...other, writeToken: 'shw_theirs' }), true); assert.equal(await readShareToken(db, other), 'shw_theirs'); }); + +/** + * scripts/import-share-tokens.ts over a migrated database: a run stores each + * signed-in owner's token, a second run of the same file stores the same + * rows again, a share whose owner is gone is skipped, and a file with an + * unreadable line stores nothing. + */ +test("the token import stores each owner's token once, however often it runs", async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const pool = database.runtimePool(); + + await pool.query( + `insert into "user" (id, name, email) values ('owner', 'O', 'o@one.org'), ('other', 'X', 'x@one.org')`, + ); + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-tokens-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const file = path.join(dir, 'tokens.jsonl'); + const lines = [ + { id: 'Ab3dEf7h', owner: 'owner', write_token: 'shw_a' }, + { id: 'Zy9xWv8u', owner: 'other', write_token: 'shw_b' }, + { id: 'Gone0001', owner: 'deleted', write_token: 'shw_c' }, + ]; + + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`); + + const env = { + ...process.env, + DATABASE_URL: `postgres://${database.runtimeUser}:${database.runtimePassword}@${process.env.PGHOST ?? 'localhost'}:${process.env.PGPORT ?? 5433}/${database.databaseName}`, + }; + const tokens = async () => + ( + await pool.query<{ share_id: string; user_id: string; write_token: string }>( + 'select share_id, user_id, write_token from share_token order by share_id', + ) + ).rows.map(({ share_id: id, user_id: owner, write_token: token }) => [id, owner, token]); + const stored = [ + ['Ab3dEf7h', 'owner', 'shw_a'], + ['Zy9xWv8u', 'other', 'shw_b'], + ]; + + const first = await run(process.execPath, [SCRIPT, '--in', file], { env }); + + assert.match(first.stderr, /share Gone0001: its owner is no longer a user; skipped/); + assert.match(first.stderr, /^2 tokens stored, 1 skipped, 0 held by another user$/m); + assert.deepEqual(await tokens(), stored); + + await run(process.execPath, [SCRIPT, '--in', file], { env }); + + assert.deepEqual(await tokens(), stored); + + // A token another user holds is left as it was and fails the run. + const clash = path.join(dir, 'clash.jsonl'); + + await fs.writeFile(clash, `${JSON.stringify({ ...lines[0], owner: 'other' })}\n`); + + const refusedClash = await run(process.execPath, [SCRIPT, '--in', clash], { env }).catch( + (error: { code: number; stderr: string }) => error, + ); + + assert.equal('code' in refusedClash && refusedClash.code, 1); + assert.equal( + await readShareToken(drizzle(pool), { shareId: 'Ab3dEf7h', userId: 'owner' }), + 'shw_a', + ); + + // One unreadable line refuses the whole file. + const bad = path.join(dir, 'bad.jsonl'); + + await fs.writeFile( + bad, + `${JSON.stringify({ id: 'New00001', owner: 'owner', write_token: 'shw_d' })}\n{"id": "short"}\n`, + ); + + const refused = await run(process.execPath, [SCRIPT, '--in', bad], { env }).catch( + (error: { code: number; stderr: string }) => error, + ); + + assert.equal('code' in refused && refused.code, 1); + assert.match(refused.stderr, /^line 2: /m); + assert.equal( + await readShareToken(drizzle(pool), { shareId: 'New00001', userId: 'owner' }), + null, + ); +}); + +test('a token file line needs an 8-character id, an owner and a shw_ token', () => { + const text = [ + JSON.stringify({ id: 'Ab3dEf7h', owner: 'u', write_token: 'shw_a' }), + '', + JSON.stringify({ id: 'Ab3dEf7', owner: 'u', write_token: 'shw_a' }), + JSON.stringify({ id: 'Ab3dEf7h', owner: '', write_token: 'shw_a' }), + JSON.stringify({ id: 'Ab3dEf7h', owner: 'u', write_token: 'cmw_a' }), + 'not json', + ].join('\n'); + + assert.deepEqual(readTokenLines(text), { + lines: [{ id: 'Ab3dEf7h', owner: 'u', write_token: 'shw_a' }], + invalid: [3, 4, 5, 6], + }); +}); diff --git a/docs/share-export.md b/docs/share-export.md index 1987046..0a6a96a 100644 --- a/docs/share-export.md +++ b/docs/share-export.md @@ -20,18 +20,18 @@ Each line is one JSON object with these members, and lines are ordered by `creat - `owner`: the website user id of the share's maker, or null for a guest's share. A guest's share imports with no write token, and nobody can change or delete it. - `created_at`: the stored moment in UTC to the microsecond, as `2026-09-01T10:00:00.000001Z`. - `title`: the stored title. -- `slots`: the data cards dfo-apis holds, in display order, each `{"component_id": "cmp_…", "revision": 3, "frame": {"spec_id": "spec_…" or "ops_…", "content_version": "…"}}`. Only canvas and card shares have slots. Search and conversation shares have `[]`. +- `slots`: the data cards dfo-apis holds, in display order, each `{"component_id": "cmp_…", "revision": 3, "frame": {"spec_id": "spec_…" or "ops_…", "content_version": "…"}}`. `frame` is `null` when the stored card names no frame in that form, and the import reads the frame from the component revision. Only canvas and card shares have slots. Search and conversation shares have `[]`. - `payload`: website-owned JSON that dfo-apis stores as given and does not read. It holds what the website needs to render the share beyond its slots. ## Where each stored share lands -| Stored share | `mode` | `slots` | `payload` | -| --------------------------------------- | ---------- | ------------------------------------------------------- | ------------------------------------------------------------------- | -| Search, mode `live` or null | `live` | `[]` | `query` and `parts` | -| Search, mode `snapshot` | `snapshot` | `[]` | `query` and `parts`, each part with its `content_version` | -| Canvas or card, mode `live` | `live` | every data card on the canvas | the stored copy, slotted cards held | -| Canvas or card, mode `snapshot` or null | `snapshot` | every data card on the canvas with a complete reference | the stored copy, slotted cards held, the other data cards as stored | -| Conversation, mode `snapshot` or null | `snapshot` | `[]` | the stored copy | +| Stored share | `mode` | `slots` | `payload` | +| --------------------------------------- | ---------- | -------------------------------------------------------- | ------------------------------------------------------------------- | +| Search, mode `live` or null | `live` | `[]` | `query` and `parts` | +| Search, mode `snapshot` | `snapshot` | `[]` | `query` and `parts`, each part with its `content_version` | +| Canvas or card, mode `live` | `live` | every data card on the canvas | the stored copy, slotted cards held | +| Canvas or card, mode `snapshot` or null | `snapshot` | every data card on the canvas with a component reference | the stored copy, slotted cards held, the other data cards as stored | +| Conversation, mode `snapshot` or null | `snapshot` | `[]` | the stored copy | A search share's `query` is `share.data.query`, or the title when the row has none, as `getSearchShare` reads it. Its `parts` are the share's `share_ref` rows in `part_index` order, each with the members `part_index`, `spec_id`, `identity`, `entity_groups`, `variable_text`, `content_version`, `artifact_digest`, `operations`, and `assumptions`. A live search share's parts have null `content_version` and `artifact_digest` and replay their definitions. A snapshot made before the website stored `artifact_digest` has a null digest and keeps its `content_version`. @@ -39,11 +39,11 @@ The stored copy of a canvas, card, or conversation share is its `share.data`, a ## Slots -The data cards on a canvas are the cards `canvasWidgetsOf` (`src/lib/chat/canvas.ts`) shows, other than text cards, in that order: the stored `widgets` the canvas displays, in stored order. A card share holds one. A data card has a complete reference when its `ref` names a component revision as `cmp_` plus up to 64 letters, digits, `_` or `-`, with a whole revision of 1 or more, and its `frame` names a `spec_` or `ops_` frame with a non-empty content version. +The data cards on a canvas are the cards `canvasWidgetsOf` (`src/lib/chat/canvas.ts`) shows, other than text cards, in that order: the stored `widgets` the canvas displays, in stored order. A card share holds one. A data card has a component reference when its `ref` names a component revision as `cmp_` plus up to 64 letters, digits, `_` or `-`, with a whole revision of 1 or more. Its slot's `frame` is the card's `frame` when that names a `spec_` or `ops_` frame with a non-empty content version, and `null` otherwise. `slotCanvas` in `src/lib/chat/sharePayload.ts` places the cards, for this export and for every share the website makes in dfo-apis. Each such card becomes one slot. In the payload's `widgets` the card is held: it keeps `kind`, `id`, `turn`, and for a chart `chartType` and `horizontal`, and gains `slot`, the index of its slot in `slots`. Its numbers, title, source and description come from the slot's component revision. Two cards drawing the same revision get a slot each. -A snapshot's data card without a complete reference, which includes every card stored before cards carried a `ref`, stays in `widgets` as stored, with the numbers it showed inline, and has no slot. Widgets the canvas does not show, such as removed cards and older turns' unpinned cards, stay in `widgets` with no slot, so the payload shows the same cards as the stored copy. In a snapshot they stay as stored. In a live share every data card is held, shown or not, because live share pages show none of the numbers stored with the share. +A snapshot's data card without a component reference, which includes every card stored before cards carried a `ref`, stays in `widgets` as stored, with the numbers it showed inline, and has no slot. Widgets the canvas does not show, such as removed cards and older turns' unpinned cards, stay in `widgets` with no slot, so the payload shows the same cards as the stored copy. In a snapshot they stay as stored. In a live share every data card is held, shown or not, because live share pages show none of the numbers stored with the share. ## Refused shares @@ -55,7 +55,7 @@ The script refuses a share whose: - kind is `conversation` and mode is `live`; - kind is `conversation`, `canvas`, or `card` and it has `share_ref` rows; - data, for a canvas or card, lacks `widgets`, `pinnedIds`, or `removedIds` arrays; -- canvas or card is live and a data card on it has no complete reference; +- canvas or card is live and a data card on it has no component reference; - search snapshot has a part with no `content_version`. ## Not exported diff --git a/scripts/export-shares.ts b/scripts/export-shares.ts index d881b6b..f6b5953 100644 --- a/scripts/export-shares.ts +++ b/scripts/export-shares.ts @@ -12,15 +12,8 @@ import fs from 'node:fs/promises'; import { parseArgs } from 'node:util'; -import type { Pool } from 'pg'; - -import { - buildRuntimePool, - connectCloudSqlRuntime, - resolveRuntimeConnection, - setCloudSqlRuntimeStream, -} from '../db/connection.ts'; import { exportShares, readShares } from '../db/share-export.ts'; +import { openPool } from './open-pool.ts'; const USAGE = `usage: node scripts/export-shares.ts --since [--out ] @@ -31,30 +24,6 @@ const USAGE = `usage: node scripts/export-shares.ts --since Promise }> => { - const connection = resolveRuntimeConnection(); - - if (connection.kind === 'url') { - const pool = buildRuntimePool(process.env, { max: 1 }); - - return { pool, close: () => pool.end() }; - } - - const { connector, stream } = await connectCloudSqlRuntime(connection); - - setCloudSqlRuntimeStream(stream); - - const pool = buildRuntimePool(process.env, { max: 1 }); - - return { - pool, - close: async () => { - await pool.end(); - connector.close(); - }, - }; -}; - const main = async (): Promise => { const { values } = parseArgs({ options: { diff --git a/scripts/import-share-tokens.ts b/scripts/import-share-tokens.ts new file mode 100644 index 0000000..55a4657 --- /dev/null +++ b/scripts/import-share-tokens.ts @@ -0,0 +1,80 @@ +// Stores the write tokens dfo-apis' `import-shares` minted for signed-in +// owners' shares in share_token, so each owner can still switch and delete +// their shares once dfo-apis holds them. db/share-token-import.ts holds the +// reading and the writes. +// +// node scripts/import-share-tokens.ts --in share-tokens.jsonl +// +// The file is the 0600 JSON lines `import-shares` writes, one +// {"id", "owner", "write_token"} per line. It reads the database from +// DATABASE_URL, or from the WEBSITE_DB_* Cloud SQL variables the runtime +// uses. A run of the same file again changes nothing. A file with a line it +// cannot read is refused whole, with each such line named on stderr, and +// nothing is stored. A share whose owner is no longer a user is skipped, and +// one whose token another user holds is left as it was and fails the run. +import fs from 'node:fs/promises'; +import { parseArgs } from 'node:util'; + +import { drizzle } from 'drizzle-orm/node-postgres'; + +import { importShareTokens, readTokenLines } from '../db/share-token-import.ts'; +import { openPool } from './open-pool.ts'; + +const USAGE = `usage: node scripts/import-share-tokens.ts --in + + --in the token file dfo-apis' import-shares wrote`; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { in: { type: 'string' }, help: { type: 'boolean', default: false } }, + }); + + if (values.help || !values.in) { + console.error(USAGE); + process.exitCode = values.help ? 0 : 2; + + return; + } + + const { lines, invalid } = readTokenLines(await fs.readFile(values.in, 'utf8')); + + if (invalid.length) { + for (const line of invalid) { + console.error(`line ${line}: not {"id", "owner", "write_token"} with a shw_ token`); + } + + console.error(`${invalid.length} lines cannot be read; nothing stored.`); + process.exitCode = 1; + + return; + } + + const { pool, close } = await openPool(); + + try { + const { stored, ownerGone, heldByOther } = await importShareTokens(drizzle(pool), lines); + + for (const id of ownerGone) { + console.error(`share ${id}: its owner is no longer a user; skipped`); + } + + for (const id of heldByOther) { + console.error(`share ${id}: another user holds its token; left as it was`); + } + + console.error( + `${stored} tokens stored, ${ownerGone.length} skipped, ${heldByOther.length} held by another user`, + ); + + if (heldByOther.length) { + process.exitCode = 1; + } + } finally { + await close(); + } +}; + +main().catch((error: unknown) => { + console.error('ERROR:', error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/scripts/open-pool.ts b/scripts/open-pool.ts new file mode 100644 index 0000000..b1e3e51 --- /dev/null +++ b/scripts/open-pool.ts @@ -0,0 +1,37 @@ +import type { Pool } from 'pg'; + +import { + buildRuntimePool, + connectCloudSqlRuntime, + resolveRuntimeConnection, + setCloudSqlRuntimeStream, +} from '../db/connection.ts'; + +/** + * A one-connection pool for a maintenance script, on DATABASE_URL or on the + * WEBSITE_DB_* Cloud SQL variables the runtime uses, and the function that + * closes it. + */ +export const openPool = async (): Promise<{ pool: Pool; close: () => Promise }> => { + const connection = resolveRuntimeConnection(); + + if (connection.kind === 'url') { + const pool = buildRuntimePool(process.env, { max: 1 }); + + return { pool, close: () => pool.end() }; + } + + const { connector, stream } = await connectCloudSqlRuntime(connection); + + setCloudSqlRuntimeStream(stream); + + const pool = buildRuntimePool(process.env, { max: 1 }); + + return { + pool, + close: async () => { + await pool.end(); + connector.close(); + }, + }; +}; diff --git a/src/app/api/share/[id]/cards/[cardId]/data/route.ts b/src/app/api/share/[id]/cards/[cardId]/data/route.ts index 8c96ae9..9db13b8 100644 --- a/src/app/api/share/[id]/cards/[cardId]/data/route.ts +++ b/src/app/api/share/[id]/cards/[cardId]/data/route.ts @@ -1,21 +1,16 @@ import type { NextRequest } from 'next/server'; -import { apisFetch } from '@/lib/apisAuth'; -import { db } from '@/lib/db'; -import { dataResponse } from '@/lib/studio/liveShare'; +import { slotDepsFor } from '@/lib/chat/shareStore'; +import { slotDataResponse } from '@/lib/studio/slotCards'; type Context = { params: Promise<{ id: string; cardId: string }> }; // GET /api/share//cards//data?format=&bundle= → a file -// The rows behind a live share's card as its last refresh drew it, streamed -// from dfo-apis. Public, like the link itself. See src/lib/studio/liveShare. +// The rows behind a live share's card on the frame its slot serves now, +// streamed from dfo-apis. Public, like the link itself. See +// src/lib/studio/slotCards. export const GET = async (request: NextRequest, { params }: Context) => { const { id, cardId } = await params; - return dataResponse( - { db, env: process.env, fetch: apisFetch }, - id, - cardId, - request.nextUrl.searchParams, - ); + return slotDataResponse(slotDepsFor(request.headers), id, cardId, request.nextUrl.searchParams); }; diff --git a/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts b/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts index 0e517b2..04ff56e 100644 --- a/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts +++ b/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts @@ -1,16 +1,14 @@ -import { apisFetch } from '@/lib/apisAuth'; -import { db } from '@/lib/db'; -import { refreshResponse } from '@/lib/studio/liveShare'; +import { slotDepsFor } from '@/lib/chat/shareStore'; +import { slotRefreshResponse } from '@/lib/studio/slotCards'; type Context = { params: Promise<{ id: string; cardId: string }> }; -// POST /api/share//cards//refresh → { status, card?, retry_after_s?, code? } -// One data card of a live canvas or card share on current data. Public, like -// the link itself. The server calls dfo-apis or dfo-ai with a credential -// that stays here. The route sits outside the /api/studio proxy, which -// forwards any path. See src/lib/studio/liveShare. -export const POST = async (_request: Request, { params }: Context) => { +// POST /api/share//cards//refresh → { status, card?, code?, retryable? } +// One data card of a live canvas or card share, drawn on the frame its slot +// in dfo-apis serves now. Public, like the link itself. See +// src/lib/studio/slotCards. +export const POST = async (request: Request, { params }: Context) => { const { id, cardId } = await params; - return refreshResponse({ db, env: process.env, fetch: apisFetch }, id, cardId); + return slotRefreshResponse(slotDepsFor(request.headers), id, cardId); }; diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts index 6c739c3..c8fd9e8 100644 --- a/src/app/api/share/[id]/route.ts +++ b/src/app/api/share/[id]/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; import { getUser } from '@/lib/auth/session'; -import { deleteShare, snapshotShare } from '@/lib/chat/shareStore'; -import type { OwnerWriteResult } from '@/lib/chat/shareWrites'; +import { SHARE_ID, SharesError } from '@/lib/chat/apisShares'; +import { writeOwnShare } from '@/lib/chat/shareStore'; import { missingProblem, problem, @@ -13,12 +13,10 @@ import { getSearchShare } from '@/lib/search/searchShare'; type Context = { params: Promise<{ id: string }> }; -const SHARE_ID = /^[A-Za-z0-9]{8}$/; - const noSuchShare = (): Response => missingProblem('There is no such share.'); // GET /api/share/ → a search share's references ({ mode, query, refs }). -// Public, like the link itself; conversation shares render server-side on +// Public, like the link itself; Studio shares render server-side on // /s/ and never come through here. export const GET = async (_request: Request, { params }: Context) => { const { id } = await params; @@ -31,12 +29,14 @@ export const GET = async (_request: Request, { params }: Context) => { return NextResponse.json(found, { headers: { 'Cache-Control': 'no-store' } }); }; -// An owner's write to their own Studio share. A share that does not exist -// and one that belongs to someone else both answer 404, so the answer does +// An owner's write to their own share in dfo-apis, with the write token the +// site kept when they made it. A share that does not exist, one the caller +// holds no token for, and a guest's share all answer 404, so the answer does // not reveal which ids are taken. const ownerWrite = async ( + request: Request, { params }: Context, - write: (id: string, userId: string) => Promise, + action: 'snapshot' | 'delete', done: (id: string) => Response, ) => { const user = await getUser(); @@ -46,27 +46,39 @@ const ownerWrite = async ( } const { id } = await params; - const result = SHARE_ID.test(id) ? await write(id, user.id) : 'not_found'; - if (result === 'not_found') { - return noSuchShare(); - } + try { + const result = await writeOwnShare(request.headers, { + shareId: id, + userId: user.id, + action, + }); + + return result === 'done' ? done(id) : noSuchShare(); + } catch (error) { + if (!(error instanceof SharesError)) { + throw error; + } + + console.error('share: dfo-apis refused an owner write', { + action, + code: error.code, + status: error.status, + }); - if (result === 'unsupported_kind') { return problem({ - status: 400, - code: 'unsupported_kind', - detail: 'Only a conversation, canvas, or card share can be changed here.', - retryable: false, + status: 502, + code: 'transient_unavailable', + detail: 'The share could not be changed right now. Try again in a minute.', + retryable: true, }); } - - return done(id); }; // PATCH /api/share/ { mode: 'snapshot' } → { id, mode } -// Switches the caller's live canvas or card share to snapshot. A snapshot -// cannot be switched to live here. +// Switches the caller's live share to snapshot under the same URLs, keeping +// the data each card shows now. A snapshot answers as it is. A snapshot +// cannot be switched to live. export const PATCH = async (request: Request, context: Context) => { const body = (await request.json().catch(() => null)) as { mode?: unknown } | null; @@ -74,10 +86,12 @@ export const PATCH = async (request: Request, context: Context) => { return requestInvalidProblem('A share can only be switched to `snapshot`.', '/mode'); } - return ownerWrite(context, snapshotShare, (id) => NextResponse.json({ id, mode: 'snapshot' })); + return ownerWrite(request, context, 'snapshot', (id) => + NextResponse.json({ id, mode: 'snapshot' }), + ); }; // DELETE /api/share/ → 204 -// Removes the caller's conversation, canvas, or card share. -export const DELETE = async (_request: Request, context: Context) => - ownerWrite(context, deleteShare, () => new NextResponse(null, { status: 204 })); +// Takes down the caller's share, of any kind. +export const DELETE = async (request: Request, context: Context) => + ownerWrite(request, context, 'delete', () => new NextResponse(null, { status: 204 })); diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index 48e4a52..acd98a5 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -2,9 +2,10 @@ import { after, NextResponse, type NextRequest } from 'next/server'; import { getUser } from '@/lib/auth/session'; -import { GUEST_SHARES, readStudioShareRequest, shareCreateProps } from '@/lib/chat/shareMode'; -import { createShare, liveShareRefusalFor, spendGuestShare } from '@/lib/chat/shareStore'; -import { clientIp } from '@/lib/proxy/forward'; +import { SharesError } from '@/lib/chat/apisShares'; +import { studioShareBody } from '@/lib/chat/sharePayload'; +import { readStudioShareRequest, shareCreateProps } from '@/lib/chat/shareMode'; +import { makeShare } from '@/lib/chat/shareStore'; import { readBoundedText } from '@/lib/proxy/boundedBody'; import { problem, requestInvalidProblem } from '@/lib/proxy/problem'; import { track } from '@/lib/events/track'; @@ -14,10 +15,35 @@ import { readSearchShareRequest } from '@/lib/search/searchShareRequest'; // The largest share request read, in bytes; a larger one gets 413. const MAX_SHARE_BODY = 10_000_000; +// What a refusal of dfo-apis' share create says to the browser. A spent +// share allowance (counted by dfo-apis per network address) is 429 +// `share_limit`, with dfo-apis' Retry-After. Everything else is a fault the +// sharer cannot fix, 502, retryable when dfo-apis said so. +const sharesRefusal = (error: SharesError): Response => { + if (error.code === 'allowance_exhausted') { + return problem({ + status: 429, + code: 'share_limit', + detail: "This network has made today's shares. Try again tomorrow.", + retryable: true, + headers: error.retryAfterS ? { 'Retry-After': String(error.retryAfterS) } : undefined, + }); + } + + console.error('share: dfo-apis refused the share', { code: error.code, status: error.status }); + + return problem({ + status: 502, + code: 'transient_unavailable', + detail: 'The share could not be made right now. Try again in a minute.', + retryable: error.retryable || error.status === undefined || error.status >= 500, + }); +}; + // POST /api/share { kind: 'search', mode, query, parts } → { id } // A search result's link: dataset references only, no numbers. The search -// page is public, so an account is optional here (the share is simply -// unowned without one). +// page is public, so an account is optional here. A signed-in sharer's +// write token is kept; a guest's share stays as made. const shareSearch = async (body: any, headers: Headers) => { const request = readSearchShareRequest(body); @@ -31,12 +57,17 @@ const shareSearch = async (body: any, headers: Headers) => { try { created = await createSearchShare( + headers, user?.id ?? null, request.mode, request.query, request.parts, ); } catch (error) { + if (error instanceof SharesError) { + return sharesRefusal(error); + } + // The platform refused the register/mint — say which, don't store a // link that can't open. The log names the datasets and the cause. const { message, cause } = error as Error; @@ -71,14 +102,16 @@ const shareSearch = async (body: any, headers: Headers) => { }; // POST /api/share { kind, conversation, cardId?, mode? } → { id, title } -// A pruned copy of what the caller is looking at, under a short public id. -// An account is optional. A guest's share has no owner, so nobody can switch -// or remove it: a guest gets snapshots only (403 `live_needs_account`), and -// GUEST_SHARES a day per network address (429 `guest_limit`). +// A pruned copy of what the caller is looking at, made in dfo-apis +// (src/lib/chat/sharePayload, src/lib/chat/apisShares) under a short public +// id. An account is optional. A signed-in sharer's write token is kept, so +// they can switch the share to a snapshot or remove it later; a guest's +// share, live or snapshot, stays as made. dfo-apis counts shares per +// network address (429 `share_limit`). // `mode` is 'live' or 'snapshot' for a canvas or a card and 'snapshot' for a // conversation. A request that names none stores a snapshot. A live share is -// refused when its data cards cannot all be refreshed (`liveShareRefusal` in -// src/lib/studio/liveShare), and the refusal names the cards in the way. +// refused when a data card it shows names no component revision +// (400 `live_cards_blocked`, naming the cards). // Every refusal is a problem document (src/lib/proxy/problem). export const POST = async (request: NextRequest) => { const text = await readBoundedText(request.body, MAX_SHARE_BODY).catch(() => ''); @@ -110,36 +143,37 @@ export const POST = async (request: NextRequest) => { return read; } - const user = await getUser(); + const share = studioShareBody(read); - if (!user) { - if (read.mode === 'live') { - return problem({ - status: 403, - code: 'live_needs_account', - detail: 'Sign in to make a live share. Without an account, a share is a snapshot.', - retryable: false, - }); - } + if ('unslotted' in share) { + return problem({ + status: 400, + code: 'live_cards_blocked', + detail: 'Some data cards cannot show live data, so the share cannot be live.', + retryable: false, + extra: { cards: share.unslotted.map((id) => ({ id, reason: 'missing_ref' })) }, + }); + } - if (!(await spendGuestShare(clientIp(request)))) { - return problem({ - status: 429, - code: 'guest_limit', - detail: `This network has made today's ${GUEST_SHARES} guest shares. Sign in to make more.`, - retryable: false, - }); - } + // A card saved before cards kept their revision has nothing dfo-apis + // can draw, and a card share takes at least one card. + if (share.kind === 'card' && share.cards.length === 0) { + return requestInvalidProblem('This card has no saved revision, so it cannot be shared.'); } - const refusal = read.mode === 'live' ? await liveShareRefusalFor(read) : null; + const user = await getUser(); + let created: { id: string }; + + try { + created = await makeShare(request.headers, user?.id ?? null, share); + } catch (error) { + if (error instanceof SharesError) { + return sharesRefusal(error); + } - if (refusal) { - return refusal; + throw error; } - const created = await createShare({ userId: user?.id ?? null, ...read }); - // Recorded after the response, so the link does not wait on analytics. after(() => track( @@ -149,5 +183,5 @@ export const POST = async (request: NextRequest) => { ), ); - return NextResponse.json(created); + return NextResponse.json({ id: created.id, title: share.title }); }; diff --git a/src/app/embed/[id]/page.tsx b/src/app/embed/[id]/page.tsx index 9ba81ca..f8083ab 100644 --- a/src/app/embed/[id]/page.tsx +++ b/src/app/embed/[id]/page.tsx @@ -4,10 +4,10 @@ import { notFound } from 'next/navigation'; import { after } from 'next/server'; import EmbedView from '@/components/organisms/EmbedView/EmbedView'; -import { getShare } from '@/lib/chat/shareStore'; +import { getShare, slotDepsFor } from '@/lib/chat/shareStore'; import { refererHost } from '@/lib/events/referer'; import { track } from '@/lib/events/track'; -import { sharedViewOf } from '@/lib/studio/liveCards'; +import { sharedViewOfRecord } from '@/lib/studio/slotCards'; type EmbedPageProps = { params: Promise<{ id: string }> }; @@ -17,14 +17,15 @@ export const generateMetadata = async ({ params }: EmbedPageProps): Promise { const { id } = await params; const row = await getShare(id); @@ -40,7 +41,7 @@ const EmbedPage = async ({ params }: EmbedPageProps) => { const host = refererHost(incoming.get('referer')); after(() => track('embed.view', { shareId: id, kind: row.kind, host }, { headers: incoming })); - return ; + return ; }; export default EmbedPage; diff --git a/src/app/s/[id]/page.tsx b/src/app/s/[id]/page.tsx index 9ef5e96..3b1af2f 100644 --- a/src/app/s/[id]/page.tsx +++ b/src/app/s/[id]/page.tsx @@ -4,11 +4,10 @@ import { notFound, redirect } from 'next/navigation'; import { after } from 'next/server'; import Studio from '@/components/organisms/Studio/Studio'; -import { isLiveStudioShare, searchShareMode, type ShareMode } from '@/lib/chat/shareMode'; -import { getShare } from '@/lib/chat/shareStore'; +import { getShare, slotDepsFor } from '@/lib/chat/shareStore'; import { refererHost } from '@/lib/events/referer'; import { track } from '@/lib/events/track'; -import { sharedViewOf } from '@/lib/studio/liveCards'; +import { sharedViewOfRecord } from '@/lib/studio/slotCards'; type SharePageProps = { params: Promise<{ id: string }> }; @@ -20,24 +19,15 @@ export const generateMetadata = async ({ params }: SharePageProps): Promise { - if (row.kind === 'search') { - return searchShareMode(row.mode); - } - - return isLiveStudioShare(row) ? 'live' : 'snapshot'; -}; - -// A public share: the Studio, read-only, on the stored copy. No account -// needed to look. A live share's data cards leave the server held and are -// filled in the browser on current data (sharedViewOf). +// A public share: the Studio, read-only, on the share dfo-apis holds. No +// account needed to look. A snapshot's data cards are drawn here from their +// component revisions; a live share's leave the server held and are filled +// in the browser on current data (src/lib/studio/slotCards). const SharePage = async ({ params }: SharePageProps) => { const { id } = await params; const row = await getShare(id); @@ -54,7 +44,7 @@ const SharePage = async ({ params }: SharePageProps) => { after(() => track( 'share.open', - { shareId: row.id, kind: row.kind, mode: modeOf(row), host }, + { shareId: row.id, kind: row.kind, mode: row.mode, host }, { headers: incoming }, ), ); @@ -65,7 +55,7 @@ const SharePage = async ({ params }: SharePageProps) => { redirect(`/data?share=${row.id}`); } - return ; + return ; }; export default SharePage; diff --git a/src/app/studio/[id]/page.tsx b/src/app/studio/[id]/page.tsx index 1d8a73b..5e98882 100644 --- a/src/app/studio/[id]/page.tsx +++ b/src/app/studio/[id]/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from 'next'; import Studio from '@/components/organisms/Studio/Studio'; import { getSession } from '@/lib/auth/session'; import { pickExamples } from '@/lib/studio/examples'; -import { liveSharingAvailable } from '@/lib/studio/liveShare'; +import { liveSharingAvailable } from '@/lib/studio/slotCards'; export const metadata: Metadata = { title: 'DFO Studio', diff --git a/src/app/studio/page.tsx b/src/app/studio/page.tsx index 23227ce..f240499 100644 --- a/src/app/studio/page.tsx +++ b/src/app/studio/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from 'next'; import Studio from '@/components/organisms/Studio/Studio'; import { getSession } from '@/lib/auth/session'; import { pickExamples } from '@/lib/studio/examples'; -import { liveSharingAvailable } from '@/lib/studio/liveShare'; +import { liveSharingAvailable } from '@/lib/studio/slotCards'; export const metadata: Metadata = { title: 'DFO Studio', diff --git a/src/components/organisms/Studio/modals/ShareModal.tsx b/src/components/organisms/Studio/modals/ShareModal.tsx index 3dc8d1e..b81fef0 100644 --- a/src/components/organisms/Studio/modals/ShareModal.tsx +++ b/src/components/organisms/Studio/modals/ShareModal.tsx @@ -1,7 +1,6 @@ 'use client'; import classNames from 'classnames'; -import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { useCopied } from '../hooks/useCopied'; @@ -12,7 +11,7 @@ import Button from '@/components/atoms/Button/Button'; import Icon from '@/components/atoms/Icon/Icon'; import Modal from '@/components/molecules/Modal/Modal'; import SegmentedTabs from '@/components/molecules/SegmentedTabs/SegmentedTabs'; -import { GUEST_SHARES, type ShareMode, type StudioShareKind } from '@/lib/chat/shareMode'; +import type { ShareMode, StudioShareKind } from '@/lib/chat/shareMode'; import type { CardRef } from '@/lib/chat/widgets'; import { downloadBlob } from '@/lib/download'; import { DATASET_FORMATS, type DatasetFormat } from '@/lib/search/searchClient'; @@ -64,8 +63,8 @@ type ShareModalProps = { // has no mode choice and every link is a snapshot. offerLive: boolean; createShare: ShareLinkDeps['createShare']; - // Signed out: the links have no owner, so the dialog says they are - // permanent, and asks the guest to sign in once today's are made. + // Signed out: the site keeps no write token for the links, so the dialog + // says they are permanent and offers no switch to a snapshot. guest: boolean; }; @@ -108,8 +107,8 @@ const COPY = { toSnapshot: 'To stop live updates, make this link a snapshot. Everyone who has the link then sees the numbers as they were when you made it. This cannot be undone.', nowSnapshot: 'This link is now a snapshot. It shows the numbers as they were when you made it.', - guest: 'Without an account, a link stays up as made and you cannot remove it later. Sign in to share live data and manage your links.', - guestLimit: `You've made today's ${GUEST_SHARES} guest links. Sign in to share more. It's free.`, + guest: 'Without an account, a link stays up as made: you cannot make it a snapshot or remove it later. Sign in to manage your links.', + shareLimit: "This network has made today's links. Try again tomorrow.", }; const origin = () => (typeof window === 'undefined' ? '' : window.location.origin); @@ -198,7 +197,6 @@ const ShareModal = ({ createShare, guest, }: ShareModalProps) => { - const router = useRouter(); const [tab, setTab] = useState(scope.kind === 'whole' ? 'conversation' : 'link'); const [mode, setMode] = useState('live'); // The links of this opening, by kind, card, and mode (`shareLinkKey`). @@ -290,7 +288,7 @@ const ShareModal = ({ const showEmbed = scope.kind === 'whole' ? tab === 'canvas' : tab === 'embed'; // A live link made a snapshot keeps its tab and reads as a snapshot. const shownMode: ShareMode = link?.status === 'ready' ? link.mode : linkMode; - const canSwitch = hasMode && link?.status === 'ready' && link.mode === 'live'; + const canSwitch = hasMode && !guest && link?.status === 'ready' && link.mode === 'live'; const switchState = key ? switching[key] : undefined; const status = !hasMode ? null @@ -390,20 +388,9 @@ const ShareModal = ({ )} {limited && ( - <> -

- {COPY.guestLimit} -

-
- -
- +

+ {COPY.shareLimit} +

)} {withdrawal && (
diff --git a/src/components/organisms/Studio/modals/StudioModals.tsx b/src/components/organisms/Studio/modals/StudioModals.tsx index 581ab2e..b2a0b85 100644 --- a/src/components/organisms/Studio/modals/StudioModals.tsx +++ b/src/components/organisms/Studio/modals/StudioModals.tsx @@ -138,7 +138,7 @@ const StudioModals = ({ scope={modals.share.scope} title={title} data={shareData} - offerLive={liveSharing && sharesData && !guest} + offerLive={liveSharing && sharesData} createShare={createShare} guest={guest} /> diff --git a/src/lib/chat/apisShares.ts b/src/lib/chat/apisShares.ts new file mode 100644 index 0000000..5042518 --- /dev/null +++ b/src/lib/chat/apisShares.ts @@ -0,0 +1,378 @@ +import type { ShareMode } from './shareMode.ts'; +import type { ShareKind } from './sharePrune.ts'; +import { + deleteShareToken, + putShareToken, + readShareToken, + type ShareTokenDb, +} from './shareTokens.ts'; +import type { CardRef, FrameRef } from './widgets.ts'; + +/** + * The website's calls to dfo-apis' share routes, and the only module that + * knows their paths, bodies, and headers: + * + * POST {apis}/api/presentation/shares createShare + * GET {apis}/api/presentation/shares/{id} readShare + * POST {apis}/api/presentation/shares/{id}/snapshot (token) ownerShareWrite + * DELETE {apis}/api/presentation/shares/{id} (token) ownerShareWrite + * GET {apis}/api/presentation/shares/{id}/slots/{n}/data readSlot + * + * dfo-apis mints every share id and a `shw_` write token, which a create + * answers once. A signed-in owner's token is kept in `share_token` + * (shareTokens.ts) and sent back in `x-dfo-write-token` to switch a live + * share to snapshot or take a share down. A guest's share keeps no token, so + * it stays as made. Every call names the browser's caller to dfo-apis + * (`SharesBackend.nameCaller`), which counts the share allowance by it. + * + * Every failure is a `SharesError` whose `code` is machine-readable: the + * code of dfo-apis' refusal, or one of `backend_unreachable`, + * `backend_timeout`, `backend_refused`, `backend_invalid`. No free text of a + * refusal leaves this module. + * + * No database binding or path-alias dependency, so `node --test` imports it. + */ + +export type SharesBackend = { + base: string; + fetch: typeof fetch; + // Names the browser's caller on headers bound for dfo-apis + // (`setApisCaller` in src/lib/proxy/caller.ts). + nameCaller: (headers: Headers) => void; + signal?: AbortSignal; +}; + +export class SharesError extends Error { + readonly code: string; + readonly status?: number; + readonly retryable: boolean; + readonly retryAfterS?: number; + + constructor( + code: string, + { + status, + retryable = false, + retryAfterS, + }: { status?: number; retryable?: boolean; retryAfterS?: number } = {}, + ) { + super(`dfo-apis shares: ${code}`); + this.name = 'SharesError'; + this.code = code; + this.status = status; + this.retryable = retryable; + this.retryAfterS = retryAfterS; + } +} + +/** One numbered slot of a share. `card` is null for a slot a replacement + * left empty, and its `frame` null when dfo-apis recorded none. */ +export type RecordSlot = { + slot: number; + card: (CardRef & { frame: FrameRef | null }) | null; +}; + +/** A share as dfo-apis holds it (`GET /api/presentation/shares/{id}`). */ +export type ShareRecord = { + id: string; + kind: ShareKind; + mode: ShareMode; + title: string | null; + slots: RecordSlot[]; + payload: unknown; + createdAt: string; +}; + +/** What a slot serves now: the card revision it shows, the frame it drew + * the rows on, and whether that is the newest data. */ +export type SlotServed = { + componentId: string; + revision: number; + frame: FrameRef; + freshness: 'current' | 'stale' | 'pending'; +}; + +export type CreateShareBody = { + kind: ShareKind; + latest: boolean; + title: string; + cards: CardRef[]; + payload: unknown; +}; + +export const SHARE_ID = /^[A-Za-z0-9]{8}$/; + +const WRITE_TOKEN_HEADER = 'x-dfo-write-token'; +const KINDS: readonly string[] = ['card', 'canvas', 'search', 'conversation']; +const CODE = /^[a-z][a-z0-9_]{0,63}$/; +// dfo-apis' bound on a title. +const MAX_TITLE = 300; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const sharePath = (id: string): string => { + // The id is the viewer's URL segment; only dfo-apis' own form reaches a path. + if (!SHARE_ID.test(id)) { + throw new SharesError('not_found', { status: 404 }); + } + + return `/api/presentation/shares/${id}`; +}; + +const refusalOf = async (res: Response): Promise => { + const body: unknown = await res.json().catch(() => null); + const code = isRecord(body) ? body['code'] : undefined; + const wait = Number(res.headers.get('retry-after')); + + return new SharesError(typeof code === 'string' && CODE.test(code) ? code : 'backend_refused', { + status: res.status, + retryable: isRecord(body) && body['retryable'] === true, + ...(Number.isFinite(wait) && wait > 0 ? { retryAfterS: wait } : {}), + }); +}; + +const send = async ( + backend: SharesBackend, + path: string, + init: { method?: string; body?: unknown; token?: string } = {}, +): Promise => { + const headers = new Headers({ accept: 'application/json' }); + + backend.nameCaller(headers); + + if (init.body !== undefined) { + headers.set('content-type', 'application/json'); + } + + if (init.token) { + headers.set(WRITE_TOKEN_HEADER, init.token); + } + + let res: Response; + + try { + res = await backend.fetch(`${backend.base}${path}`, { + method: init.method ?? 'GET', + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + cache: 'no-store', + redirect: 'manual', + signal: backend.signal, + }); + } catch (error) { + const timedOut = + error instanceof Error && + (error.name === 'TimeoutError' || error.name === 'AbortError'); + + throw new SharesError(timedOut ? 'backend_timeout' : 'backend_unreachable'); + } + + if (!res.ok) { + throw await refusalOf(res); + } + + return res; +}; + +const json = async (res: Response): Promise> => { + const body: unknown = await res.json().catch(() => null); + + if (!isRecord(body)) { + throw new SharesError('backend_invalid'); + } + + return body; +}; + +const frameOf = (value: unknown): FrameRef | null => + isRecord(value) && + typeof value['spec_id'] === 'string' && + typeof value['content_version'] === 'string' + ? { spec_id: value['spec_id'], content_version: value['content_version'] } + : null; + +const slotOf = (value: unknown): RecordSlot => { + if (!isRecord(value) || !Number.isSafeInteger(value['slot'])) { + throw new SharesError('backend_invalid'); + } + + const card = value['card']; + + if (card === null) { + return { slot: value['slot'] as number, card: null }; + } + + if ( + !isRecord(card) || + typeof card['component_id'] !== 'string' || + !Number.isSafeInteger(card['revision']) + ) { + throw new SharesError('backend_invalid'); + } + + return { + slot: value['slot'] as number, + card: { + component_id: card['component_id'], + revision: card['revision'] as number, + frame: frameOf(card['frame']), + }, + }; +}; + +/** Make a share. Returns its id and write token. */ +export const createShare = async ( + backend: SharesBackend, + { kind, latest, title, cards, payload }: CreateShareBody, +): Promise<{ id: string; writeToken: string }> => { + const body = await json( + await send(backend, '/api/presentation/shares', { + method: 'POST', + body: { + kind, + latest, + title: title.trim().slice(0, MAX_TITLE) || null, + cards, + payload, + }, + }), + ); + const { id, write_token: writeToken } = body; + + if (typeof id !== 'string' || !SHARE_ID.test(id) || typeof writeToken !== 'string') { + throw new SharesError('backend_invalid'); + } + + return { id, writeToken }; +}; + +/** A share, or null when no share has this id. */ +export const readShare = async ( + backend: SharesBackend, + id: string, +): Promise => { + let body: Record; + + try { + body = await json(await send(backend, sharePath(id))); + } catch (error) { + if (error instanceof SharesError && error.status === 404) { + return null; + } + + throw error; + } + + const { kind, mode, title, slots, payload, created_at: createdAt } = body; + + if ( + body['id'] !== id || + typeof kind !== 'string' || + !KINDS.includes(kind) || + (mode !== 'live' && mode !== 'snapshot') || + (title !== null && typeof title !== 'string') || + !Array.isArray(slots) || + typeof createdAt !== 'string' + ) { + throw new SharesError('backend_invalid'); + } + + return { + id, + kind: kind as ShareKind, + mode, + title, + slots: slots.map(slotOf), + payload: payload ?? null, + createdAt, + }; +}; + +/** What slot `slot` (from 1) of a share serves now. Reading it is what has + * dfo-apis check a live slot for newer data, at most once per 15 minutes, + * and record the frame in the share's history. */ +export const readSlot = async ( + backend: SharesBackend, + id: string, + slot: number, +): Promise => { + const body = await json(await send(backend, `${sharePath(id)}/slots/${slot}/data?format=json`)); + const { component_id: componentId, revision, freshness } = body; + const frame = frameOf(body['frame']); + + if ( + typeof componentId !== 'string' || + !Number.isSafeInteger(revision) || + !frame || + (freshness !== 'current' && freshness !== 'stale' && freshness !== 'pending') + ) { + throw new SharesError('backend_invalid'); + } + + return { componentId, revision: revision as number, frame, freshness }; +}; + +/** + * Make a share and keep its write token for `userId`, the signed-in maker. + * A guest's share (`userId` null) keeps no token, so nobody can change or + * remove it. + */ +export const createOwnedShare = async ( + { db, backend }: { db: ShareTokenDb; backend: SharesBackend }, + userId: string | null, + body: CreateShareBody, +): Promise<{ id: string }> => { + const { id, writeToken } = await createShare(backend, body); + + if (userId) { + await putShareToken(db, { shareId: id, userId, writeToken }); + } + + return { id }; +}; + +/** + * A signed-in owner's write to their own share: `snapshot` turns a live + * share into a snapshot under the same URLs, `delete` takes it down and + * drops the token. `not_found` covers a share that does not exist, one the + * caller holds no token for, and one whose token dfo-apis refuses, so the + * answer does not reveal which ids are taken. Any other refusal throws. + */ +export const ownerShareWrite = async ( + { db, backend }: { db: ShareTokenDb; backend: SharesBackend }, + { shareId, userId, action }: { shareId: string; userId: string; action: 'snapshot' | 'delete' }, +): Promise<'done' | 'not_found'> => { + if (!SHARE_ID.test(shareId)) { + return 'not_found'; + } + + const token = await readShareToken(db, { shareId, userId }); + + if (!token) { + return 'not_found'; + } + + try { + await send( + backend, + action === 'delete' ? sharePath(shareId) : `${sharePath(shareId)}/snapshot`, + { method: action === 'delete' ? 'DELETE' : 'POST', token }, + ); + } catch (error) { + if (!(error instanceof SharesError) || (error.status !== 404 && error.status !== 403)) { + throw error; + } + + // The share is gone, or the token no longer grants it. + await deleteShareToken(db, { shareId, userId }); + + return 'not_found'; + } + + if (action === 'delete') { + await deleteShareToken(db, { shareId, userId }); + } + + return 'done'; +}; diff --git a/src/lib/chat/shareMode.ts b/src/lib/chat/shareMode.ts index abf4a98..3eb4fcb 100644 --- a/src/lib/chat/shareMode.ts +++ b/src/lib/chat/shareMode.ts @@ -19,10 +19,6 @@ export type ShareMode = 'live' | 'snapshot'; export type StudioShareKind = Exclude; -// Studio shares a day a guest may make from one network address. A guest's -// share has no owner, so it is a snapshot, and nobody can switch or remove it. -export const GUEST_SHARES = 10; - const MODES: ShareMode[] = ['live', 'snapshot']; export const STUDIO_KINDS: StudioShareKind[] = ['conversation', 'canvas', 'card']; diff --git a/src/lib/chat/sharePayload.ts b/src/lib/chat/sharePayload.ts new file mode 100644 index 0000000..2c7e923 --- /dev/null +++ b/src/lib/chat/sharePayload.ts @@ -0,0 +1,214 @@ +import { canvasWidgetsOf } from './canvas.ts'; +import type { SavedConversation } from './history.ts'; +import type { ShareMode, StudioShareKind } from './shareMode.ts'; +import { pruneForShare } from './sharePrune.ts'; +import type { CardRef, FrameRef } from './widgets.ts'; + +/** + * What a Studio share holds in dfo-apis: its slots, the data cards dfo-apis + * draws, and the website-owned payload that renders the rest. A new share + * (`studioShareBody`) and the export of a stored one (db/share-export.ts) + * both place cards through `slotCanvas`, so a share made today and one + * imported from the website's table read back alike. + * + * A data card with a ref naming a component revision in dfo-apis' form gets + * a slot. In the payload's `widgets` it is held: it keeps `kind`, `id`, + * `turn`, and for a chart `chartType` and `horizontal`, and gains `slot`, the + * 0-based index of its slot (dfo-apis numbers slots from 1). Its numbers, + * title, source and description come from the slot's component revision. + * + * No database, React, or path-alias dependency, so `node --test` imports it. + */ + +/** One data card of a share as dfo-apis holds it. `frame` is null when the + * stored card names none in dfo-apis' form; dfo-apis reads it from the + * component revision. */ +export type ShareSlot = CardRef & { frame: FrameRef | null }; + +type StoredWidget = SavedConversation['widgets'][number]; + +const COMPONENT_ID = /^cmp_[A-Za-z0-9_-]{1,64}$/; +const FRAME_SPEC_ID = /^(spec|ops)_[A-Za-z0-9_-]+$/; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const frameOf = (frame: unknown): FrameRef | null => + isRecord(frame) && + typeof frame.spec_id === 'string' && + FRAME_SPEC_ID.test(frame.spec_id) && + typeof frame.content_version === 'string' && + frame.content_version !== '' + ? { spec_id: frame.spec_id, content_version: frame.content_version } + : null; + +/** The slot of a data card whose `ref` names a component revision in + * dfo-apis' form, or null for a text card and a card with no such ref. */ +export const slotOf = (widget: StoredWidget): ShareSlot | null => { + if (widget.kind === 'text') { + return null; + } + + const ref: unknown = widget.ref; + + if ( + !isRecord(ref) || + typeof ref.component_id !== 'string' || + !COMPONENT_ID.test(ref.component_id) || + !Number.isSafeInteger(ref.revision) || + (ref.revision as number) < 1 + ) { + return null; + } + + return { + component_id: ref.component_id, + revision: ref.revision as number, + frame: frameOf(widget.frame), + }; +}; + +// A data card in the payload with none of what it showed: where it sits, +// what shape it takes, and the index of the slot that draws it, if any. +const heldCard = (widget: StoredWidget, slot: number | undefined): Record => ({ + kind: widget.kind, + id: widget.id, + turn: widget.turn, + ...(widget.kind === 'chart' + ? { chartType: widget.chartType, horizontal: widget.horizontal } + : {}), + slot, +}); + +/** A stored copy as a viewer reads it: no owner's harness session and no + * inherited refs (`pruneForShare`). */ +export const viewerCopy = >(data: T): T => ({ + ...data, + sessionId: null, + componentRefs: [], +}); + +export type SlottedCanvas = + | { slots: ShareSlot[]; widgets: unknown[] } + // The ids of the shown data cards a live share cannot hold. + | { unslotted: string[] }; + +/** + * The slots of a canvas or card share and its payload widgets. The data + * cards the canvas shows (`canvasWidgetsOf`) get a slot each, in that order, + * when they have a ref; two cards drawing the same revision get one each. A + * live share needs a slot for every shown data card, and holds every data + * card, shown or not, because live share pages show none of the numbers + * stored with the share. A snapshot keeps a card with no ref, and every card + * it does not show, as stored. + */ +export const slotCanvas = (saved: SavedConversation, mode: ShareMode): SlottedCanvas => { + const slots: ShareSlot[] = []; + const slotIndex = new Map(); + const unslotted: string[] = []; + + // canvasWidgetsOf hands back the stored widget objects, so each slot is + // tied to its own widget even when two cards draw the same revision. + for (const widget of canvasWidgetsOf(saved)) { + if (widget.kind === 'text') { + continue; + } + + const slot = slotOf(widget as StoredWidget); + + if (slot) { + slotIndex.set(widget as StoredWidget, slots.length); + slots.push(slot); + } else { + unslotted.push(widget.id); + } + } + + if (mode === 'live' && unslotted.length) { + return { unslotted }; + } + + const widgets = saved.widgets.map((widget) => { + const slot = slotIndex.get(widget); + const held = slot !== undefined || (mode === 'live' && widget.kind !== 'text'); + + return held ? heldCard(widget, slot) : widget; + }); + + return { slots, widgets }; +}; + +// The title a live card share gets when its sharer set none. The page title +// and the embed's title come from the share, and a live card's own title is +// withheld from the page with its data, so it stays out of them too. +export const LIVE_CARD_TITLE = 'Live card'; + +/** The title of a Studio share: a card share's own title, else the + * conversation's. `data` is the pruned copy (`pruneForShare`). */ +export const studioShareTitle = ( + data: SavedConversation, + { + kind, + mode, + cardId, + savedTitle, + }: { kind: StudioShareKind; mode: ShareMode; cardId?: string; savedTitle: string }, +): string => { + const storedTitle = () => + data.widgets + .map((widget) => (widget.kind === 'key_number' ? widget.label : widget.title)) + .find(Boolean) ?? data.textCards?.[0]?.title; + const cardTitle = + cardId && (data.titles?.[cardId] ?? (mode === 'live' ? LIVE_CARD_TITLE : storedTitle())); + + return (kind === 'card' && cardTitle) || savedTitle; +}; + +/** The create body of `POST /api/presentation/shares` for a Studio share. */ +export type StudioShareBody = { + kind: StudioShareKind; + latest: boolean; + title: string; + cards: CardRef[]; + payload: Record; +}; + +/** + * What `POST /api/share` sends dfo-apis for a Studio share of `kind`, or + * the ids of the data cards that keep it from being live. A card share + * whose card has no ref has no slot, which dfo-apis refuses (a card share + * takes 1 to 24 cards). + */ +export const studioShareBody = ({ + saved, + kind, + mode, + cardId, +}: { + saved: SavedConversation; + kind: StudioShareKind; + mode: ShareMode; + cardId?: string; +}): StudioShareBody | { unslotted: string[] } => { + const data = pruneForShare(saved, kind, cardId); + const title = studioShareTitle(data, { kind, mode, cardId, savedTitle: saved.title }); + const latest = mode === 'live'; + + if (kind === 'conversation') { + return { kind, latest, title, cards: [], payload: viewerCopy(data) }; + } + + const slotted = slotCanvas(data, mode); + + if ('unslotted' in slotted) { + return slotted; + } + + return { + kind, + latest, + title, + cards: slotted.slots.map(({ component_id, revision }) => ({ component_id, revision })), + payload: { ...viewerCopy(data), widgets: slotted.widgets }, + }; +}; diff --git a/src/lib/chat/shareStore.ts b/src/lib/chat/shareStore.ts index 00a4f87..4887ad1 100644 --- a/src/lib/chat/shareStore.ts +++ b/src/lib/chat/shareStore.ts @@ -1,70 +1,72 @@ -import { eq } from 'drizzle-orm'; +import { headers } from 'next/headers'; +import { cache } from 'react'; -import type { StudioShareRequest } from './shareMode'; -import { pruneForShare, shareForViewer, type ShareKind } from './sharePrune'; import { - deleteOwnedShare, - insertStudioShare, - spendGuestShare as spendGuestShareIn, - type NewStudioShare, - type OwnerWriteResult, -} from './shareWrites'; + createOwnedShare, + ownerShareWrite, + readShare, + type CreateShareBody, + type ShareRecord, + type SharesBackend, +} from './apisShares'; +import type { ShareKind } from './sharePrune'; import { apisFetch } from '@/lib/apisAuth'; -import { counterDb, db } from '@/lib/db'; -import { liveShareRefusal } from '@/lib/studio/liveShare'; -import { snapshotLiveShare } from '@/lib/studio/liveShareResults'; -import { share } from '../../../db/schema'; +import { db } from '@/lib/db'; +import { setApisCaller } from '@/lib/proxy/caller'; +import { searchApiBase } from '@/lib/searchApi'; +import { liveBackendFrom } from '@/lib/studio/liveBackend'; +import type { SlotDeps } from '@/lib/studio/slotCards'; /** - * Public shares (Postgres). A share is a pruned copy of a conversation under - * a short id: + * Public shares, held in dfo-apis (src/lib/chat/apisShares.ts) and bound + * here to the server's pool, its dfo-apis origin, and the caller of the + * request at hand. A share is a pruned copy of what its maker was looking + * at under a short id that dfo-apis mints: * conversation → chat + canvas, as saved * canvas → the canvas only (transcript and activity dropped, since * the link must not leak the chat the sharer didn't share) * card → one card, with just its layout/title/note/view - * The stored copy is fixed at creation while the owner's conversation keeps - * evolving. A snapshot share shows that copy as stored. A live canvas or - * card share (`isLiveStudioShare` in shareMode.ts) is the same stored copy - * with `share.mode` set to `live`, whose data cards the viewer redraws on - * current data (src/lib/studio/liveShare.ts). `getShare` returns the row - * through `shareForViewer`, so no read carries the owner's `sessionId`. - * - * The writes live in shareWrites.ts, which takes the database handle as an - * argument. This module binds them to the server's pool. + * search → a search result's dataset references (lib/search/searchShare.ts) + * The data cards of a canvas or card share are its slots, and the rest of + * the copy is the website's payload (src/lib/chat/sharePayload.ts). A + * signed-in maker's write token is kept in `share_token`. */ -export type { ShareKind }; +export type { ShareKind, ShareRecord }; -export { shortId } from './shareWrites'; +/** dfo-apis' share routes, naming the caller of `incoming`. */ +export const sharesBackendFor = (incoming: Headers): SharesBackend => ({ + base: searchApiBase(), + fetch: apisFetch, + nameCaller: (outgoing) => setApisCaller(outgoing, incoming), +}); -export const createShare = (input: NewStudioShare): Promise<{ id: string; title: string }> => - insertStudioShare(db, input); +/** A share by id, or null. One read per request, however many readers. */ +export const getShare = cache(async (id: string): Promise => + readShare(sharesBackendFor(await headers()), id), +); -/** Spend one of today's guest shares for `address`; false when spent. */ -export const spendGuestShare = (address: string): Promise => - spendGuestShareIn(counterDb, address); +/** Make a share for `userId` (null for a guest), keeping a signed-in + * maker's write token. */ +export const makeShare = ( + incoming: Headers, + userId: string | null, + body: CreateShareBody, +): Promise<{ id: string }> => + createOwnedShare({ db, backend: sharesBackendFor(incoming) }, userId, body); -/** Why the copy this request would store cannot be a live share, or null - * when it can. */ -export const liveShareRefusalFor = ({ - saved, - kind, - cardId, -}: StudioShareRequest): Promise => - liveShareRefusal({ env: process.env, fetch: apisFetch }, pruneForShare(saved, kind, cardId)); +/** Switch the caller's live share to snapshot, or take it down. */ +export const writeOwnShare = ( + incoming: Headers, + write: { shareId: string; userId: string; action: 'snapshot' | 'delete' }, +): Promise<'done' | 'not_found'> => + ownerShareWrite({ db, backend: sharesBackendFor(incoming) }, write); -export const getShare = async (id: string) => { - const [row] = await db.select().from(share).where(eq(share.id, id)).limit(1); - - return row ? shareForViewer(row) : null; -}; - -/** Switch the caller's Studio share to snapshot, dropping the stored - * refresh answers of its cards. */ -export const snapshotShare = (id: string, userId: string): Promise => - snapshotLiveShare(db, id, userId); - -/** Delete the caller's Studio share. */ -export const deleteShare = (id: string, userId: string): Promise => - deleteOwnedShare(db, id, userId); +/** What the share pages and live card routes draw with: dfo-apis' share + * routes, and its render routes when this deployment holds the + * live-refresh credential. */ +export const slotDepsFor = (incoming: Headers): SlotDeps => ({ + shares: sharesBackendFor(incoming), + render: liveBackendFrom(process.env, apisFetch), +}); diff --git a/src/lib/chat/shareTokens.ts b/src/lib/chat/shareTokens.ts index a56ef99..778005e 100644 --- a/src/lib/chat/shareTokens.ts +++ b/src/lib/chat/shareTokens.ts @@ -10,7 +10,7 @@ import { shareToken } from '../../../db/schema.ts'; * test database. */ -export type ShareTokenDb = Pick; +export type ShareTokenDb = Pick; /** * Store `writeToken` as `userId`'s token for `shareId`, replacing the token @@ -47,3 +47,14 @@ export const readShareToken = async ( return row?.writeToken ?? null; }; + +/** Delete `userId`'s write token for `shareId`, once the share is taken + * down. A token another user stored for it stays. */ +export const deleteShareToken = async ( + db: ShareTokenDb, + { shareId, userId }: { shareId: string; userId: string }, +): Promise => { + await db + .delete(shareToken) + .where(and(eq(shareToken.shareId, shareId), eq(shareToken.userId, userId))); +}; diff --git a/src/lib/chat/shareWrites.ts b/src/lib/chat/shareWrites.ts index 675d4de..09edb19 100644 --- a/src/lib/chat/shareWrites.ts +++ b/src/lib/chat/shareWrites.ts @@ -4,10 +4,9 @@ import { and, eq } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { share } from '../../../db/schema.ts'; -import { limiterBucket } from '../proxy/caller.ts'; -import { reserveHit, type CounterDb } from '../proxy/limitCounter.ts'; import type { SavedConversation } from './history.ts'; -import { GUEST_SHARES, isStudioKind, type ShareMode, type StudioShareKind } from './shareMode.ts'; +import { studioShareTitle } from './sharePayload.ts'; +import { isStudioKind, type ShareMode, type StudioShareKind } from './shareMode.ts'; import { pruneForShare } from './sharePrune.ts'; /** @@ -32,11 +31,6 @@ export const shortId = (length = 8): string => { return out; }; -// The title a live card share gets when its sharer set none. The page title -// and the embed's title come from the row, and a live card's own title is -// withheld from the page with its data, so it stays out of them too. -export const LIVE_CARD_TITLE = 'Live card'; - export type NewStudioShare = { // Null for a guest's share, which no one owns. userId: string | null; @@ -46,28 +40,12 @@ export type NewStudioShare = { cardId?: string; }; -const DAY_S = 86_400; - -/** - * Spend one of today's GUEST_SHARES for the guest at `address`, counted by - * network (limiterBucket) per UTC day. False when they are spent. Throws what - * the database throws. - */ -export const spendGuestShare = async (db: CounterDb, address: string): Promise => - (await reserveHit(db, `share-guest:${limiterBucket(address)}`, GUEST_SHARES, DAY_S)).admitted; - export const insertStudioShare = async ( db: ShareDb, { userId, kind, mode, saved, cardId }: NewStudioShare, ): Promise<{ id: string; title: string }> => { const data = pruneForShare(saved, kind, cardId); - const storedTitle = () => - data.widgets - .map((widget) => (widget.kind === 'key_number' ? widget.label : widget.title)) - .find(Boolean) ?? data.textCards?.[0]?.title; - const cardTitle = - cardId && (data.titles?.[cardId] ?? (mode === 'live' ? LIVE_CARD_TITLE : storedTitle())); - const title = (kind === 'card' && cardTitle) || saved.title; + const title = studioShareTitle(data, { kind, mode, cardId, savedTitle: saved.title }); const id = shortId(); await db.insert(share).values({ diff --git a/src/lib/chat/test/apisShares.test.ts b/src/lib/chat/test/apisShares.test.ts new file mode 100644 index 0000000..e2f7078 --- /dev/null +++ b/src/lib/chat/test/apisShares.test.ts @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createShare, + readShare, + readSlot, + SharesError, + type SharesBackend, +} from '../apisShares.ts'; + +type Call = { url: string; method: string; headers: Headers; body: unknown }; + +const APIS = 'https://apis.example'; + +const backendOf = (answer: (call: Call) => Response): { backend: SharesBackend; calls: Call[] } => { + const calls: Call[] = []; + + return { + calls, + backend: { + base: APIS, + nameCaller: (headers) => headers.set('x-dfo-caller', 'ip:test'), + fetch: async (input, init) => { + const call = { + url: String(input), + method: init?.method ?? 'GET', + headers: new Headers(init?.headers), + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }; + + calls.push(call); + + return answer(call); + }, + }, + }; +}; + +const RECORD = { + id: 'Ab3dEf7h', + kind: 'canvas', + mode: 'live', + title: 'Live aid', + revision: 1, + slots: [ + { + slot: 1, + card: { + component_id: 'cmp_a', + revision: 2, + frame: { spec_id: 'spec_aid', content_version: 'cv_1' }, + }, + data_url: 'https://site.example/api/v1/presentation/shares/Ab3dEf7h/slots/1/data', + }, + { slot: 2, card: { component_id: 'cmp_b', revision: 1, frame: null }, data_url: 'x' }, + { slot: 3, card: null, data_url: 'y' }, + ], + payload: { widgets: [] }, + created_at: '2026-10-28T10:00:00Z', +}; + +test('a create names the caller, sends the body, and returns the id and token', async () => { + const { backend, calls } = backendOf(() => + Response.json({ id: 'Ab3dEf7h', write_token: 'shw_x', revision: 1 }, { status: 201 }), + ); + + assert.deepEqual( + await createShare(backend, { + kind: 'canvas', + latest: true, + title: ` ${'t'.repeat(400)}`, + cards: [{ component_id: 'cmp_a', revision: 2 }], + payload: { widgets: [] }, + }), + { id: 'Ab3dEf7h', writeToken: 'shw_x' }, + ); + + const [call] = calls; + + assert.equal(call?.url, `${APIS}/api/presentation/shares`); + assert.equal(call?.method, 'POST'); + assert.equal(call?.headers.get('x-dfo-caller'), 'ip:test'); + assert.equal(call?.headers.get('x-dfo-write-token'), null); + assert.deepEqual(call?.body, { + kind: 'canvas', + latest: true, + // dfo-apis takes a title of at most 300 characters. + title: 't'.repeat(300), + cards: [{ component_id: 'cmp_a', revision: 2 }], + payload: { widgets: [] }, + }); +}); + +test("a refusal throws dfo-apis' code, retryable and Retry-After", async () => { + const { backend } = backendOf(() => + Response.json( + { code: 'allowance_exhausted', retryable: true, detail: 'secret detail' }, + { status: 429, headers: { 'retry-after': '3600' } }, + ), + ); + const refused = await createShare(backend, { + kind: 'card', + latest: false, + title: 'T', + cards: [], + payload: null, + }).catch((error: unknown) => error); + + assert.ok(refused instanceof SharesError); + assert.equal(refused.code, 'allowance_exhausted'); + assert.equal(refused.status, 429); + assert.equal(refused.retryable, true); + assert.equal(refused.retryAfterS, 3600); + assert.ok(!refused.message.includes('secret')); +}); + +test('a read maps the record, its empty slots, and slots with no recorded frame', async () => { + const { backend } = backendOf(() => Response.json(RECORD)); + + assert.deepEqual(await readShare(backend, 'Ab3dEf7h'), { + id: 'Ab3dEf7h', + kind: 'canvas', + mode: 'live', + title: 'Live aid', + slots: [ + { + slot: 1, + card: { + component_id: 'cmp_a', + revision: 2, + frame: { spec_id: 'spec_aid', content_version: 'cv_1' }, + }, + }, + { slot: 2, card: { component_id: 'cmp_b', revision: 1, frame: null } }, + { slot: 3, card: null }, + ], + payload: { widgets: [] }, + createdAt: '2026-10-28T10:00:00Z', + }); +}); + +test('no share is null: a 404, and an id not in dfo-apis form without a request', async () => { + const { backend, calls } = backendOf(() => + Response.json({ code: 'not_found' }, { status: 404 }), + ); + + assert.equal(await readShare(backend, 'Ab3dEf7h'), null); + assert.equal(await readShare(backend, '../admin'), null); + assert.equal(calls.length, 1); +}); + +test('a slot read asks for JSON and gives the frame served and its freshness', async () => { + const { backend, calls } = backendOf(() => + Response.json({ + share_id: 'Ab3dEf7h', + slot: 1, + mode: 'live', + freshness: 'pending', + component_id: 'cmp_a', + revision: 2, + frame: { spec_id: 'spec_aid', content_version: 'cv_2' }, + rows: [], + }), + ); + + assert.deepEqual(await readSlot(backend, 'Ab3dEf7h', 1), { + componentId: 'cmp_a', + revision: 2, + frame: { spec_id: 'spec_aid', content_version: 'cv_2' }, + freshness: 'pending', + }); + assert.equal( + calls[0]?.url, + `${APIS}/api/presentation/shares/Ab3dEf7h/slots/1/data?format=json`, + ); +}); diff --git a/src/lib/chat/test/sharePayload.test.ts b/src/lib/chat/test/sharePayload.test.ts new file mode 100644 index 0000000..a119b9d --- /dev/null +++ b/src/lib/chat/test/sharePayload.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { SavedConversation } from '../history.ts'; +import { LIVE_CARD_TITLE, studioShareBody } from '../sharePayload.ts'; + +const FRAME = { spec_id: 'spec_aid', content_version: 'cv_1' }; + +const chart = (id: string, extra: Record = {}) => ({ + kind: 'chart', + id, + turn: 1, + chartType: 'line', + title: `Chart ${id}`, + categories: ['2024'], + series: [{ label: 'Kenya', values: [1] }], + ...extra, +}); + +const saved = (widgets: unknown[]): SavedConversation => + ({ + id: 'conv-1', + sessionId: 'harness-session', + componentRefs: [{ component_id: 'cmp_private', revision: 1 }], + title: 'Aid to Kenya', + updatedAt: 1, + messages: [{ role: 'user', text: 'aid' }], + widgets, + activity: [], + turnCount: 1, + pinnedIds: [], + removedIds: [], + layout: {}, + textCards: [], + }) as unknown as SavedConversation; + +test('a live canvas sends a slot per shown data card and holds every data card', () => { + const body = studioShareBody({ + saved: saved([ + chart('c1', { ref: { component_id: 'cmp_a', revision: 2 }, frame: FRAME }), + chart('c2', { ref: { component_id: 'cmp_b', revision: 1 } }), + ]), + kind: 'canvas', + mode: 'live', + }); + + assert.ok(!('unslotted' in body)); + assert.equal(body.latest, true); + assert.deepEqual(body.cards, [ + { component_id: 'cmp_a', revision: 2 }, + { component_id: 'cmp_b', revision: 1 }, + ]); + assert.deepEqual(body.payload['widgets'], [ + { kind: 'chart', id: 'c1', turn: 1, chartType: 'line', horizontal: undefined, slot: 0 }, + { kind: 'chart', id: 'c2', turn: 1, chartType: 'line', horizontal: undefined, slot: 1 }, + ]); + // The canvas share carries no transcript and no owner session. + assert.deepEqual(body.payload['messages'], []); + assert.equal(body.payload['sessionId'], null); + assert.deepEqual(body.payload['componentRefs'], []); +}); + +test('a live share is refused with the shown data cards that have no ref', () => { + assert.deepEqual( + studioShareBody({ + saved: saved([ + chart('c1', { ref: { component_id: 'cmp_a', revision: 2 } }), + chart('c2'), + ]), + kind: 'canvas', + mode: 'live', + }), + { unslotted: ['c2'] }, + ); +}); + +test('a snapshot keeps a card with no ref as stored, with its numbers', () => { + const stored = chart('c2'); + const body = studioShareBody({ + saved: saved([chart('c1', { ref: { component_id: 'cmp_a', revision: 2 } }), stored]), + kind: 'canvas', + mode: 'snapshot', + }); + + assert.ok(!('unslotted' in body)); + assert.equal(body.latest, false); + assert.deepEqual(body.cards, [{ component_id: 'cmp_a', revision: 2 }]); + assert.deepEqual((body.payload['widgets'] as unknown[])[1], stored); +}); + +test('a conversation sends no cards and its viewer copy, transcript included', () => { + const body = studioShareBody({ + saved: saved([chart('c1', { ref: { component_id: 'cmp_a', revision: 2 } })]), + kind: 'conversation', + mode: 'snapshot', + }); + + assert.ok(!('unslotted' in body)); + assert.deepEqual(body.cards, []); + assert.equal(body.title, 'Aid to Kenya'); + assert.deepEqual(body.payload['messages'], [{ role: 'user', text: 'aid' }]); + assert.equal(body.payload['sessionId'], null); +}); + +test("a live card share's title is the sharer's own, never the card's", () => { + const body = studioShareBody({ + saved: saved([chart('c1', { ref: { component_id: 'cmp_a', revision: 2 } })]), + kind: 'card', + mode: 'live', + cardId: 'c1', + }); + + assert.ok(!('unslotted' in body)); + assert.equal(body.title, LIVE_CARD_TITLE); + assert.deepEqual(body.cards, [{ component_id: 'cmp_a', revision: 2 }]); +}); diff --git a/src/lib/search/searchShare.ts b/src/lib/search/searchShare.ts index 688b09a..c06b1d6 100644 --- a/src/lib/search/searchShare.ts +++ b/src/lib/search/searchShare.ts @@ -1,18 +1,15 @@ -import { asc, eq } from 'drizzle-orm'; - -import { searchShareMode } from '@/lib/chat/shareMode'; -import { shortId } from '@/lib/chat/shareStore'; -import { db } from '@/lib/db'; +import { getShare, makeShare } from '@/lib/chat/shareStore'; import type { Assumption, EntityGroup, Operation } from '@/lib/search/types'; import { searchApiBase } from '@/lib/searchApi'; -import { share, shareRef } from '../../../db/schema'; import { registerDefinitions } from './registerDefinitions.ts'; +import { searchShareOf, searchSharePayload, type Captured } from './searchSharePayload.ts'; import type { SearchShareMode, SearchSharePart } from './searchShareRequest.ts'; import { captureSnapshot } from './snapshot.ts'; /** - * Search shares (server only). A link to a search result stores no numbers: - * one `share_ref` row per part holds the dataset it resolved to, and the + * Search shares (server only), held in dfo-apis as `kind: search` with the + * website's payload (searchSharePayload.ts). A link to a search result + * stores no numbers: each part holds the dataset it resolved to, and the * data comes back from the Datasets API when the link opens. * * live — each part's definition is registered, one @@ -66,6 +63,7 @@ const capture = (part: SearchSharePart) => }); export const createSearchShare = async ( + incoming: Headers, userId: string | null, mode: SearchShareMode, query: string, @@ -74,10 +72,7 @@ export const createSearchShare = async ( const ordered = [...parts].sort((a, b) => a.partIndex - b.partIndex); // Two parts can fold onto one dataset: register / capture each once. const distinct = [...new Map(ordered.map((part) => [part.specId, part])).values()]; - const capturedBySpec = new Map< - string, - { specId: string; contentVersion: string; artifactDigest: string } - >(); + const capturedBySpec = new Map(); if (mode === 'snapshot') { const results = await Promise.all(distinct.map(capture)); @@ -90,65 +85,21 @@ export const createSearchShare = async ( }); } - const captured = ordered.map((part) => capturedBySpec.get(part.specId) ?? null); - const id = shortId(); - - await db.transaction(async (tx) => { - await tx.insert(share).values({ - id, - userId, - kind: 'search', - mode, - title: query, - data: { query }, - }); - await tx.insert(shareRef).values( - ordered.map((part, index) => ({ - shareId: id, - partIndex: part.partIndex, - specId: captured?.[index]?.specId ?? part.specId, - identity: part.identity, - entityGroups: part.entityGroups ?? null, - variableText: part.variableText, - operations: part.operations, - assumptions: part.assumptions ?? null, - contentVersion: captured?.[index]?.contentVersion ?? null, - artifactDigest: captured?.[index]?.artifactDigest ?? null, - })), - ); + return makeShare(incoming, userId, { + kind: 'search', + latest: mode === 'live', + title: query, + cards: [], + payload: searchSharePayload( + query, + ordered.map((part) => ({ part, captured: capturedBySpec.get(part.specId) ?? null })), + ), }); - - return { id }; }; +/** A search share by id, or null for no share or a share of another kind. */ export const getSearchShare = async (id: string): Promise => { - const [row] = await db.select().from(share).where(eq(share.id, id)).limit(1); - - if (!row || row.kind !== 'search') { - return null; - } - - const refs = await db - .select() - .from(shareRef) - .where(eq(shareRef.shareId, id)) - .orderBy(asc(shareRef.partIndex)); + const record = await getShare(id); - return { - id: row.id, - mode: searchShareMode(row.mode), - query: (row.data as { query?: string })?.query ?? row.title, - createdAt: row.createdAt.toISOString(), - refs: refs.map((ref) => ({ - partIndex: ref.partIndex, - variableText: ref.variableText, - specId: ref.specId, - identity: ref.identity, - entityGroups: ref.entityGroups, - operations: ref.operations, - assumptions: ref.assumptions, - contentVersion: ref.contentVersion, - artifactDigest: ref.artifactDigest, - })), - }; + return record && searchShareOf(record); }; diff --git a/src/lib/search/searchSharePayload.ts b/src/lib/search/searchSharePayload.ts new file mode 100644 index 0000000..c8dcb14 --- /dev/null +++ b/src/lib/search/searchSharePayload.ts @@ -0,0 +1,106 @@ +import type { ShareRecord } from '../chat/apisShares.ts'; +import type { SearchShare, SearchShareRef } from './searchShare.ts'; +import type { SearchSharePart } from './searchShareRequest.ts'; + +/** + * A search share's payload in dfo-apis: `{query, parts}`, each part under the + * column names of the website's old `share_ref` rows (docs/share-export.md), + * so a share made here and one imported from the website's table read back + * alike. dfo-apis stores the payload as given and does not read it. + * + * No database or path-alias dependency, so `node --test` imports it. + */ + +/** A stored part. `content_version` and `artifact_digest` are a snapshot's + * captured file, null for a live share and for a snapshot stored before + * digests were kept. */ +type StoredPart = { + part_index: number; + spec_id: string; + identity: unknown; + entity_groups: SearchShareRef['entityGroups']; + variable_text: string | null; + content_version: string | null; + artifact_digest: string | null; + operations: SearchShareRef['operations']; + assumptions: SearchShareRef['assumptions']; +}; + +export type Captured = { specId: string; contentVersion: string; artifactDigest: string }; + +/** The payload of a search share of `parts` in part order, each with the + * file a snapshot captured for it, or null for a live share. */ +export const searchSharePayload = ( + query: string, + parts: { part: SearchSharePart; captured: Captured | null }[], +): { query: string; parts: StoredPart[] } => ({ + query, + parts: parts.map(({ part, captured }) => ({ + part_index: part.partIndex, + spec_id: captured?.specId ?? part.specId, + identity: part.identity, + entity_groups: part.entityGroups ?? null, + variable_text: part.variableText, + content_version: captured?.contentVersion ?? null, + artifact_digest: captured?.artifactDigest ?? null, + operations: part.operations, + assumptions: part.assumptions ?? null, + })), +}); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const textOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null); + +const refOf = (part: unknown): SearchShareRef | null => { + if ( + !isRecord(part) || + !Number.isSafeInteger(part['part_index']) || + typeof part['spec_id'] !== 'string' + ) { + return null; + } + + return { + partIndex: part['part_index'] as number, + variableText: textOrNull(part['variable_text']), + specId: part['spec_id'], + identity: part['identity'], + entityGroups: isRecord(part['entity_groups']) + ? (part['entity_groups'] as SearchShareRef['entityGroups']) + : null, + operations: Array.isArray(part['operations']) + ? (part['operations'] as SearchShareRef['operations']) + : [], + assumptions: Array.isArray(part['assumptions']) + ? (part['assumptions'] as SearchShareRef['assumptions']) + : null, + contentVersion: textOrNull(part['content_version']), + artifactDigest: textOrNull(part['artifact_digest']), + }; +}; + +/** A search share as the search page and its download read it, or null for + * a share of another kind or a payload that is not a search's. */ +export const searchShareOf = (record: ShareRecord): SearchShare | null => { + const { payload } = record; + + if (record.kind !== 'search' || !isRecord(payload) || !Array.isArray(payload['parts'])) { + return null; + } + + const refs = payload['parts'].map(refOf); + + if (refs.some((ref) => ref === null)) { + return null; + } + + return { + id: record.id, + mode: record.mode, + query: textOrNull(payload['query']) ?? record.title ?? '', + createdAt: record.createdAt, + refs: (refs as SearchShareRef[]).toSorted((a, b) => a.partIndex - b.partIndex), + }; +}; diff --git a/src/lib/search/test/searchSharePayload.test.ts b/src/lib/search/test/searchSharePayload.test.ts new file mode 100644 index 0000000..d15ceee --- /dev/null +++ b/src/lib/search/test/searchSharePayload.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ShareRecord } from '../../chat/apisShares.ts'; +import { searchShareOf, searchSharePayload } from '../searchSharePayload.ts'; +import type { SearchSharePart } from '../searchShareRequest.ts'; + +const part = (partIndex: number, specId: string): SearchSharePart => + ({ + partIndex, + variableText: `part ${partIndex}`, + specId, + identity: { variable: specId }, + entityGroups: null, + operations: [], + assumptions: [], + definition: { spec_id: specId }, + }) as unknown as SearchSharePart; + +const record = (payload: unknown, mode: 'live' | 'snapshot' = 'snapshot'): ShareRecord => ({ + id: 'Ab3dEf7h', + kind: 'search', + mode, + title: 'aid to Kenya', + slots: [], + payload, + createdAt: '2026-10-28T10:00:00Z', +}); + +test('a search share reads back as the search page read it from the old table', () => { + const payload = searchSharePayload('aid to Kenya', [ + { + part: part(0, 'spec_a'), + captured: { specId: 'spec_a2', contentVersion: 'cv_1', artifactDigest: 'sha256:x' }, + }, + { part: part(1, 'spec_b'), captured: null }, + ]); + + // Stored under the old share_ref column names, as the export writes them. + assert.deepEqual(payload.parts[0], { + part_index: 0, + spec_id: 'spec_a2', + identity: { variable: 'spec_a' }, + entity_groups: null, + variable_text: 'part 0', + content_version: 'cv_1', + artifact_digest: 'sha256:x', + operations: [], + assumptions: [], + }); + + assert.deepEqual(searchShareOf(record({ ...payload, parts: payload.parts.toReversed() })), { + id: 'Ab3dEf7h', + mode: 'snapshot', + query: 'aid to Kenya', + createdAt: '2026-10-28T10:00:00Z', + refs: [ + { + partIndex: 0, + variableText: 'part 0', + specId: 'spec_a2', + identity: { variable: 'spec_a' }, + entityGroups: null, + operations: [], + assumptions: [], + contentVersion: 'cv_1', + artifactDigest: 'sha256:x', + }, + { + partIndex: 1, + variableText: 'part 1', + specId: 'spec_b', + identity: { variable: 'spec_b' }, + entityGroups: null, + operations: [], + assumptions: [], + contentVersion: null, + artifactDigest: null, + }, + ], + }); +}); + +test('a share of another kind, or a payload with no parts, is no search share', () => { + assert.equal(searchShareOf({ ...record({ parts: [] }), kind: 'canvas' }), null); + assert.equal(searchShareOf(record({ query: 'q' })), null); + assert.equal(searchShareOf(record({ parts: [{ part_index: 0 }] })), null); + // A payload with no query reads the title, as the old table's rows did. + assert.equal(searchShareOf(record({ parts: [] }, 'live'))?.query, 'aid to Kenya'); +}); diff --git a/src/lib/studio/shareDialog.ts b/src/lib/studio/shareDialog.ts index 4e83431..199e47a 100644 --- a/src/lib/studio/shareDialog.ts +++ b/src/lib/studio/shareDialog.ts @@ -31,7 +31,7 @@ export type ShareRefusal = | { code: 'live_cards_blocked'; cardIds: string[] } | { code: 'live_too_many_cards'; limit: number; count: number } | { code: 'live_unavailable' } - | { code: 'guest_limit' } + | { code: 'share_limit' } | { code: 'failed' }; /** What `POST /api/share` came to: the share's id, or the refusal. */ @@ -55,7 +55,8 @@ export type Withdrawal = * Its URL is not shown yet. `ready`: the link to copy, in the mode it has * now, with the number of cards whose check failed for a passing reason. * `withdrawn`: no link, and why. `failed`: the share was not made and a - * second try may make it. `limited`: a guest has made today's GUEST_SHARES. + * second try may make it. `limited`: this network has made today's shares + * (dfo-apis' share allowance). */ export type ShareLink = | { status: 'checking'; id: string; checked: number; total: number } @@ -119,7 +120,7 @@ export const readShareAnswer = (ok: boolean, body: unknown): ShareAnswer => { return { refusal: { code, limit, count } }; } - return code === 'live_unavailable' || code === 'guest_limit' ? { refusal: { code } } : FAILED; + return code === 'live_unavailable' || code === 'share_limit' ? { refusal: { code } } : FAILED; }; /** @@ -150,7 +151,7 @@ const withdrawalOf = (refusal: ShareRefusal, cards: SharedCard[]): Withdrawal | return { reason: 'limit', limit: refusal.limit, count: refusal.count }; case 'live_unavailable': return { reason: 'unavailable' }; - case 'guest_limit': + case 'share_limit': case 'failed': return null; } @@ -212,7 +213,7 @@ export const openShareLink = async ( report( withdrawal ? { status: 'withdrawn', withdrawal } - : { status: mint.refusal.code === 'guest_limit' ? 'limited' : 'failed' }, + : { status: mint.refusal.code === 'share_limit' ? 'limited' : 'failed' }, ); return; diff --git a/src/lib/studio/slotCards.ts b/src/lib/studio/slotCards.ts new file mode 100644 index 0000000..1c562da --- /dev/null +++ b/src/lib/studio/slotCards.ts @@ -0,0 +1,407 @@ +import { + readShare, + readSlot, + SharesError, + type ShareRecord, + type SharesBackend, +} from '../chat/apisShares.ts'; +import type { SavedConversation } from '../chat/history.ts'; +import type { StudioShareKind } from '../chat/shareMode.ts'; +import type { CardRef, FrameRef, Widget } from '../chat/widgets.ts'; +import { dropEncodedLength, pick } from '../proxy/headers.ts'; +import { problem, type ProblemStatus } from '../proxy/problem.ts'; +import { gateOf } from './gate.ts'; +import { + BackendError, + bounded, + livePrincipal, + readComponent, + renderedExport, + renderOnFrame, + storedExport, + type LiveBackend, + type LiveCard, +} from './liveBackend.ts'; +import { widgetFromLiveCard, type SharedView } from './liveCards.ts'; + +/** + * The cards of a share held in dfo-apis, as the share pages draw them. + * + * A share's payload (src/lib/chat/sharePayload.ts) holds each slotted data + * card with `slot`, the 0-based index of the slot that draws it, and none of + * what it showed. `sharedViewOfRecord` gives every held card its slot's + * component revision as `ref`. A snapshot's cards are drawn here on the + * server from their revisions' public reads, which are the numbers the share + * was made with. A live share's cards leave the server held, and the browser + * fills each one (`refreshLiveCards`) from + * `POST /api/share/[id]/cards/[cardId]/refresh`, whose body is + * `slotRefreshResponse`. + * + * A live card is drawn on the frame its slot serves now: a read of dfo-apis' + * slot data route, which checks for newer data at most once per 15 minutes + * and records what it served in the share's history. When that is the + * frame the card revision stands on, the revision's own public read is the + * card. When the data moved, the revision is drawn on the served frame + * through dfo-apis' render route, which only the live-refresh credential + * (`LIVE_REFRESH_PRINCIPAL`) opens. Without it a moved card answers + * `live_unavailable`. + * + * No database, React, or path-alias dependency, so `node --test` imports it. + */ + +type Env = Record; + +export type SlotDeps = { + shares: SharesBackend; + // dfo-apis' render routes, or null without the live-refresh credential. + render: LiveBackend | null; +}; + +/** Whether this deployment can draw a live share's cards on moved data. */ +export const liveSharingAvailable = (env: Env = process.env): boolean => + livePrincipal(env) !== null; + +// The public component reads and exports, which carry no credential. +const publicReads = ({ shares }: SlotDeps): LiveBackend => ({ + apisBase: shares.base, + aiBase: '', + principal: '', + fetch: shares.fetch, +}); + +// A refresh's whole budget, as the site's old live refresh had it. +const REFRESH_DEADLINE_MS = 75_000; + +// One export call: dfo-apis stops a component route at 30 seconds. +const EXPORT_DEADLINE_MS = 35_000; + +// How many component reads one snapshot page has in flight at once: the +// dfo-apis default for `PRESENTATION_COMPONENTS_MAX_CONCURRENCY`. +const READ_CONCURRENCY = 4; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const sameFrame = (a: FrameRef | null, b: FrameRef): boolean => + a !== null && a.spec_id === b.spec_id && a.content_version === b.content_version; + +type PayloadWidget = Record & { id: string; kind: string }; + +const widgetsOf = (record: ShareRecord): PayloadWidget[] => { + const widgets = isRecord(record.payload) ? record.payload['widgets'] : undefined; + + return Array.isArray(widgets) + ? widgets.filter( + (widget): widget is PayloadWidget => + isRecord(widget) && + typeof widget['id'] === 'string' && + typeof widget['kind'] === 'string', + ) + : []; +}; + +// The 1-based slot a payload widget's `slot` index names, with its card. +const slotCardOf = ( + record: ShareRecord, + widget: PayloadWidget, +): { slot: number; ref: CardRef } | null => { + const index = widget['slot']; + + if (!Number.isSafeInteger(index)) { + return null; + } + + const slot = (index as number) + 1; + const card = record.slots.find((entry) => entry.slot === slot)?.card; + + return card + ? { slot, ref: { component_id: card.component_id, revision: card.revision } } + : null; +}; + +// A payload's held card in the shape the card components draw: the kind, +// id, turn, and chart shape it keeps, empty values, and its slot's `ref`. +const heldWidget = (widget: PayloadWidget, ref: CardRef | undefined): Widget => { + const { id } = widget; + const turn = widget['turn'] as number; + + switch (widget.kind) { + case 'key_number': + return { kind: 'key_number', id, turn, label: '', value: '', ref } as Widget; + case 'chart': + return { + kind: 'chart', + id, + turn, + chartType: widget['chartType'] === 'bar' ? 'bar' : 'line', + horizontal: widget['horizontal'] === true ? true : undefined, + title: '', + categories: [], + series: [], + ref, + } as Widget; + default: + return { kind: 'table', id, turn, title: '', columns: [], rows: [], ref } as Widget; + } +}; + +// Held in the payload: every slotted card, and in a live share every data +// card, slotted or not (`slotCanvas`). +const isHeld = (record: ShareRecord, widget: PayloadWidget): boolean => + widget.kind !== 'text' && (Number.isSafeInteger(widget['slot']) || record.mode === 'live'); + +/** + * A Studio share from dfo-apis as its page passes it to the client. Every + * held card has its slot's revision as `ref`. A snapshot's held cards are + * drawn from their revisions; a card whose read fails stays held, and the + * failure is logged. A live share's cards stay held for the browser. + */ +export const sharedViewOfRecord = async ( + deps: SlotDeps, + record: ShareRecord, +): Promise => { + const live = record.mode === 'live'; + const reads = bounded(publicReads(deps), REFRESH_DEADLINE_MS); + const gate = gateOf(READ_CONCURRENCY); + + const widgets = await Promise.all( + widgetsOf(record).map(async (widget) => { + if (!isHeld(record, widget)) { + return widget as unknown as Widget; + } + + const slotted = slotCardOf(record, widget); + const held = heldWidget(widget, slotted?.ref); + + if (live || !slotted) { + return held; + } + + try { + const { card } = await gate(() => readComponent(reads, slotted.ref)); + + return widgetFromLiveCard(held, card) ?? held; + } catch (error) { + console.error('share: a snapshot card could not be read', { + share: record.id, + slot: slotted.slot, + error: error instanceof BackendError ? error.code : String(error), + }); + + return held; + } + }), + ); + + return { + id: record.id, + kind: record.kind as StudioShareKind, + saved: { ...(record.payload as SavedConversation), widgets } as SavedConversation, + live, + createdAt: record.createdAt, + }; +}; + +type Drawn = + | { status: 'current' | 'redrawn'; card: LiveCard; ref: CardRef; frame: FrameRef } + | { status: 'error'; code: string; retryable?: true }; + +const errorOf = (error: unknown): Drawn => { + if (error instanceof BackendError || error instanceof SharesError) { + return { + status: 'error', + code: error.code, + ...(error.retryable ? { retryable: true as const } : {}), + }; + } + + throw error; +}; + +/** One slot of a live share drawn on the frame it serves now. */ +export const drawLiveSlot = async ( + deps: SlotDeps, + shareId: string, + slot: number, +): Promise => { + const signal = AbortSignal.timeout(REFRESH_DEADLINE_MS); + + try { + const served = await readSlot({ ...deps.shares, signal }, shareId, slot); + const ref = { component_id: served.componentId, revision: served.revision }; + const stored = await readComponent(bounded(publicReads(deps), REFRESH_DEADLINE_MS), ref); + + if (sameFrame(stored.frame, served.frame)) { + return { status: 'current', card: stored.card, ref, frame: served.frame }; + } + + if (!deps.render) { + return { status: 'error', code: 'live_unavailable' }; + } + + const drawn = await renderOnFrame( + bounded(deps.render, REFRESH_DEADLINE_MS), + ref, + served.frame, + ); + + return { status: 'redrawn', card: drawn.card, ref, frame: served.frame }; + } catch (error) { + return errorOf(error); + } +}; + +const NO_STORE = { 'Cache-Control': 'no-store' }; + +const notFound = (): Response => + problem({ + status: 404, + code: 'not_found', + detail: 'There is no such live card.', + retryable: false, + headers: NO_STORE, + }); + +// The slot of a live share's data card, or null: no such share, a share +// that is not live, or no data card with that id drawn by a slot. +const liveSlotOf = async ( + deps: SlotDeps, + shareId: string, + cardId: string, +): Promise => { + const record = await readShare(deps.shares, shareId); + + if (!record || record.mode !== 'live') { + return null; + } + + const widget = widgetsOf(record).find((entry) => entry.id === cardId); + + return (widget && slotCardOf(record, widget)?.slot) ?? null; +}; + +/** + * `POST /api/share/[id]/cards/[cardId]/refresh`. 404 unless the share is + * live and a slot draws the card. Otherwise 200 with `{status, card}` or + * `{status: 'error', code, retryable?}`, so one failing card leaves the rest + * of the page standing. A share id that is not dfo-apis' form answers 404. + */ +export const slotRefreshResponse = async ( + deps: SlotDeps, + shareId: string, + cardId: string, +): Promise => { + let slot: number | null; + + try { + slot = await liveSlotOf(deps, shareId, cardId); + } catch (error) { + if (error instanceof SharesError && error.status === 404) { + return notFound(); + } + + return Response.json(errorOf(error), { headers: NO_STORE }); + } + + if (slot === null) { + return notFound(); + } + + const drawn = await drawLiveSlot(deps, shareId, slot); + + return Response.json( + drawn.status === 'error' ? drawn : { status: drawn.status, card: drawn.card }, + { headers: NO_STORE }, + ); +}; + +const EXPORT_HEADERS = ['content-type', 'content-disposition', 'content-length']; + +// The statuses with which dfo-apis refuses an export request itself: a +// format it cannot write, or rows that are gone. +const REQUEST_REFUSALS = [400, 404, 422] as const satisfies readonly ProblemStatus[]; + +const noFile = ( + { code, retryable }: { code: string; retryable?: boolean }, + status: ProblemStatus = 502, +): Response => + problem({ + status, + code, + detail: 'The card has no data file to serve.', + retryable: retryable === true, + headers: NO_STORE, + }); + +/** + * `GET /api/share/[id]/cards/[cardId]/data?format=&bundle=`. The rows of a + * live card on the frame its slot serves now: the stored revision's public + * export when that is its own frame, else the render export on the served + * frame. A download that cannot be served answers a problem document under + * an error status, so a browser does not save it as the file. + */ +export const slotDataResponse = async ( + deps: SlotDeps, + shareId: string, + cardId: string, + query: URLSearchParams, +): Promise => { + let slot: number | null; + + try { + slot = await liveSlotOf(deps, shareId, cardId); + } catch (error) { + if (error instanceof SharesError && error.status === 404) { + return notFound(); + } + + const failed = errorOf(error) as { code: string; retryable?: boolean }; + + return noFile(failed); + } + + if (slot === null) { + return notFound(); + } + + const drawn = await drawLiveSlot(deps, shareId, slot); + + if (drawn.status === 'error') { + return noFile(drawn); + } + + const options = { + format: query.get('format') ?? undefined, + bundle: query.get('bundle') ?? undefined, + }; + + try { + const upstream = + drawn.status === 'redrawn' && deps.render + ? await renderedExport( + bounded(deps.render, EXPORT_DEADLINE_MS), + drawn.ref, + drawn.frame, + options, + ) + : await storedExport( + bounded(publicReads(deps), EXPORT_DEADLINE_MS), + drawn.ref, + options, + ); + const headers = new Headers(NO_STORE); + + pick(upstream.headers, EXPORT_HEADERS, headers); + dropEncodedLength(upstream.headers, headers); + + return new Response(upstream.body, { headers }); + } catch (error) { + if (!(error instanceof BackendError)) { + throw error; + } + + const status = REQUEST_REFUSALS.find((refusal) => refusal === error.status) ?? 502; + + return noFile(error, status); + } +}; diff --git a/src/lib/studio/test/shareDialog.test.ts b/src/lib/studio/test/shareDialog.test.ts index 97c104a..3c303b0 100644 --- a/src/lib/studio/test/shareDialog.test.ts +++ b/src/lib/studio/test/shareDialog.test.ts @@ -143,7 +143,7 @@ test("the site's answer to a new share is its id or the refusal it names", () => { refusal: { code: 'live_too_many_cards', limit: 20, count: 23 } }, ], [false, { code: 'live_unavailable' }, { refusal: { code: 'live_unavailable' } }], - [false, { code: 'guest_limit' }, { refusal: { code: 'guest_limit' } }], + [false, { code: 'share_limit' }, { refusal: { code: 'share_limit' } }], // What a second try can outlive, and every answer outside the contract. [ false, @@ -152,7 +152,7 @@ test("the site's answer to a new share is its id or the refusal it names", () => ], [false, { code: 'unauthorized' }, { refusal: { code: 'failed' } }], // The old `{ error }` body is no longer read. - [false, { error: 'guest_limit' }, { refusal: { code: 'failed' } }], + [false, { error: 'share_limit' }, { refusal: { code: 'failed' } }], [false, { code: 'live_cards_blocked', cards: [] }, { refusal: { code: 'failed' } }], [false, null, { refusal: { code: 'failed' } }], [true, { title: 'no id' }, { refusal: { code: 'failed' } }], @@ -382,7 +382,7 @@ test('a share the site refuses to make is withdrawn, failed or limited, with not { status: 'withdrawn', withdrawal: { reason: 'unavailable' } }, ], [{ refusal: { code: 'failed' } }, { status: 'failed' }], - [{ refusal: { code: 'guest_limit' } }, { status: 'limited' }], + [{ refusal: { code: 'share_limit' } }, { status: 'limited' }], ]; for (const [mint, expected] of rows) { diff --git a/src/lib/studio/test/slotCards.test.ts b/src/lib/studio/test/slotCards.test.ts new file mode 100644 index 0000000..8104569 --- /dev/null +++ b/src/lib/studio/test/slotCards.test.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ShareRecord, SharesBackend } from '../../chat/apisShares.ts'; +import type { LiveBackend } from '../liveBackend.ts'; +import { + sharedViewOfRecord, + slotDataResponse, + slotRefreshResponse, + type SlotDeps, +} from '../slotCards.ts'; +import { + APIS, + componentRead, + DATASET_FRAME, + MOVED_FRAME, + PRINCIPAL, + renderReply, +} from './fixtures/live-backend-stub.ts'; + +type Call = { method: string; path: string; headers: Headers }; + +const NOTE = { kind: 'text', id: 'n1', turn: 1, title: 'Mine', body: 'Words' }; + +const recordOf = (mode: 'live' | 'snapshot'): ShareRecord => ({ + id: 'Ab3dEf7h', + kind: 'canvas', + mode, + title: 'Aid', + slots: [{ slot: 1, card: { component_id: 'cmp_a', revision: 2, frame: DATASET_FRAME } }], + payload: { + id: 'conv-1', + sessionId: null, + componentRefs: [], + title: 'Aid', + updatedAt: 1, + messages: [], + turnCount: 1, + pinnedIds: [], + removedIds: [], + layout: {}, + widgets: [ + { kind: 'chart', id: 'c1', turn: 1, chartType: 'line', slot: 0 }, + // A data card a live share holds with no slot. + { kind: 'table', id: 'c2', turn: 1 }, + NOTE, + ], + }, + createdAt: '2026-10-28T10:00:00Z', +}); + +const shareBody = (record: ShareRecord) => ({ + id: record.id, + kind: record.kind, + mode: record.mode, + title: record.title, + revision: 1, + slots: record.slots.map((slot) => ({ ...slot, data_url: 'x' })), + payload: record.payload, + created_at: record.createdAt, +}); + +/** A dfo-apis holding `record`, whose slot 1 serves `served`. */ +const depsOf = ( + record: ShareRecord, + served = DATASET_FRAME, + { render = true } = {}, +): { deps: SlotDeps; calls: Call[] } => { + const calls: Call[] = []; + const fetcher: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + const call = { + method: init?.method ?? 'GET', + path: `${url.pathname}${url.search}`, + headers: new Headers(init?.headers), + }; + + calls.push(call); + + if (url.pathname === `/api/presentation/shares/${record.id}`) { + return Response.json(shareBody(record)); + } + + if (url.pathname === `/api/presentation/shares/${record.id}/slots/1/data`) { + return Response.json({ + share_id: record.id, + slot: 1, + mode: record.mode, + freshness: 'current', + component_id: 'cmp_a', + revision: 2, + frame: served, + rows: [], + }); + } + + if (url.pathname === '/api/presentation/components/cmp_a') { + return Response.json(componentRead('cmp_a', 2, DATASET_FRAME)); + } + + if (url.pathname === '/api/presentation/components/cmp_a/revisions/2/render') { + return Response.json(renderReply('cmp_a', 2, MOVED_FRAME)); + } + + if (url.pathname.endsWith('/data')) { + return new Response('year,value\n2024,1\n', { + headers: { 'content-type': 'text/csv' }, + }); + } + + return Response.json({ code: 'not_found' }, { status: 404 }); + }; + const shares: SharesBackend = { base: APIS, fetch: fetcher, nameCaller: () => {} }; + const live: LiveBackend = { apisBase: APIS, aiBase: '', principal: PRINCIPAL, fetch: fetcher }; + + return { deps: { shares, render: render ? live : null }, calls }; +}; + +test("a snapshot's slotted cards are drawn from their revisions on the server", async () => { + const { deps, calls } = depsOf(recordOf('snapshot')); + const view = await sharedViewOfRecord(deps, recordOf('snapshot')); + const [chart, table, note] = view.saved.widgets; + + assert.equal(view.live, false); + assert.ok(chart?.kind === 'chart'); + assert.ok(chart.series.length > 0 && chart.title); + assert.deepEqual(chart.ref, { component_id: 'cmp_a', revision: 2 }); + // A snapshot's card with no slot stays as stored, and so does a note. + assert.deepEqual(table, { kind: 'table', id: 'c2', turn: 1 }); + assert.deepEqual(note, NOTE); + // The revision's public read, with no slot read and no credential. + assert.deepEqual( + calls.map(({ path }) => path), + ['/api/presentation/components/cmp_a?revision=2'], + ); + assert.equal(calls[0]?.headers.get('x-dfo-principal'), null); +}); + +test("a live share's data cards leave the server held, with their slot's ref", async () => { + const { deps, calls } = depsOf(recordOf('live')); + const view = await sharedViewOfRecord(deps, recordOf('live')); + + assert.equal(view.live, true); + assert.deepEqual(view.saved.widgets, [ + { + kind: 'chart', + id: 'c1', + turn: 1, + chartType: 'line', + horizontal: undefined, + title: '', + categories: [], + series: [], + ref: { component_id: 'cmp_a', revision: 2 }, + }, + { kind: 'table', id: 'c2', turn: 1, title: '', columns: [], rows: [], ref: undefined }, + NOTE, + ]); + assert.equal(calls.length, 0); +}); + +test('a live card on its own frame is its revision, with no render', async () => { + const { deps, calls } = depsOf(recordOf('live')); + const answer = await (await slotRefreshResponse(deps, 'Ab3dEf7h', 'c1')).json(); + + assert.equal(answer.status, 'current'); + assert.equal(answer.card.component_id, 'cmp_a'); + assert.ok(calls.some(({ path }) => path.endsWith('/slots/1/data?format=json'))); + assert.ok(!calls.some(({ path }) => path.endsWith('/render'))); +}); + +test('a live card whose slot serves moved data is drawn on that frame', async () => { + const { deps, calls } = depsOf(recordOf('live'), MOVED_FRAME); + const answer = await (await slotRefreshResponse(deps, 'Ab3dEf7h', 'c1')).json(); + const render = calls.find(({ path }) => path.endsWith('/render')); + + assert.equal(answer.status, 'redrawn'); + assert.deepEqual(answer.card.payload.provenance.inputs, [MOVED_FRAME]); + assert.equal(render?.headers.get('x-dfo-principal'), PRINCIPAL); +}); + +test('a moved live card answers live_unavailable without the credential', async () => { + const { deps } = depsOf(recordOf('live'), MOVED_FRAME, { render: false }); + + assert.deepEqual(await (await slotRefreshResponse(deps, 'Ab3dEf7h', 'c1')).json(), { + status: 'error', + code: 'live_unavailable', + }); +}); + +test('a card no live slot draws is 404', async () => { + for (const [mode, cardId] of [ + ['snapshot', 'c1'], + ['live', 'c2'], + ['live', 'n1'], + ['live', 'nope'], + ] as const) { + const { deps } = depsOf(recordOf(mode)); + const response = await slotRefreshResponse(deps, 'Ab3dEf7h', cardId); + + assert.equal(response.status, 404, `${mode} ${cardId}`); + } + + const { deps } = depsOf(recordOf('live')); + + assert.equal((await slotRefreshResponse(deps, 'Zz9yXx8w', 'c1')).status, 404); +}); + +test("a live card's rows come from the export of the frame its slot serves", async () => { + const query = new URLSearchParams({ format: 'csv', bundle: 'false' }); + + for (const [served, expected] of [ + [ + DATASET_FRAME, + '/api/presentation/components/cmp_a/revisions/2/data?format=csv&bundle=false', + ], + [ + MOVED_FRAME, + `/api/presentation/components/cmp_a/revisions/2/render/data?format=csv&bundle=false&spec_id=${MOVED_FRAME.spec_id}&content_version=${MOVED_FRAME.content_version}`, + ], + ] as const) { + const { deps, calls } = depsOf(recordOf('live'), served); + const response = await slotDataResponse(deps, 'Ab3dEf7h', 'c1', query); + + assert.equal(response.status, 200); + assert.equal(await response.text(), 'year,value\n2024,1\n'); + assert.equal(calls.at(-1)?.path, expected); + } +}); From 67bda0a1b261e233073f66037894e4d2bb367de4 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 28 Oct 2026 22:47:53 +0100 Subject: [PATCH 04/18] feat(share): draw live cards from dfo-apis' slot card route A live card on /s/ and /embed/ reads its slot's card from dfo-apis, drawn on the frame the slot serves, so the site needs no render principal; the refresh route is a GET that passes the ETag both ways, so the browser revalidates instead of redrawing. A live share's cards are checked before the share is created, so a failing card never leaves a share behind. Share create sends a minted write token and retries once with the same body, and a payload over dfo-apis' limit answers 413 payload_too_large. --- docs/development.md | 8 +- .../[id]/cards/[cardId]/refresh/route.ts | 19 +- src/app/api/share/route.ts | 43 +-- src/app/studio/[id]/page.tsx | 10 +- src/app/studio/page.tsx | 5 +- src/components/organisms/Studio/Studio.tsx | 5 - .../organisms/Studio/hooks/useLiveCards.ts | 2 +- .../organisms/Studio/modals/ShareModal.tsx | 40 +-- .../organisms/Studio/modals/StudioModals.tsx | 6 +- src/lib/chat/apisShares.ts | 137 +++++--- src/lib/chat/shareRefusal.ts | 42 +++ src/lib/chat/shareStore.ts | 7 +- src/lib/chat/test/apisShares.test.ts | 142 +++++--- src/lib/chat/test/shareRefusal.test.ts | 33 ++ src/lib/studio/cardDownload.ts | 3 + src/lib/studio/liveBackend.ts | 27 +- src/lib/studio/liveCards.ts | 4 +- src/lib/studio/shareDialog.ts | 124 +------ src/lib/studio/slotCards.ts | 302 +++++++++++------- src/lib/studio/test/liveCards.test.ts | 7 +- src/lib/studio/test/shareDialog.test.ts | 225 +------------ src/lib/studio/test/slotCards.test.ts | 259 +++++++++++---- 22 files changed, 772 insertions(+), 678 deletions(-) create mode 100644 src/lib/chat/shareRefusal.ts create mode 100644 src/lib/chat/test/shareRefusal.test.ts diff --git a/docs/development.md b/docs/development.md index da437ac..a723b3c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -32,11 +32,9 @@ at a mail catcher. To open the dev server from a phone/tablet on the same network, put the machine's LAN IP in `DEV_ORIGINS`. -Live Studio shares need `LIVE_REFRESH_PRINCIPAL`, the credential dfo-apis -and dfo-ai ask of a card refresh, set to the same value on all three. With -it empty the Share dialog offers Snapshot only. A live card's refreshed -answer stands for `LIVE_SHARE_WINDOW_S` seconds (default 300, at most 900) -before the next viewer asks the back ends again. +Live Studio shares need no credential. A live card is dfo-apis' public +slot card route (`GET /api/presentation/shares/{id}/slots/{n}`), which +checks for newer data at most once per 15 minutes. ### Reach the dev APIs diff --git a/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts b/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts index 04ff56e..981a3df 100644 --- a/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts +++ b/src/app/api/share/[id]/cards/[cardId]/refresh/route.ts @@ -3,12 +3,21 @@ import { slotRefreshResponse } from '@/lib/studio/slotCards'; type Context = { params: Promise<{ id: string; cardId: string }> }; -// POST /api/share//cards//refresh → { status, card?, code?, retryable? } +// GET /api/share//cards//refresh → { status, card?, code?, retryable? } // One data card of a live canvas or card share, drawn on the frame its slot -// in dfo-apis serves now. Public, like the link itself. See -// src/lib/studio/slotCards. -export const POST = async (request: Request, { params }: Context) => { +// in dfo-apis serves now. Public, like the link itself. dfo-apis' ETag goes +// back with it and the browser's If-None-Match goes on to dfo-apis, so an +// unchanged card answers 304. POST answers alike for pages loaded before +// the refresh became a GET. See src/lib/studio/slotCards. +export const GET = async (request: Request, { params }: Context) => { const { id, cardId } = await params; - return slotRefreshResponse(slotDepsFor(request.headers), id, cardId); + return slotRefreshResponse( + slotDepsFor(request.headers), + id, + cardId, + request.headers.get('if-none-match'), + ); }; + +export const POST = GET; diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index acd98a5..867a29c 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -3,43 +3,20 @@ import { after, NextResponse, type NextRequest } from 'next/server'; import { getUser } from '@/lib/auth/session'; import { SharesError } from '@/lib/chat/apisShares'; +import { sharesRefusal } from '@/lib/chat/shareRefusal'; import { studioShareBody } from '@/lib/chat/sharePayload'; import { readStudioShareRequest, shareCreateProps } from '@/lib/chat/shareMode'; -import { makeShare } from '@/lib/chat/shareStore'; +import { makeShare, slotDepsFor } from '@/lib/chat/shareStore'; import { readBoundedText } from '@/lib/proxy/boundedBody'; import { problem, requestInvalidProblem } from '@/lib/proxy/problem'; import { track } from '@/lib/events/track'; import { createSearchShare } from '@/lib/search/searchShare'; import { readSearchShareRequest } from '@/lib/search/searchShareRequest'; +import { liveCardsRefusal } from '@/lib/studio/slotCards'; // The largest share request read, in bytes; a larger one gets 413. const MAX_SHARE_BODY = 10_000_000; -// What a refusal of dfo-apis' share create says to the browser. A spent -// share allowance (counted by dfo-apis per network address) is 429 -// `share_limit`, with dfo-apis' Retry-After. Everything else is a fault the -// sharer cannot fix, 502, retryable when dfo-apis said so. -const sharesRefusal = (error: SharesError): Response => { - if (error.code === 'allowance_exhausted') { - return problem({ - status: 429, - code: 'share_limit', - detail: "This network has made today's shares. Try again tomorrow.", - retryable: true, - headers: error.retryAfterS ? { 'Retry-After': String(error.retryAfterS) } : undefined, - }); - } - - console.error('share: dfo-apis refused the share', { code: error.code, status: error.status }); - - return problem({ - status: 502, - code: 'transient_unavailable', - detail: 'The share could not be made right now. Try again in a minute.', - retryable: error.retryable || error.status === undefined || error.status >= 500, - }); -}; - // POST /api/share { kind: 'search', mode, query, parts } → { id } // A search result's link: dataset references only, no numbers. The search // page is public, so an account is optional here. A signed-in sharer's @@ -110,8 +87,10 @@ const shareSearch = async (body: any, headers: Headers) => { // network address (429 `share_limit`). // `mode` is 'live' or 'snapshot' for a canvas or a card and 'snapshot' for a // conversation. A request that names none stores a snapshot. A live share is -// refused when a data card it shows names no component revision -// (400 `live_cards_blocked`, naming the cards). +// checked before it is made (`liveCardsRefusal`), so none is left behind +// when its cards fail: 400 `live_cards_blocked` names a data card it shows +// that names no component revision, or one that cannot follow newer data, +// and 400 `live_too_many_cards` gives the cap. // Every refusal is a problem document (src/lib/proxy/problem). export const POST = async (request: NextRequest) => { const text = await readBoundedText(request.body, MAX_SHARE_BODY).catch(() => ''); @@ -161,6 +140,14 @@ export const POST = async (request: NextRequest) => { return requestInvalidProblem('This card has no saved revision, so it cannot be shared.'); } + const refusal = share.latest + ? await liveCardsRefusal(slotDepsFor(request.headers), share) + : null; + + if (refusal) { + return refusal; + } + const user = await getUser(); let created: { id: string }; diff --git a/src/app/studio/[id]/page.tsx b/src/app/studio/[id]/page.tsx index 5e98882..e0f5c91 100644 --- a/src/app/studio/[id]/page.tsx +++ b/src/app/studio/[id]/page.tsx @@ -3,7 +3,6 @@ import type { Metadata } from 'next'; import Studio from '@/components/organisms/Studio/Studio'; import { getSession } from '@/lib/auth/session'; import { pickExamples } from '@/lib/studio/examples'; -import { liveSharingAvailable } from '@/lib/studio/slotCards'; export const metadata: Metadata = { title: 'DFO Studio', @@ -16,14 +15,7 @@ const StudioConversationPage = async ({ params }: { params: Promise<{ id: string const { id } = await params; const session = await getSession(); - return ( - - ); + return ; }; export default StudioConversationPage; diff --git a/src/app/studio/page.tsx b/src/app/studio/page.tsx index f240499..c81da0b 100644 --- a/src/app/studio/page.tsx +++ b/src/app/studio/page.tsx @@ -3,7 +3,6 @@ import type { Metadata } from 'next'; import Studio from '@/components/organisms/Studio/Studio'; import { getSession } from '@/lib/auth/session'; import { pickExamples } from '@/lib/studio/examples'; -import { liveSharingAvailable } from '@/lib/studio/slotCards'; export const metadata: Metadata = { title: 'DFO Studio', @@ -16,9 +15,7 @@ export const metadata: Metadata = { const StudioPage = async () => { const session = await getSession(); - return ( - - ); + return ; }; export default StudioPage; diff --git a/src/components/organisms/Studio/Studio.tsx b/src/components/organisms/Studio/Studio.tsx index b8abae6..69d40c6 100644 --- a/src/components/organisms/Studio/Studio.tsx +++ b/src/components/organisms/Studio/Studio.tsx @@ -78,9 +78,6 @@ export type StudioProps = { // A gallery item: the same read-only sheet, plus the one action a // curated session exists for — carrying it on in your own Studio. continuable?: boolean; - // The site can refresh a live share's cards (`liveSharingAvailable`, - // read on the server), so the Share dialog offers Live Data. - liveSharing?: boolean; // The landing's example prompts for this visit (pickExamples, drawn by // the page). A share has no landing and passes none. examples?: readonly string[]; @@ -116,7 +113,6 @@ const Studio = ({ openId, guest = false, continuable = false, - liveSharing = false, examples = [], }: StudioProps) => { const sharedSaved = shared?.saved; @@ -744,7 +740,6 @@ const Studio = ({ title={title} firstPrompt={firstPrompt} shareData={shareData} - liveSharing={liveSharing} createShare={save.createShare} guest={guest} /> diff --git a/src/components/organisms/Studio/hooks/useLiveCards.ts b/src/components/organisms/Studio/hooks/useLiveCards.ts index 5291813..343ab87 100644 --- a/src/components/organisms/Studio/hooks/useLiveCards.ts +++ b/src/components/organisms/Studio/hooks/useLiveCards.ts @@ -56,7 +56,7 @@ const settledOf = (held: Widget | undefined, outcome: LiveCardOutcome): Settled * The data cards of a live share, filled in the browser. `widgets` is the * canvas as the share page sent it, whose data cards are held (see * `sharedViewOf`). Each one is refreshed through - * `POST /api/share/[id]/cards/[cardId]/refresh` and drawn as soon as its own + * `GET /api/share/[id]/cards/[cardId]/refresh` and drawn as soon as its own * answer arrives. * * Returns the widgets to render, with every refreshed card drawn in place diff --git a/src/components/organisms/Studio/modals/ShareModal.tsx b/src/components/organisms/Studio/modals/ShareModal.tsx index b81fef0..24be221 100644 --- a/src/components/organisms/Studio/modals/ShareModal.tsx +++ b/src/components/organisms/Studio/modals/ShareModal.tsx @@ -22,14 +22,12 @@ import { fetchCardData, } from '@/lib/studio/cardDownload'; import { - checkingText, openShareLink, type ShareLink, type ShareLinkDeps, shareLinkKey, shareModeFor, snapshotShare, - uncheckedText, withdrawalText, } from '@/lib/studio/shareDialog'; @@ -41,8 +39,8 @@ import { * when `offerLive` is set; Chat + Canvas is always a snapshot, and so is * every share when it is not. Links are made lazily, one per tab and mode * (`openShareLink` in src/lib/studio/shareDialog), and kept for the dialog's - * lifetime. A live link is shown once every data card it holds has been - * checked, and withdrawn when one cannot refresh. Data offers each card's + * lifetime. The site checks a live link's data cards before it makes the + * share, and a link whose cards cannot show live data is withdrawn. Data offers each card's * stored rows as a file from the export route (src/lib/studio/cardDownload). */ @@ -58,9 +56,9 @@ type ShareModalProps = { // The conversation's title (embed