From 7fff253dcfc2989186b08f8ca4530f9dfe9ab5d7 Mon Sep 17 00:00:00 2001 From: Stephen Zweibel Date: Mon, 7 Sep 2026 17:55:49 -0400 Subject: [PATCH 1/7] docs: reconcile architecture and Workers proposals --- README.md | 4 + .../2026-09-07-architecture-reconciliation.md | 254 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-architecture-reconciliation.md diff --git a/README.md b/README.md index 186e1c1..b05503a 100644 --- a/README.md +++ b/README.md @@ -195,3 +195,7 @@ See `CLAUDE.md` for full deployment details and server layout. ## Contributing See `AGENTS.md` for coding style, testing guidelines, and commit conventions. + +Architecture proposals #11 and #12 are reconciled in the +[September 7 design review](docs/superpowers/specs/2026-09-07-architecture-reconciliation.md), +which separates current behavior, proposed consolidation, and unresolved host/data decisions. diff --git a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md new file mode 100644 index 0000000..99b4f48 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md @@ -0,0 +1,254 @@ +# Slide Maker architecture reconciliation + +Date: 2026-09-07. Status: review proposal; no implementation or rollout approval. + +This document reconciles [PR #11](https://github.com/CUNY-AI-Lab/slide-maker/pull/11) +and [PR #12](https://github.com/CUNY-AI-Lab/slide-maker/pull/12) against source. The +useful first step is a shared, atomic deck edit path on the existing host. Document +storage, a new renderer, tool-based AI editing, and a Workers migration remain +separate decisions. None is required just to eliminate browser request fan-out. + +Recommendation: use this reconciliation as the successor review baseline for both +PRs. Keep their detailed proposals available as design history; do not treat their +phase lists or configuration examples as an accepted implementation plan. The +September 7 revisions addressed the original reviews' dual writes, retained +parsers, per-mutation log keys, DNS compatibility, and approximate rate limiting. +The remaining conflicts below affect correctness or current service contracts. + +## Evidence and current behavior + +The inspected slide-maker base is `4cc0bf4b5b4d4a5d0372c7c1c525ab14ed54755c`. +The proposals are pinned at [#11, `7519049`](https://github.com/CUNY-AI-Lab/slide-maker/blob/75190496c1dacdfd258e4e65563bbebd032cb5d6/docs/superpowers/specs/2026-09-03-unified-architecture-design.md) +and [#12, `89af32f`](https://github.com/CUNY-AI-Lab/slide-maker/blob/89af32f3c634eea6467e4ff048dd09d65ef61e4a/docs/superpowers/specs/2026-09-03-cloudflare-transition-design.md). +Their full bodies, reviews, discussion comments, and diffs were read; neither had +inline review threads. These are source observations, not deployed-state checks. + +| Concern | What the inspected source implements | +| --- | --- | +| Deck persistence | [Schema](../../../apps/api/src/db/schema.ts) has normalized `slides` and `content_blocks`, including block `source_node_ids` and slide timestamps. It has no document column, version column, or commit log. | +| Edits | [Client mutations](../../../apps/web/src/lib/utils/mutations.ts) dispatch several REST calls for `applyTemplate`. [Deck routes](../../../apps/api/src/routes/decks.ts) and [plan apply](../../../apps/api/src/routes/plan.ts) own persistence and generate IDs. Several individual routes already use SQLite transactions. | +| Authorization | Mutating deck routes check `deck_access` and reject viewers before checking the [edit lock](../../../apps/api/src/middleware/deck-lock.ts). The lock is not an access grant. Owner deletion and metadata editing have different lock behavior from slide/block editing. | +| AI | [Chat](../../../apps/api/src/routes/chat.ts) streams text and stores separately extracted fences. [Shared types](../../../packages/shared/src/mutations.ts) do not describe every client action. [Provider adapters](../../../apps/api/src/providers/) yield text; chat estimates tokens from text length. | +| Rendering | [Svelte renderers](../../../apps/web/src/lib/components/renderers/), [server HTML](../../../apps/api/src/export/html-renderer.ts), and separate [export artifact code](../../../apps/api/src/export/artifacts.ts) implement overlapping behavior. [ZIP export](../../../apps/api/src/export/index.ts) uses local files and `archiver`. | +| Uploads | [Files](../../../apps/api/src/routes/files.ts) counts bytes on disk, including existing sidecars, before writing. `uploaded_files` has neither byte size nor a reservation state. | +| Host and checks | [Svelte configuration](../../../apps/web/svelte.config.js) uses `adapter-auto` and a build-time `/slide-maker` base. [CI](../../../.github/workflows/ci.yml) builds and runs Vitest plus shell checks; Playwright is outside that workflow. The [deployment workflow](../../../.github/workflows/deploy.yml) is manually dispatched and documents its Tailscale reachability problem. | + +Related contracts were read at the following commits. Their deployed versions, +bindings, and institutional hostname readiness were not probed here. + +| Owner | Inspected source | +| --- | --- | +| Identity | [`65c74fc`, verifier and identity contract](https://github.com/CUNY-AI-Lab/cail-identity/tree/65c74fc53bb8549bd1906bdec55c75c3b273041f) | +| Doorway | [`8565e73`, route policy](https://github.com/CUNY-AI-Lab/cail-doorway/blob/8565e73f032e310d04c9955dfa2f00bdb54583f9/config/route-policy.json) and [Admission RPC type](https://github.com/CUNY-AI-Lab/cail-doorway/blob/8565e73f032e310d04c9955dfa2f00bdb54583f9/src/config.ts) | +| Admission | [`ec1e8be`, `AdmissionResolver`](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/ec1e8bec45c7b9477d4ce1a1b3b43a065a30b069/src/index.ts) and [administrator boundary](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/ec1e8bec45c7b9477d4ce1a1b3b43a065a30b069/README.md) | +| Gateway | [`f65eda6`, runtime contract](https://github.com/CUNY-AI-Lab/cail-gateway/blob/f65eda6195bffbac52c228f1ce3586d739578039/docs/gateway-contract.md) and [quota contract](https://github.com/CUNY-AI-Lab/cail-gateway/blob/f65eda6195bffbac52c228f1ce3586d739578039/docs/quota-design.md) | +| Kale | [`15e2c74`, release surface](https://github.com/CUNY-AI-Lab/cail-deploy/blob/15e2c742bad0d7190a2b1e9e4f58052a2d8f7075/README.md) and [artifact contract](https://github.com/CUNY-AI-Lab/cail-deploy/blob/15e2c742bad0d7190a2b1e9e4f58052a2d8f7075/src/domain/contracts.ts) | + +## Shared edit direction + +Carry forward one authoritative writer for deck edits. A reducer and a shared +loader can operate over today's tables; adopting JSON storage or a Durable Object +is a separate decision. Each implementation increment must identify every caller, +including chat, planner, direct canvas actions, metadata edits, and stale browser +clients. Moving current UI calls alone does not retire a server write route. +Remove replaced routes when their replacement takes traffic, or make any required +transition route delegate to the same writer with a defined retirement point. + +The two proposals agree that a commit contains a whole batch, advances the deck +version once, and records inverses in undo order. Preserve that unit in responses, +history, and any future stream: #11's single `mutation`/`inverse` SSE example must +not represent #12's multi-mutation commit. A proposal is uncommitted data; it does +not advance the version or append history. Undo is a new edit with a new version. + +Before implementing the writer, resolve these details in its focused PR: + +- **Determinism and effects.** Today the server allocates IDs, `searchImage` + performs network and file operations, and `updateTheme` creates a separate + theme resource. A pure deck reducer cannot promise to undo those effects. + Define which commands prepare resources outside the reducer, when they may + run in proposal mode, and how rejected preparations are cleaned up. Resolved + mutations need stable IDs and concrete template/artifact inputs so replay and + inverses do not consult a changed catalog or generate different IDs. +- **Version versus content.** Apply/inverse identity can restore deck content; + committed undo cannot restore the old revision number or append-only history. + Specify where version increments happen, how empty batches/no-ops behave, and + what the property test compares. A reducer identity test alone proves neither + persistence atomicity nor replay from historical inputs. +- **Retries and conflicts.** A lost response after a successful commit must be + distinguishable from a rejected edit. Choose the request identity and duplicate + handling before enabling automatic retries. A stale edit or inverse may + overwrite a later collaborator's change; define reconciliation instead of + promising unconditional refetch-and-reapply. Acceptance needs the proposal's + reviewed base or renewed review of changed effects, not just a fresh version. +- **Access and lock semantics.** The authenticated actor comes from the server, + never a client `actor` field. Preserve deck-role checks for every transport. + Decide whether metadata edits retain today's lock exception and how lock, + version, validation, and persistence checks share the commit boundary. + +## Preserve data before changing its representation + +#11 Phase 1 creates `deck_commits` and advances versions. Phase 2 then inserts a +`system:import` row at the deck's current version with the same base version. For +any deck edited in Phase 1, that primary key already exists. It also contradicts +the rule that a commit advances the version once. + +Record a representation migration separately from edit history, or specify a +distinct advancing migration event and its replay semantics. The implementation +must choose one; it must neither overwrite an existing commit nor duplicate its +key. Preserve pre-migration history and define the initial replay snapshot and +schema/registry versions. #11's later suggestion to compact old log rows also +needs a retention and snapshot decision before claiming permanent undo history. + +The proposed document sketch omits slide timestamps and puts source-node metadata +on the slide, while current `source_node_ids` belongs to each block. The round +trip must account for every persisted field and relationship, including nulls, +string `split_ratio`, ordering, unknown existing data, and timestamps. Do not +silently coerce or discard rows to make a new schema validate. Use an explicit +canonical comparison when representations intentionally differ. + +A migration plan must freeze all affected writers, not only `/mutations`, take a +consistent SQLite backup, verify every deck and referenced upload, and rehearse +restore on disposable state. Specify when writes resume. Restoring the old backup +after accepting new writes loses those edits; keeping the old tables for a release +cycle does not solve that. Rollback after writes resume requires a demonstrated +reverse migration/replay path or an explicitly accepted data-loss boundary. +Dropping tables and reclaiming server storage are later authorized operations. + +## Workers and CAIL integration decisions + +### Identity and administrator access + +The inspected Doorway policy uses the `cail-doorway.ailab-452.workers.dev` origin +and issuer and contains no slide-maker route. #12's institutional hostname and +issuer are a proposed cutover, not current source configuration. Consume the +reviewed Identity/Doorway contract at implementation time and coordinate the +receiver before enabling a caller. Do not copy a speculative issuer into a +production verifier. + +Current `AdmissionResolver.resolveMembership({ subject })` returns exactly +`{ ok: true, expiresAt, revision }` or a denial; Doorway validates those exact +keys. It returns neither `accessRole` nor `budgetScope`. #12's proposed +`membership.accessRole === 'admin'` check therefore cannot authorize an admin. +Admission's own admin HTTP surface has a separate audience and bootstrap +allowlist. A product cannot borrow that audience or infer admin access from +ordinary membership. Admission and slide-maker owners must agree on a supported +product-admin capability, or choose an operator-only administration plan before +removing the existing admin functions. Binding the present resolver alone does +not resolve this decision. + +Legacy-account migration also needs its own design. #12 deletes Argon2 and +password fields while requiring a password-based legacy claim. Choose verified +operator linking or a bounded claim mechanism with an implemented verifier, +collision policy, expiry, and deletion criteria. Preserve ownership across all +user references: deck access and ownership, uploads, token usage, resource +creators, locks/presence, and future history actors. Define whether IDs are mapped +or rewritten and rehearse conflicts. Display names are ambiguous search labels; +capability links change who can acquire access and require an explicit product +decision about redemption, expiry, and revocation. + +### Gateway transport and accounting + +Use the current Gateway contract as the receiver contract. Its successful Chat +streams are largely provider-owned; consume through EOF so trailing usage and +errors remain observable. #12's claim that usage always arrives in a final chunk +is not a portable guarantee. Missing usage must remain unknown, not zero or a +character estimate. Structured logging must retain useful bounded diagnostics +without treating AI Gateway logs as a replacement for a payload transcript: the +Gateway explicitly disables prompt/response payload logging. + +The managed app key proposed for #12's Node phase represents an app budget; it +does not establish the signed-in person's budget by adding `x-cail-app` or a deck +session header. Choose whether an app-funded intermediate phase is wanted, or +wait for verified person credentials. Never derive a human budget identity from +local email or an arbitrary header. Model capability filtering and tool protocol +tests are appropriate; model wording/quality diagnostics do not become admission +or release gates. Do not preserve a second fenced-parser path after switching to +tool-based editing. + +Gateway remains the spend authority, but #12's claim of exact spend enforcement +is wrong. Its [quota contract](https://github.com/CUNY-AI-Lab/cail-gateway/blob/f65eda6195bffbac52c228f1ce3586d739578039/docs/quota-design.md) +and [Cloudflare spend-limit documentation](https://developers.cloudflare.com/ai-gateway/features/spend-limits/) +describe estimates and eventual consistency; concurrent requests can exceed a +limit before enforcement catches up. `/v1/quota` is display-only. Do not add a +second local spend authority to compensate. + +### State ownership and uploads + +#12 keeps name, slug, theme, and `updated_at` in D1 while #11's document also owns +them in the deck object. A Durable Object transaction does not include D1 or R2. +Before selecting that split, identify the authority for each field, gallery +consistency expectations, and recovery after any one write fails. A derived +gallery index needs a replay/rebuild rule; two editable copies are dual writes. +Specify deck deletion and access revocation across stores and open connections. +Keep access checks at the real mutation/read boundary; the proposed one-line RPC +is insufficient as an authorization description. WebSocket fan-out, reconnect, +duplicate suppression, and stale authorization are additional work, not a free +consequence of choosing Durable Objects. Existing polling can remain the sole +transport until a replacement is implemented and verified. + +The upload proposal cannot sum sizes from the current database: that column does +not exist. Define byte accounting for files, extraction sidecars, downloaded +images, deletes, and concurrent uploads. If using D1 plus R2, choose an atomic +reservation/conditional-write and compensation approach supported by the actual +APIs; a read followed by an insert is not a quota transaction. Exercise failed +object writes, interrupted finalization, and retries without leaking quota or +orphaning referenced files. The Workers Rate Limiting binding may throttle these +requests, but it is [per-location and approximate](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). + +### Runtime, extraction, and release + +#12 correctly acknowledges that Workers does not implement DNS `lookup`; +[`resolve4` and `resolve6` are supported](https://developers.cloudflare.com/workers/runtime-apis/nodejs/dns/). +However, the current Node guard checks each of two answers for private addresses; +it does not compare answer sets, and its caller does not pin the returned IP. +The [current test](../../../tests/ssrf-guard.test.ts) only tests `isPrivateIp`. +A replacement needs tests of the actual URL-validation/fetch boundary, mixed +A/AAAA answers, absent record families, DNS failures, redirects, and cancellation. +State the remaining resolution-to-connection gap without claiming that Worker +placement or lack of a private binding proves a URL safe. + +The sample `env.staging` in #12 is incomplete: Wrangler environment variables and +bindings such as services, R2, and Durable Objects are +[not inherited](https://developers.cloudflare.com/workers/wrangler/configuration/#non-inheritable-keys). +Generate and check complete staging configuration with isolated data and identity +keys. A public staging browser cannot simply send a private identity JWT header +on normal navigation; define its access path separately from a test issuer in a +component harness. An empty staging base also does not validate the production +`/slide-maker` mount. Test that mount, asset routing, CSP, API paths, and direct +receiver denial before a doorway cutover. + +Replacing file extraction with `AI.toMarkdown`, rendering with Svelte SSR, or +ZIP generation with `fflate` needs a focused runtime fixture exercise. Those +changes are candidates, not proven drop-ins. Measure export memory/CPU and bundle +size on representative decks, including large embedded artifact source and +uploads; a slide-count cap alone does not bound their serialized size. The sample +configuration's CPU limit and the proposal's latency/size estimates are not +measurements. + +The April [Kale publishing design](2026-04-11-kale-deploy-integration-design.md) +is also a proposal. Choose a publishing product and access/revocation policy +separately; #11's Kale target and #12's public R2 target cannot both be treated as +settled. Do not create public buckets, DNS, or change existing published URLs as +a side effect of renderer consolidation. Consult Kale's current artifact/release +contract when that choice is scoped. + +## Sequence and acceptance boundaries + +Both proposals number unrelated work as phases 0–5/6. #12's gateway phase depends +on the AI package introduced only in #11 Phase 5; its transcript deletion also +precedes #11's proposed transcript-based model diagnostics. Review independent +increments by their actual prerequisites rather than interleaving these numbers. + +| Increment | Scope and required evidence before advancing | +| --- | --- | +| Existing-host foundations | Capture representative export fixtures and unify duplicated parsing/loading where callers justify it. Verify real template replacement and rejection behavior. This work does not require a host decision. | +| Atomic edit path | Resolve the edit decisions above. Exercise the actual browser/API against disposable SQLite: compound success, mid-batch failure, viewer denial, lock/version conflicts, response loss, and undo. Retire or delegate every replaced writer. Normalized storage is a valid stopping point. | +| Optional representation migration | Approve field mapping and snapshot/history semantics; prove complete round-trip and restore with edits, references, and uploads. Freeze, resume, and post-resume recovery must be explicit before touching existing data. | +| Renderer/artifact/AI consolidation | Each package earns its boundary through current callers. Compare visible canvas/preview/export behavior and ZIP contents; exercise protocol cancellation, proposal acceptance, usage absence, and trailing errors. Recorded streams validate parsing; they do not prove live model behavior. | +| Optional Worker integration | Resolve identity/admin/account mapping, storage authority, gateway budget identity, and staging access first. Run caller-to-receiver tests against actual local Worker services with isolated stores, then coordinate receiver deployment before routing callers. Stub resolvers/test issuers do not prove CUNY login or live access. | +| Authorized cutover | Use the repository's reviewed CI release path, serialize releases, deploy the exact checked commit, and read back one serving version at 100%. Verify the user action plus public/campus routing, private file boundaries, and existing published URLs. A backup or health response alone is insufficient acceptance. | + +This successor changes documentation only. No schema, credential, route, service +binding, application behavior, or published deck changes here. Existing build and +test CI can establish that the application is unchanged; it cannot validate any +of the proposed implementations or settle the owner decisions above. From a600af6be7decdf260f62713bf99b6966cbd1691 Mon Sep 17 00:00:00 2001 From: Stephen Zweibel Date: Mon, 7 Sep 2026 18:05:42 -0400 Subject: [PATCH 2/7] docs: clarify file access and version ownership decisions --- .../2026-09-07-architecture-reconciliation.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md index 99b4f48..98b0f10 100644 --- a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md +++ b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md @@ -30,7 +30,7 @@ inline review threads. These are source observations, not deployed-state checks. | Authorization | Mutating deck routes check `deck_access` and reject viewers before checking the [edit lock](../../../apps/api/src/middleware/deck-lock.ts). The lock is not an access grant. Owner deletion and metadata editing have different lock behavior from slide/block editing. | | AI | [Chat](../../../apps/api/src/routes/chat.ts) streams text and stores separately extracted fences. [Shared types](../../../packages/shared/src/mutations.ts) do not describe every client action. [Provider adapters](../../../apps/api/src/providers/) yield text; chat estimates tokens from text length. | | Rendering | [Svelte renderers](../../../apps/web/src/lib/components/renderers/), [server HTML](../../../apps/api/src/export/html-renderer.ts), and separate [export artifact code](../../../apps/api/src/export/artifacts.ts) implement overlapping behavior. [ZIP export](../../../apps/api/src/export/index.ts) uses local files and `archiver`. | -| Uploads | [Files](../../../apps/api/src/routes/files.ts) counts bytes on disk, including existing sidecars, before writing. `uploaded_files` has neither byte size nor a reservation state. | +| Uploads | [Files](../../../apps/api/src/routes/files.ts) counts bytes on disk, including existing sidecars, before writing. `uploaded_files` has neither byte size nor a reservation state. File GET is unauthenticated: possession of the CUID URL grants access; its private cache header is not an authorization check. | | Host and checks | [Svelte configuration](../../../apps/web/svelte.config.js) uses `adapter-auto` and a build-time `/slide-maker` base. [CI](../../../.github/workflows/ci.yml) builds and runs Vitest plus shell checks; Playwright is outside that workflow. The [deployment workflow](../../../.github/workflows/deploy.yml) is manually dispatched and documents its Tailscale reachability problem. | Related contracts were read at the following commits. Their deployed versions, @@ -71,6 +71,10 @@ Before implementing the writer, resolve these details in its focused PR: inverses do not consult a changed catalog or generate different IDs. - **Version versus content.** Apply/inverse identity can restore deck content; committed undo cannot restore the old revision number or append-only history. + #11 stores both `DeckDocument.version` and `decks.version`; #12 similarly keeps + a version in both the deck row inside the object and its document. Choose one + persisted authority, or derive and validate the serialized copy from it; define + rejection/recovery if they diverge rather than trusting whichever was read first. Specify where version increments happen, how empty batches/no-ops behave, and what the property test compares. A reducer identity test alone proves neither persistence atomicity nor replay from historical inputs. @@ -186,6 +190,12 @@ duplicate suppression, and stale authorization are additional work, not a free consequence of choosing Durable Objects. Existing polling can remain the sole transport until a replacement is implemented and verified. +File access is an explicit product decision. Today's file GET uses the URL as a +bearer capability without checking login or `deck_access`. Choose whether to +preserve that behavior or require deck authorization, including how revocation +works. Exercise existing canvas and shared-deck file URLs, preview/export asset +rewriting, and previously exported or published decks before changing that boundary. + The upload proposal cannot sum sizes from the current database: that column does not exist. Define byte accounting for files, extraction sidecars, downloaded images, deletes, and concurrent uploads. If using D1 plus R2, choose an atomic @@ -246,7 +256,7 @@ increments by their actual prerequisites rather than interleaving these numbers. | Optional representation migration | Approve field mapping and snapshot/history semantics; prove complete round-trip and restore with edits, references, and uploads. Freeze, resume, and post-resume recovery must be explicit before touching existing data. | | Renderer/artifact/AI consolidation | Each package earns its boundary through current callers. Compare visible canvas/preview/export behavior and ZIP contents; exercise protocol cancellation, proposal acceptance, usage absence, and trailing errors. Recorded streams validate parsing; they do not prove live model behavior. | | Optional Worker integration | Resolve identity/admin/account mapping, storage authority, gateway budget identity, and staging access first. Run caller-to-receiver tests against actual local Worker services with isolated stores, then coordinate receiver deployment before routing callers. Stub resolvers/test issuers do not prove CUNY login or live access. | -| Authorized cutover | Use the repository's reviewed CI release path, serialize releases, deploy the exact checked commit, and read back one serving version at 100%. Verify the user action plus public/campus routing, private file boundaries, and existing published URLs. A backup or health response alone is insufficient acceptance. | +| Authorized cutover | Establish and use a reviewed CI release path: the existing staging workflow documents that GitHub runners cannot reach its Tailscale host. Serialize releases, deploy the exact checked commit, and read back one serving version at 100%. Verify the user action plus public/campus routing, the chosen file-access boundary, and existing published URLs. A backup or health response alone is insufficient acceptance. | This successor changes documentation only. No schema, credential, route, service binding, application behavior, or published deck changes here. Existing build and From a34cd4a7de3bc8fb9e6e1670f3d20b377c11649d Mon Sep 17 00:00:00 2001 From: Stephen Zweibel Date: Mon, 7 Sep 2026 18:06:40 -0400 Subject: [PATCH 3/7] docs: specify uploaded asset compatibility checks --- .../specs/2026-09-07-architecture-reconciliation.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md index 98b0f10..9bf303f 100644 --- a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md +++ b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md @@ -193,8 +193,9 @@ transport until a replacement is implemented and verified. File access is an explicit product decision. Today's file GET uses the URL as a bearer capability without checking login or `deck_access`. Choose whether to preserve that behavior or require deck authorization, including how revocation -works. Exercise existing canvas and shared-deck file URLs, preview/export asset -rewriting, and previously exported or published decks before changing that boundary. +works. Exercise persisted image/carousel URLs in the canvas, preview, thumbnails, +Files panel, and shared sessions. ZIP exports already rewrite uploads to bundled +assets; verify that bundling still works after an authorization or R2 change. The upload proposal cannot sum sizes from the current database: that column does not exist. Define byte accounting for files, extraction sidecars, downloaded From 52a33a69579dcf18308b9bad6195f0e5fc5273d4 Mon Sep 17 00:00:00 2001 From: Stephen Zweibel Date: Mon, 7 Sep 2026 18:29:20 -0400 Subject: [PATCH 4/7] Correct current Doorway and Admission contract assumptions --- .../2026-09-07-architecture-reconciliation.md | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md index 9bf303f..9c20f07 100644 --- a/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md +++ b/docs/superpowers/specs/2026-09-07-architecture-reconciliation.md @@ -39,8 +39,8 @@ bindings, and institutional hostname readiness were not probed here. | Owner | Inspected source | | --- | --- | | Identity | [`65c74fc`, verifier and identity contract](https://github.com/CUNY-AI-Lab/cail-identity/tree/65c74fc53bb8549bd1906bdec55c75c3b273041f) | -| Doorway | [`8565e73`, route policy](https://github.com/CUNY-AI-Lab/cail-doorway/blob/8565e73f032e310d04c9955dfa2f00bdb54583f9/config/route-policy.json) and [Admission RPC type](https://github.com/CUNY-AI-Lab/cail-doorway/blob/8565e73f032e310d04c9955dfa2f00bdb54583f9/src/config.ts) | -| Admission | [`ec1e8be`, `AdmissionResolver`](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/ec1e8bec45c7b9477d4ce1a1b3b43a065a30b069/src/index.ts) and [administrator boundary](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/ec1e8bec45c7b9477d4ce1a1b3b43a065a30b069/README.md) | +| Doorway | [`f149a53`, institutional route policy](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/f149a53d3d9570ebb8d20c57f13a6d848fc09eba/apps/doorway/config/route-policy.json) in the shared Doorway/Admission repository | +| Admission | [`f149a53`, resolver result schema](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/f149a53d3d9570ebb8d20c57f13a6d848fc09eba/packages/admission-contract/src/identity.ts) and [administrator boundary](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/f149a53d3d9570ebb8d20c57f13a6d848fc09eba/apps/admission/README.md) | | Gateway | [`f65eda6`, runtime contract](https://github.com/CUNY-AI-Lab/cail-gateway/blob/f65eda6195bffbac52c228f1ce3586d739578039/docs/gateway-contract.md) and [quota contract](https://github.com/CUNY-AI-Lab/cail-gateway/blob/f65eda6195bffbac52c228f1ce3586d739578039/docs/quota-design.md) | | Kale | [`15e2c74`, release surface](https://github.com/CUNY-AI-Lab/cail-deploy/blob/15e2c742bad0d7190a2b1e9e4f58052a2d8f7075/README.md) and [artifact contract](https://github.com/CUNY-AI-Lab/cail-deploy/blob/15e2c742bad0d7190a2b1e9e4f58052a2d8f7075/src/domain/contracts.ts) | @@ -122,23 +122,28 @@ Dropping tables and reclaiming server storage are later authorized operations. ### Identity and administrator access -The inspected Doorway policy uses the `cail-doorway.ailab-452.workers.dev` origin -and issuer and contains no slide-maker route. #12's institutional hostname and -issuer are a proposed cutover, not current source configuration. Consume the +At the inspected shared-repository pin, Doorway's canonical origin is +`https://tools.ailab.gc.cuny.edu` and its identity issuer is +`https://tools.ailab.gc.cuny.edu/cail-sso`; the policy contains no slide-maker +route. #12's institutional hostname and issuer therefore match current source, +while its slide-maker route and receiver cutover remain proposed. Consume the reviewed Identity/Doorway contract at implementation time and coordinate the -receiver before enabling a caller. Do not copy a speculative issuer into a -production verifier. - -Current `AdmissionResolver.resolveMembership({ subject })` returns exactly -`{ ok: true, expiresAt, revision }` or a denial; Doorway validates those exact -keys. It returns neither `accessRole` nor `budgetScope`. #12's proposed -`membership.accessRole === 'admin'` check therefore cannot authorize an admin. -Admission's own admin HTTP surface has a separate audience and bootstrap -allowlist. A product cannot borrow that audience or infer admin access from -ordinary membership. Admission and slide-maker owners must agree on a supported -product-admin capability, or choose an operator-only administration plan before -removing the existing admin functions. Binding the present resolver alone does -not resolve this decision. +receiver before enabling that caller. + +Current `AdmissionResolver.resolveMembership({ subject })` success returns +exactly `{ ok: true, expiresAt, revision, accessRole, budgetScope }`; its strict +shared schema defines `accessRole` as `member` or `admin` and `budgetScope` as +`person`, `person-plus`, or `admin`. #12's proposed +`membership.accessRole === 'admin'` check is structurally compatible with that +resolver contract. Admission's own admin HTTP surface separately requires the +`cail:tools-admission-admin` audience and an active, unexpired +`accessRole: admin` membership; it has no bootstrap subject allowlist or identity +exception route. Slide-maker still needs an explicit product policy for whether +Admission administrators inherit its admin functions or it keeps a distinct +product-admin role. It should validate the shared resolver schema and derive the +subject from server-authenticated identity rather than copying the contract or +accepting role claims from the browser. No new Admission transport is required +merely to carry the current role and budget fields. Legacy-account migration also needs its own design. #12 deletes Argon2 and password fields while requiring a password-based legacy claim. Choose verified @@ -256,7 +261,7 @@ increments by their actual prerequisites rather than interleaving these numbers. | Atomic edit path | Resolve the edit decisions above. Exercise the actual browser/API against disposable SQLite: compound success, mid-batch failure, viewer denial, lock/version conflicts, response loss, and undo. Retire or delegate every replaced writer. Normalized storage is a valid stopping point. | | Optional representation migration | Approve field mapping and snapshot/history semantics; prove complete round-trip and restore with edits, references, and uploads. Freeze, resume, and post-resume recovery must be explicit before touching existing data. | | Renderer/artifact/AI consolidation | Each package earns its boundary through current callers. Compare visible canvas/preview/export behavior and ZIP contents; exercise protocol cancellation, proposal acceptance, usage absence, and trailing errors. Recorded streams validate parsing; they do not prove live model behavior. | -| Optional Worker integration | Resolve identity/admin/account mapping, storage authority, gateway budget identity, and staging access first. Run caller-to-receiver tests against actual local Worker services with isolated stores, then coordinate receiver deployment before routing callers. Stub resolvers/test issuers do not prove CUNY login or live access. | +| Optional Worker integration | Use the current Doorway issuer and Admission resolver schema; resolve slide-maker's admin/account-mapping policy, storage authority, gateway budget identity, and staging access first. Run caller-to-receiver tests against actual local Worker services with isolated stores, then coordinate receiver deployment before routing callers. Stub resolvers/test issuers do not prove CUNY login or live access. | | Authorized cutover | Establish and use a reviewed CI release path: the existing staging workflow documents that GitHub runners cannot reach its Tailscale host. Serialize releases, deploy the exact checked commit, and read back one serving version at 100%. Verify the user action plus public/campus routing, the chosen file-access boundary, and existing published URLs. A backup or health response alone is insufficient acceptance. | This successor changes documentation only. No schema, credential, route, service From ba48f0e9e5876c71c13890daac79230bd3f8e8ff Mon Sep 17 00:00:00 2001 From: Stephen Zweibel Date: Mon, 7 Sep 2026 22:46:43 -0400 Subject: [PATCH 5/7] feat: integrate institutional identity and Gateway receiver --- .env.example | 30 +- .github/workflows/ci.yml | 8 + .npmrc | 1 + CLAUDE.md | 4 + README.md | 44 +- apps/api/package.json | 10 +- apps/api/scripts/link-identity.ts | 11 + apps/api/scripts/migrate-identity.ts | 4 + apps/api/src/app.ts | 102 ++++ apps/api/src/auth/identity.ts | 20 + apps/api/src/db/identity-mapping.ts | 19 + apps/api/src/db/schema.ts | 1 + apps/api/src/env.ts | 40 -- apps/api/src/fleet/log.ts | 33 ++ apps/api/src/fleet/readiness.ts | 26 + apps/api/src/index.ts | 100 +--- apps/api/src/middleware/auth.ts | 20 + apps/api/src/providers/anthropic.ts | 47 -- apps/api/src/providers/bedrock.ts | 68 --- apps/api/src/providers/index.ts | 116 +++- apps/api/src/providers/openrouter.ts | 34 -- apps/api/src/routes/admin.ts | 8 +- apps/api/src/routes/auth.ts | 22 +- apps/api/src/routes/chat.ts | 166 +----- apps/api/src/routes/plan.ts | 52 +- apps/api/src/routes/providers.ts | 61 +- .../components/admin/UserApprovalQueue.svelte | 139 +---- .../lib/components/chat/GatewayQuota.svelte | 37 ++ .../lib/components/chat/ModelSelector.svelte | 2 + apps/web/src/lib/stores/auth.ts | 1 + apps/web/src/lib/utils/sse.ts | 79 ++- apps/web/src/routes/(app)/+page.svelte | 10 +- .../routes/(app)/thumbnail/[id]/+server.ts | 3 + apps/web/svelte.config.js | 2 +- docs/fleet-integration.md | 65 +++ e2e/fleet-browser.scenario.ts | 60 ++ e2e/fleet-server.mts | 80 +++ e2e/fleet.config.ts | 7 + package.json | 6 - pnpm-lock.yaml | 537 ++---------------- tests/browser-chat-stream.test.ts | 60 ++ tests/fleet-gateway-integration.test.ts | 236 ++++++++ tests/fleet-readiness.test.ts | 28 + tests/gateway-stream.test.ts | 65 +++ tests/identity-auth.test.ts | 100 ++++ tests/identity-mapping.test.ts | 24 + tests/test_structure.sh | 2 - 47 files changed, 1329 insertions(+), 1261 deletions(-) create mode 100644 .npmrc create mode 100644 apps/api/scripts/link-identity.ts create mode 100644 apps/api/scripts/migrate-identity.ts create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/auth/identity.ts create mode 100644 apps/api/src/db/identity-mapping.ts create mode 100644 apps/api/src/fleet/log.ts create mode 100644 apps/api/src/fleet/readiness.ts delete mode 100644 apps/api/src/providers/anthropic.ts delete mode 100644 apps/api/src/providers/bedrock.ts delete mode 100644 apps/api/src/providers/openrouter.ts create mode 100644 apps/web/src/lib/components/chat/GatewayQuota.svelte create mode 100644 docs/fleet-integration.md create mode 100644 e2e/fleet-browser.scenario.ts create mode 100644 e2e/fleet-server.mts create mode 100644 e2e/fleet.config.ts create mode 100644 tests/browser-chat-stream.test.ts create mode 100644 tests/fleet-gateway-integration.test.ts create mode 100644 tests/fleet-readiness.test.ts create mode 100644 tests/gateway-stream.test.ts create mode 100644 tests/identity-auth.test.ts create mode 100644 tests/identity-mapping.test.ts diff --git a/.env.example b/.env.example index c9d1877..0f889ec 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ DATABASE_URL=file:./data/slide-maker.db SESSION_SECRET=change-me-to-random-string # Email — pick one provider: ses or smtp -# Set EMAIL_PROVIDER=ses to use Amazon SES (uses AWS credentials from Bedrock config) +# Set EMAIL_PROVIDER=ses to use Amazon SES (uses AWS credentials for email only) EMAIL_PROVIDER=ses SES_FROM_EMAIL=ailab@gc.cuny.edu # SES_REGION=us-east-1 # defaults to AWS_REGION if not set @@ -20,25 +20,14 @@ SMTP_USER= SMTP_PASS= SMTP_FROM=noreply@ailab.gc.cuny.edu -# AI Providers -ANTHROPIC_API_KEY= -OPENROUTER_API_KEY= -# Optional: restrict provider at runtime (also supports CLI flag --provider) -# Valid values: bedrock | anthropic | openrouter -AI_PROVIDER= - -# AWS Bedrock (optional) -# Use either long-lived keys or assume-role via your environment. The SDK -# resolves credentials automatically; set region to enable Bedrock models. -AWS_REGION= -# AWS_ACCESS_KEY_ID= -# AWS_SECRET_ACCESS_KEY= -# AWS_SESSION_TOKEN= - -# Admin-only model toggles (optional) -# If set, these models appear only for admin users. -BEDROCK_SONNET_46_MODEL_ID= -ANTHROPIC_SONNET_46_MODEL_ID= +# Institutional identity: public JWKS JSON snapshot and exact issuer URL +CAIL_IDENTITY_JWKS= +CAIL_IDENTITY_ISSUER=https://tools.ailab.gc.cuny.edu +# Gateway URL must be a reviewed reachable receiver; never provider credentials. +CAIL_GATEWAY_URL=https://tools.ailab.gc.cuny.edu +# Private readiness probe; random operator-held token and exact deployed commit. +READINESS_TOKEN= +RELEASE_SHA= # Web Search TAVILY_API_KEY= @@ -50,3 +39,4 @@ PEXELS_API_KEY= # Frontend PUBLIC_API_URL=http://localhost:3001 PUBLIC_URL=http://localhost:5173 +# Production: PUBLIC_URL=https://tools.ailab.gc.cuny.edu/slide-maker diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76e3d4a..fb4d054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + packages: read concurrency: group: slide-maker-ci-${{ github.workflow }}-${{ github.ref }} @@ -34,9 +35,16 @@ jobs: with: node-version: 20.x cache: pnpm + registry-url: https://npm.pkg.github.com + scope: "@cuny-ai-lab" - name: Install dependencies run: pnpm install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check frontend types + run: pnpm --filter @slide-maker/web check - name: Build workspaces run: pnpm build diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..51f4345 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@cuny-ai-lab:registry=https://npm.pkg.github.com diff --git a/CLAUDE.md b/CLAUDE.md index 01ccdd2..5b3076c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Check `TODO.md` for the current task list when planning work, but default to the operator's instructions, especially if they involve birds. +## Institutional integration authority + +The current receiver contract and release proposal are in [docs/fleet-integration.md](docs/fleet-integration.md). They supersede the historical direct-provider, local-token-cap, password-production-auth and manual-deployment descriptions below. Model calls now use Gateway; production identity maps verified canonical subjects to unchanged local IDs. Do not deploy or activate a mount from historical staging instructions. + ## What This Is A chat-driven slide builder for the CUNY AI Lab. Users create presentation decks through AI conversation + direct on-canvas editing. Three-panel UI: chat + outline (left), canvas (center), resources (right). Produces HTML slide decks matching the CUNY AI Lab's actual deck framework (section-based navigation, step reveals, carousel sync). diff --git a/README.md b/README.md index 186e1c1..39be61a 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ Open http://localhost:5173, log in with a seeded admin account, create a deck, a ## Prerequisites -- Node.js 18+ +- Node.js 20+ - [pnpm](https://pnpm.io/) 9.x (`corepack enable && corepack prepare pnpm@9.15.0 --activate`) ## Setup 1. **Install dependencies** + The pinned CAIL packages require authorized GitHub Packages read access. The checked-in `.npmrc` contains registry resolution only; supply credentials through your user configuration or CI environment. + ```bash pnpm install ``` @@ -35,17 +37,7 @@ Open http://localhost:5173, log in with a seeded admin account, create a deck, a ln -s ../../.env apps/api/.env # required — API loads env from its own CWD ``` - At minimum, set `SESSION_SECRET` and one AI provider: - - | Provider | Required env vars | - |----------|-------------------| - | OpenRouter | `OPENROUTER_API_KEY` | - | Anthropic | `ANTHROPIC_API_KEY` | - | AWS Bedrock | `AWS_REGION` + valid AWS credentials | - - Optional: - - `TAVILY_API_KEY` and/or `BRAVE_API_KEY` for web search (Tavily preferred) - - `PEXELS_API_KEY` for openly-licensed image search + Configure institutional identity and Gateway as described in [the receiver guide](docs/fleet-integration.md). Local password sessions are development-only; production requires signed institutional identity and an operator-verified local account mapping. Provider keys are not used by Slide Maker. 3. **Initialize the database** @@ -67,25 +59,9 @@ Starts both services via Turborepo: | Web (SvelteKit) | http://localhost:5173 | | API (Hono) | http://localhost:3001 | -### Provider selection - -Run with a specific AI provider: - -```bash -pnpm dev:anthropic # Anthropic SDK -pnpm dev:bedrock # AWS Bedrock -pnpm dev:openrouter # OpenRouter -``` - -Or set `AI_PROVIDER=bedrock|anthropic|openrouter` in your `.env`. +### Models and identity -### Available models - -| Provider | Models | -|----------|--------| -| Anthropic | Claude Sonnet 4, Haiku 4.5 (Sonnet 4.6 admin-only, requires env var) | -| Bedrock | Haiku 4.5, Sonnet 4.6 | -| OpenRouter | Kimi K2.5, GLM 5, Gemini 3.1 Flash, Qwen 3.5 Flash | +Chat and planning use the CAIL Gateway catalog and verified Gateway credentials. Gateway owns quota admission and accounting. Before serving an existing database with this version, run the additive identity migration and verify account mappings; see [the operator and release guide](docs/fleet-integration.md). ## Features @@ -152,11 +128,11 @@ Exports a self-contained ZIP with: ### Auth -Email/password with `*.cuny.edu` domain gating. Registration, email verification, admin approval. Lucia v3 session cookies. Rate-limited login (5/15min) and registration (3/hr). +Production uses verified institutional JWTs mapped explicitly to existing local user IDs. Product roles and sharing remain local. Development retains Lucia password sessions. ### Admin dashboard -User management, role assignment, token usage tracking with monthly charts and per-user caps (default 1M tokens, annual reset). +User management and local product role assignment. Gateway owns current model quota; existing local usage records are historical. ## Project structure @@ -188,9 +164,7 @@ npx vitest --watch # watch mode ## Deployment -Staging at `tools.cuny.qzz.io/slide-maker`. Deploy via `./deploy-staging.sh` (requires Tailscale/CUNY VPN). Traffic: Cloudflare -> Caddy (TLS) -> Nginx -> PM2 (API on 3004, web on 4173). - -See `CLAUDE.md` for full deployment details and server layout. +Institutional integration is source-only until the receiver release and private reachability are reviewed. See [the release proposal](docs/fleet-integration.md). Historical staging scripts are not authorization to deploy this branch. ## Contributing diff --git a/apps/api/package.json b/apps/api/package.json index 7193187..a0d6642 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,12 +8,15 @@ "build": "tsc", "db:push": "drizzle-kit push", "db:seed": "tsx src/db/seed.ts", - "seed:admin": "tsx src/db/seed.ts --admin" + "seed:admin": "tsx src/db/seed.ts --admin", + "identity:migrate": "tsx scripts/migrate-identity.ts", + "identity:link": "tsx scripts/link-identity.ts" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", - "@aws-sdk/client-bedrock-runtime": "^3.676.0", "@aws-sdk/client-ses": "^3.1029.0", + "@cuny-ai-lab/cail-client": "6.2.2", + "@cuny-ai-lab/cail-identity": "5.2.6", + "@cuny-ai-lab/cail-log": "0.6.4", "@hono/node-server": "^1.19.11", "@lucia-auth/adapter-drizzle": "^1.1.0", "@node-rs/argon2": "^2.0.0", @@ -27,7 +30,6 @@ "lucia": "^3.2.0", "mammoth": "^1.7.2", "nodemailer": "^6.10.0", - "openai": "^4.80.0", "pdf-parse": "^1.1.1", "rate-limiter-flexible": "^10.0.1", "sanitize-html": "^2.17.2", diff --git a/apps/api/scripts/link-identity.ts b/apps/api/scripts/link-identity.ts new file mode 100644 index 0000000..a04e082 --- /dev/null +++ b/apps/api/scripts/link-identity.ts @@ -0,0 +1,11 @@ +import { sqlite } from '../src/db/index.js' +import { migrateIdentityMapping, linkVerifiedIdentity } from '../src/db/identity-mapping.js' + +const [userId, subject, verification] = process.argv.slice(2) +if (!userId || !subject || verification !== '--operator-verified') { + throw new Error('Usage: bun apps/api/scripts/link-identity.ts --operator-verified; verify account ownership independently first, never by email alone') +} +migrateIdentityMapping(sqlite) +linkVerifiedIdentity(sqlite, userId, subject) +console.log('Verified identity linked; existing ownership and local role preserved') +sqlite.close() diff --git a/apps/api/scripts/migrate-identity.ts b/apps/api/scripts/migrate-identity.ts new file mode 100644 index 0000000..d09f04b --- /dev/null +++ b/apps/api/scripts/migrate-identity.ts @@ -0,0 +1,4 @@ +import { sqlite } from '../src/db/index.js' +import { migrateIdentityMapping } from '../src/db/identity-mapping.js' +migrateIdentityMapping(sqlite) +sqlite.close() diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..4dab83c --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,102 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { bodyLimit } from 'hono/body-limit' +import { csrf } from 'hono/csrf' +import { env } from './env.js' +import auth from './routes/auth.js' +import admin from './routes/admin.js' +import { decksRouter } from './routes/decks.js' +import exportRouter from './routes/export.js' +import previewRouter from './routes/preview.js' +import chat from './routes/chat.js' +import providers from './routes/providers.js' +import resources from './routes/resources.js' +import artifactRouter from './routes/artifact.js' +import sharing from './routes/sharing.js' +import filesRouter from './routes/files.js' +import search from './routes/search.js' +import planRouter from './routes/plan.js' +import debugRouter from './routes/debug.js' +import { correlationFromHeaders } from '@cuny-ai-lab/cail-log' +import { logRequestFailure } from './fleet/log.js' +import { readinessRouter } from './fleet/readiness.js' +import { loadIdentityConfigs } from './auth/identity.js' +import { sqlite } from './db/index.js' + +const app = new Hono() + +// Global error handler — ensures CORS headers are present on unhandled exceptions +// so the browser can read the error instead of showing "NetworkError" +app.onError((err, c) => { + const origin = c.req.header('origin') + if (origin && env.allowedOrigins.includes(origin)) { + c.header('Access-Control-Allow-Origin', origin) + c.header('Access-Control-Allow-Credentials', 'true') + } + logRequestFailure(correlationFromHeaders(c.req.raw.headers).request_id) + return c.json({ error: 'Internal server error' }, 500) +}) + +app.use('/*', cors({ + origin: env.allowedOrigins, + credentials: true, +})) + +app.use('/*', csrf({ origin: env.allowedOrigins })) +// 11MB for file upload routes (10MB file + overhead) +app.use('/api/decks/:id/files', bodyLimit({ maxSize: 11 * 1024 * 1024 })) +// Apply a 2MB limit to all other routes, but explicitly skip the upload endpoint +const smallBodyLimit = bodyLimit({ maxSize: 2 * 1024 * 1024 }) +app.use('*', async (c, next) => { + const { pathname } = new URL(c.req.url) + // Skip when path matches /api/decks/:deckId/files exactly + if (/^\/api\/decks\/[^/]+\/files$/.test(pathname)) { + return next() + } + return smallBodyLimit(c, next) +}) + +app.get('/', (c) => c.json({ name: 'slide-wiz-dev', status: 'ok' })) +app.get('/api/health', (c) => c.json({ status: 'ok' })) +app.route('/internal', readinessRouter(() => { + sqlite.prepare('SELECT canonical_subject FROM users LIMIT 1').all() +}, loadIdentityConfigs)) + +// Serve static files (Leaflet, etc.) for artifact iframes +app.get('/api/static/:file', async (c) => { + const file = c.req.param('file') + const fs = await import('node:fs') + const path = await import('node:path') + const { fileURLToPath } = await import('node:url') + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const filePath = path.join(__dirname, '..', 'static', path.basename(file)) + if (!fs.existsSync(filePath)) return c.text('Not found', 404) + const content = fs.readFileSync(filePath, 'utf-8') + const ext = path.extname(file) + const type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/plain' + c.header('Content-Type', type) + c.header('Cache-Control', 'public, max-age=86400') + c.header('Access-Control-Allow-Origin', '*') + return c.body(content) +}) + +app.route('/api/auth', auth) +app.route('/api/admin', admin) +app.route('/api/decks', filesRouter) +app.route('/api/decks', decksRouter) +app.route('/api/decks', exportRouter) +app.route('/api/decks', previewRouter) +app.route('/api/decks', sharing) +app.route('/api/decks', planRouter) +app.route('/api/chat', chat) +app.route('/api/providers', providers) +app.route('/api', resources) +app.route('/api', artifactRouter) +app.route('/api/search', search) + +// Mount debug routes only when explicitly enabled (requires admin auth) +if (process.env.ENABLE_DEBUG_ROUTES === 'true') { + app.route('/api/debug', debugRouter) +} + +export default app diff --git a/apps/api/src/auth/identity.ts b/apps/api/src/auth/identity.ts new file mode 100644 index 0000000..a699a4d --- /dev/null +++ b/apps/api/src/auth/identity.ts @@ -0,0 +1,20 @@ +import { loadIdentityVerifierConfig, readIdentityKeyring, verifyIdentityJwt, verifyKeyringGatewayJwt, type IdentityVerifierConfig } from '@cuny-ai-lab/cail-identity' + +export async function loadIdentityConfigs() { + const input = { jwks: process.env.CAIL_IDENTITY_JWKS, issuer: process.env.CAIL_IDENTITY_ISSUER } + const [app, gateway] = await Promise.all([ + loadIdentityVerifierConfig({ ...input, expectedAudience: 'cail:slide-maker' }), + loadIdentityVerifierConfig({ ...input, expectedAudience: 'cail:gateway' }), + ]) + if (!app.ok || !gateway.ok) throw new Error('Invalid CAIL identity verifier configuration') + return { app: app.config, gateway: gateway.config } +} + +export async function verifyRequestIdentity(headers: Headers, configs: { app: IdentityVerifierConfig; gateway: IdentityVerifierConfig }) { + const keyring = readIdentityKeyring(headers) + if (!keyring) return null + const app = await verifyIdentityJwt(keyring.appJwt, configs.app) + if (!app) return null + if (keyring.gatewayJwt && !await verifyKeyringGatewayJwt(keyring, configs.gateway, app.subject)) return null + return { subject: app.subject, gatewayToken: keyring.gatewayJwt } +} diff --git a/apps/api/src/db/identity-mapping.ts b/apps/api/src/db/identity-mapping.ts new file mode 100644 index 0000000..8f96097 --- /dev/null +++ b/apps/api/src/db/identity-mapping.ts @@ -0,0 +1,19 @@ +import type Database from 'better-sqlite3' +import { isCailSubject } from '@cuny-ai-lab/cail-identity' + +/** Additive migration: existing user IDs, ownership and local roles are untouched. */ +export function migrateIdentityMapping(sqlite: Database.Database) { + sqlite.transaction(() => { + const columns = sqlite.prepare('PRAGMA table_info(users)').all() as { name: string }[] + if (!columns.some((column) => column.name === 'canonical_subject')) { + sqlite.exec('ALTER TABLE users ADD COLUMN canonical_subject TEXT') + } + sqlite.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_canonical_subject_unique ON users(canonical_subject)') + })() +} + +export function linkVerifiedIdentity(sqlite: Database.Database, userId: string, subject: string) { + if (!isCailSubject(subject)) throw new Error('A verified canonical CAIL subject is required') + const result = sqlite.prepare('UPDATE users SET canonical_subject = ? WHERE id = ? AND canonical_subject IS NULL').run(subject, userId) + if (result.changes !== 1) throw new Error('User missing or already linked; refusing replacement') +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7ebcacb..ed57a6a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' // ── Auth ── export const users = sqliteTable('users', { + canonicalSubject: text('canonical_subject').unique(), id: text('id').primaryKey(), email: text('email').notNull().unique(), name: text('name').notNull(), diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 7dcaac4..c49deb5 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -1,24 +1,6 @@ import 'dotenv/config' import { resolveApiHost } from './listen-host.js' -// Accept provider selection via env or CLI flag: --provider -function parseProvider(input?: string | null): '' | 'bedrock' | 'anthropic' | 'openrouter' { - const v = (input || '').trim().toLowerCase() - if (v === 'bedrock' || v === 'anthropic' || v === 'openrouter') return v - return '' -} - -function getCliArg(name: string): string | undefined { - const idx = process.argv.findIndex((a) => a === `--${name}`) - if (idx !== -1 && idx + 1 < process.argv.length) return process.argv[idx + 1] - const pref = `--${name}=` - const m = process.argv.find((a) => a.startsWith(pref)) - if (m) return m.slice(pref.length) - return undefined -} - -const cliProvider = getCliArg('provider') - export const env = { host: resolveApiHost(), port: Number(process.env.API_PORT ?? 3001), @@ -36,16 +18,6 @@ export const env = { from: process.env.SES_FROM_EMAIL ?? 'ailab@gc.cuny.edu', enabled: process.env.EMAIL_PROVIDER === 'ses', }, - anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', - openrouterApiKey: process.env.OPENROUTER_API_KEY ?? '', - // AWS Bedrock uses the standard AWS credential chain; we detect enablement via region - awsRegion: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || process.env.BEDROCK_REGION || '', - bedrockApiKey: process.env.BEDROCK_API_KEY || '', - // Optional, to expose admin-only higher tier models - bedrockSonnet46ModelId: process.env.BEDROCK_SONNET_46_MODEL_ID || '', - anthropicSonnet46Id: process.env.ANTHROPIC_SONNET_46_MODEL_ID || '', - // Provider filter (optional): '', 'bedrock', 'anthropic', 'openrouter' - aiProvider: parseProvider(cliProvider || process.env.AI_PROVIDER || null), tavilyApiKey: process.env.TAVILY_API_KEY ?? '', braveApiKey: process.env.BRAVE_API_KEY ?? '', pexelsApiKey: process.env.PEXELS_API_KEY ?? '', @@ -74,18 +46,6 @@ if (process.env.NODE_ENV === 'production' && !process.env.SESSION_SECRET) { // Validate critical env vars on startup const warnings: string[] = [] -if (!env.openrouterApiKey && !env.anthropicApiKey && !env.awsRegion) { - warnings.push('No AI provider configured (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, or AWS_REGION for Bedrock) — chat will not work') -} -if (env.aiProvider === 'openrouter' && !env.openrouterApiKey) { - warnings.push('AI_PROVIDER=openrouter but OPENROUTER_API_KEY is not set') -} -if (env.aiProvider === 'anthropic' && !env.anthropicApiKey) { - warnings.push('AI_PROVIDER=anthropic but ANTHROPIC_API_KEY is not set') -} -if (env.aiProvider === 'bedrock' && !env.awsRegion) { - warnings.push('AI_PROVIDER=bedrock but AWS_REGION is not set') -} if (!env.smtp.host && !env.ses.enabled) { warnings.push('No email provider configured — set SMTP_HOST or EMAIL_PROVIDER=ses') } diff --git a/apps/api/src/fleet/log.ts b/apps/api/src/fleet/log.ts new file mode 100644 index 0000000..103d0ef --- /dev/null +++ b/apps/api/src/fleet/log.ts @@ -0,0 +1,33 @@ +import { createCailLogger, defineEventCatalog } from '@cuny-ai-lab/cail-log' + +const catalog = defineEventCatalog({ + 'slide-maker.gateway.completed': { + source: 'tenant', severity: 'outcome', + required: ['request_id', 'terminal'], optional: [], + }, + 'slide-maker.request.failed': { + source: 'tenant', severity: 'error', + required: ['request_id', 'terminal'], optional: [], + }, +}) +const log = createCailLogger({ + service: 'slide-maker', release: process.env.RELEASE_SHA ?? 'development', + env: process.env.NODE_ENV === 'production' ? 'production' : 'development', + sourceClass: 'tenant', catalog, + sink: (event) => { console.log(JSON.stringify(event)) }, +}) + +export function logGatewayOutcome(requestId: string, outcome: 'ok' | 'error' | 'cancelled') { + const terminal = outcome === 'ok' + ? { outcome: 'ok', reason: 'completed' } as const + : outcome === 'cancelled' + ? { outcome: 'cancelled', reason: 'cancelled' } as const + : { outcome: 'error', reason: 'upstream_failure' } as const + log.emit('slide-maker.gateway.completed', { request_id: requestId, terminal }) +} + +export function logRequestFailure(requestId: string) { + log.emit('slide-maker.request.failed', { + request_id: requestId, terminal: { outcome: 'error', reason: 'application_failure' }, + }) +} diff --git a/apps/api/src/fleet/readiness.ts b/apps/api/src/fleet/readiness.ts new file mode 100644 index 0000000..e5a2e6b --- /dev/null +++ b/apps/api/src/fleet/readiness.ts @@ -0,0 +1,26 @@ +import { timingSafeEqual } from 'node:crypto' +import { Hono } from 'hono' + +export function readinessRouter(checkStorage: () => void, checkIdentity: () => unknown | Promise) { + const router = new Hono() + router.get('/ready', async (c) => { + const configured = process.env.READINESS_TOKEN + const authorization = c.req.header('authorization') + const supplied = authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined + if (!configured || !supplied || Buffer.byteLength(configured) !== Buffer.byteLength(supplied) + || !timingSafeEqual(Buffer.from(configured), Buffer.from(supplied))) { + return c.json({ error: 'Not found' }, 404) + } + const release = process.env.RELEASE_SHA + try { + if (!release || !/^[a-f0-9]{40}$/.test(release)) throw new Error('release') + if (!process.env.CAIL_GATEWAY_URL) throw new Error('gateway') + await checkIdentity() + checkStorage() + } catch { + return c.json({ status: 'not_ready' }, 503) + } + return c.json({ status: 'ready', release, service: 'slide-maker' }) + }) + return router +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cb51175..8dd4f4c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,105 +1,9 @@ import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { bodyLimit } from 'hono/body-limit' -import { csrf } from 'hono/csrf' +import app from './app.js' import { env } from './env.js' -import auth from './routes/auth.js' -import admin from './routes/admin.js' -import { decksRouter } from './routes/decks.js' -import exportRouter from './routes/export.js' -import previewRouter from './routes/preview.js' -import chat from './routes/chat.js' -import providers from './routes/providers.js' -import resources from './routes/resources.js' -import artifactRouter from './routes/artifact.js' -import sharing from './routes/sharing.js' -import filesRouter from './routes/files.js' -import search from './routes/search.js' -import planRouter from './routes/plan.js' -import debugRouter from './routes/debug.js' - -const app = new Hono() - -// Global error handler — ensures CORS headers are present on unhandled exceptions -// so the browser can read the error instead of showing "NetworkError" -app.onError((err, c) => { - const origin = c.req.header('origin') - if (origin && env.allowedOrigins.includes(origin)) { - c.header('Access-Control-Allow-Origin', origin) - c.header('Access-Control-Allow-Credentials', 'true') - } - console.error('Unhandled error:', err.message, err.stack) - return c.json({ error: 'Internal server error' }, 500) -}) - -app.use('/*', cors({ - origin: env.allowedOrigins, - credentials: true, -})) - -app.use('/*', csrf({ origin: env.allowedOrigins })) -// 11MB for file upload routes (10MB file + overhead) -app.use('/api/decks/:id/files', bodyLimit({ maxSize: 11 * 1024 * 1024 })) -// Apply a 2MB limit to all other routes, but explicitly skip the upload endpoint -const smallBodyLimit = bodyLimit({ maxSize: 2 * 1024 * 1024 }) -app.use('*', async (c, next) => { - const { pathname } = new URL(c.req.url) - // Skip when path matches /api/decks/:deckId/files exactly - if (/^\/api\/decks\/[^/]+\/files$/.test(pathname)) { - return next() - } - return smallBodyLimit(c, next) -}) - -app.get('/', (c) => c.json({ name: 'slide-wiz-dev', status: 'ok' })) -app.get('/api/health', (c) => c.json({ status: 'ok' })) - -// Serve static files (Leaflet, etc.) for artifact iframes -app.get('/api/static/:file', async (c) => { - const file = c.req.param('file') - const fs = await import('node:fs') - const path = await import('node:path') - const { fileURLToPath } = await import('node:url') - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const filePath = path.join(__dirname, '..', 'static', path.basename(file)) - if (!fs.existsSync(filePath)) return c.text('Not found', 404) - const content = fs.readFileSync(filePath, 'utf-8') - const ext = path.extname(file) - const type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/plain' - c.header('Content-Type', type) - c.header('Cache-Control', 'public, max-age=86400') - c.header('Access-Control-Allow-Origin', '*') - return c.body(content) -}) - -app.route('/api/auth', auth) -app.route('/api/admin', admin) -app.route('/api/decks', filesRouter) -app.route('/api/decks', decksRouter) -app.route('/api/decks', exportRouter) -app.route('/api/decks', previewRouter) -app.route('/api/decks', sharing) -app.route('/api/decks', planRouter) -app.route('/api/chat', chat) -app.route('/api/providers', providers) -app.route('/api', resources) -app.route('/api', artifactRouter) -app.route('/api/search', search) - -// Mount debug routes only when explicitly enabled (requires admin auth) -if (process.env.ENABLE_DEBUG_ROUTES === 'true') { - app.route('/api/debug', debugRouter) -} const displayHost = env.host.includes(':') ? `[${env.host}]` : env.host - -serve({ - fetch: app.fetch, - hostname: env.host, - port: env.port, -}, () => { +serve({ fetch: app.fetch, hostname: env.host, port: env.port }, () => { console.log(`API server running on http://${displayHost}:${env.port}`) }) - export default app diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 0d5194e..800e446 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -1,8 +1,28 @@ import type { Context, Next } from 'hono' import { getCookie } from 'hono/cookie' +import type { User, Session } from 'lucia' +import { eq } from 'drizzle-orm' +import { db } from '../db/index.js' +import { users } from '../db/schema.js' +import { loadIdentityConfigs, verifyRequestIdentity } from '../auth/identity.js' import { lucia } from '../auth/lucia.js' +export type AuthEnv = { Variables: { user: User; session: Session | null; gatewayToken: string | undefined; canonicalSubject: string | undefined } } + export async function authMiddleware(c: Context, next: Next) { + if (process.env.NODE_ENV === 'production' || c.req.header('x-cail-identity-jwt') !== undefined || c.req.header('x-cail-gateway-identity-jwt') !== undefined) { + let configs + try { configs = await loadIdentityConfigs() } catch { return c.json({ error: 'Identity unavailable' }, 503) } + const identity = await verifyRequestIdentity(c.req.raw.headers, configs) + if (!identity) return c.json({ error: 'Unauthorized' }, 401) + const user = db.select().from(users).where(eq(users.canonicalSubject, identity.subject)).get() + if (!user || user.status !== 'approved') return c.json({ error: 'Account requires operator verification' }, 403) + c.set('user', { id: user.id, email: user.email, name: user.name, role: user.role, status: user.status, emailVerified: user.emailVerified }) + c.set('session', null) + c.set('canonicalSubject', identity.subject) + c.set('gatewayToken', identity.gatewayToken) + return next() + } const sessionId = getCookie(c, lucia.sessionCookieName) if (!sessionId) { return c.json({ error: 'Unauthorized' }, 401) diff --git a/apps/api/src/providers/anthropic.ts b/apps/api/src/providers/anthropic.ts deleted file mode 100644 index d046efb..0000000 --- a/apps/api/src/providers/anthropic.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk' -import { env } from '../env.js' - -export type SplitSystemPrompt = { staticPrompt: string; dynamicContext: string } - -const client = new Anthropic({ apiKey: env.anthropicApiKey }) - -export async function* streamAnthropic( - system: string | SplitSystemPrompt, - messages: { role: 'user' | 'assistant'; content: string }[], - model: string = 'claude-sonnet-4-20250514', -): AsyncGenerator { - const systemBlocks: Anthropic.Messages.TextBlockParam[] = typeof system === 'string' - ? [{ type: 'text', text: system }] - : [ - { type: 'text', text: system.staticPrompt, cache_control: { type: 'ephemeral' } }, - { type: 'text', text: system.dynamicContext }, - ] - - const stream = client.messages.stream({ - model, - max_tokens: 4096, - system: systemBlocks, - messages, - }) - - for await (const event of stream) { - if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - yield event.delta.text - } - } -} - -export const ANTHROPIC_MODELS = [ - { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', provider: 'anthropic' }, -] - -// Optionally expose Sonnet 4.6 via Anthropic SDK (admin-only in listing) -if (env.anthropicSonnet46Id) { - ;(ANTHROPIC_MODELS as any).push({ - id: env.anthropicSonnet46Id, - name: 'Claude Sonnet 4.6', - provider: 'anthropic', - adminOnly: true, - }) -} diff --git a/apps/api/src/providers/bedrock.ts b/apps/api/src/providers/bedrock.ts deleted file mode 100644 index f870d9d..0000000 --- a/apps/api/src/providers/bedrock.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { env } from '../env.js' - -/** - * AWS Bedrock via native SDK with IAM credentials. - * Supports both IAM auth (AWS_ACCESS_KEY_ID/SECRET) and ABSK bearer token (Mantle API). - * Set AWS_REGION and provide credentials via env vars or AWS credential chain. - */ - -// Lazy import to avoid requiring the package unless used -async function loadBedrock() { - const mod = await import('@aws-sdk/client-bedrock-runtime') - return { - BedrockRuntimeClient: mod.BedrockRuntimeClient, - ConverseStreamCommand: mod.ConverseStreamCommand, - } -} - -export async function* streamBedrock( - systemPrompt: string, - messages: { role: 'user' | 'assistant'; content: string }[], - model: string = 'us.anthropic.claude-haiku-4-5-20251001-v1:0', -): AsyncGenerator { - const { BedrockRuntimeClient, ConverseStreamCommand } = await loadBedrock() - - const region = env.awsRegion || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1' - - const clientConfig: Record = { region } - - // Explicit IAM credentials from env (takes precedence over credential chain) - const accessKey = process.env.AWS_ACCESS_KEY_ID - const secretKey = process.env.AWS_SECRET_ACCESS_KEY - if (accessKey && secretKey) { - clientConfig.credentials = { - accessKeyId: accessKey, - secretAccessKey: secretKey, - ...(process.env.AWS_SESSION_TOKEN ? { sessionToken: process.env.AWS_SESSION_TOKEN } : {}), - } - } - - const client = new BedrockRuntimeClient(clientConfig) - - const command = new ConverseStreamCommand({ - modelId: model, - system: [{ text: systemPrompt }], - messages: messages.map((m) => ({ - role: m.role, - content: [{ text: m.content }], - })), - inferenceConfig: { - maxTokens: 4096, - }, - }) - - const res = await client.send(command) - - if (res.stream) { - for await (const event of res.stream) { - if (event.contentBlockDelta?.delta?.text) { - yield event.contentBlockDelta.delta.text - } - } - } -} - -export const BEDROCK_MODELS = [ - { id: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', name: 'Claude Haiku 4.5 (AWS)', provider: 'bedrock' }, - { id: 'us.anthropic.claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (AWS)', provider: 'bedrock' }, -] diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts index 7403687..7dc067a 100644 --- a/apps/api/src/providers/index.ts +++ b/apps/api/src/providers/index.ts @@ -1,27 +1,111 @@ -import { streamAnthropic, ANTHROPIC_MODELS, type SplitSystemPrompt } from './anthropic.js' -import { streamOpenRouter, OPENROUTER_MODELS } from './openrouter.js' -import { streamBedrock, BEDROCK_MODELS } from './bedrock.js' -import { env } from '../env.js' +import { correlationFromHeaders, type CailCorrelation } from '@cuny-ai-lab/cail-log' +import { CailError, createCailClient, extractCailError, type CailClient } from '@cuny-ai-lab/cail-client' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import { logGatewayOutcome } from '../fleet/log.js' -export type { SplitSystemPrompt } +export const gateway = createCailClient({ app: 'slide-maker', allowInsecureLoopback: process.env.NODE_ENV === 'test', ...(process.env.CAIL_GATEWAY_URL ? { baseUrl: process.env.CAIL_GATEWAY_URL } : {}) }) +export type SplitSystemPrompt = { staticPrompt: string; dynamicContext: string } -export const ALL_MODELS = [...ANTHROPIC_MODELS, ...OPENROUTER_MODELS, ...BEDROCK_MODELS] +export function gatewayErrorStatus(error: unknown): ContentfulStatusCode { + if (error instanceof CailError && [400, 401, 403, 404, 409, 429, 503].includes(error.status)) return error.status as ContentfulStatusCode + return 502 +} + +export function safeGatewayError(error: unknown) { + const requestId = error instanceof CailError ? error.extras.request_id : undefined + return { + message: 'The model request could not be completed. Please try again.', + ...(typeof requestId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(requestId) + ? { requestId } : {}), + } +} -function flattenSystem(system: string | SplitSystemPrompt): string { - return typeof system === 'string' ? system : `${system.staticPrompt}\n\n${system.dynamicContext}` +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Read to EOF even after DONE: a trailing Gateway error invalidates the turn. */ +export async function* readGatewayStream(response: Response, signal: AbortSignal): AsyncGenerator { + if (!response.ok || !response.body) throw new Error('Gateway response unavailable') + const reader = response.body.getReader() + const cancel = () => { void reader.cancel().catch(() => {}) } + signal.addEventListener('abort', cancel, { once: true }) + const decoder = new TextDecoder() + let buffer = '' + let done = false + function parse(frame: string): string[] { + const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n') + if (!data) return [] + if (data === '[DONE]') { done = true; return [] } + const value: unknown = JSON.parse(data) + const gatewayError = extractCailError(value) + if (gatewayError) throw gatewayError + if (!record(value) || value.error || frame.split('\n').some(line => /^event:\s*error\s*$/.test(line))) throw new Error('Gateway stream error') + if (done && (!Array.isArray(value.choices) || value.choices.length !== 0 || !('usage' in value))) throw new Error('Unexpected data after completion') + if (!Array.isArray(value.choices)) throw new Error('Invalid Gateway chunk') + const text: string[] = [] + for (const choice of value.choices) { + if (!record(choice)) throw new Error('Invalid Gateway choice') + if (record(choice.delta) && typeof choice.delta.content === 'string') text.push(choice.delta.content) + } + return text + } + try { + while (true) { + signal.throwIfAborted() + const chunk = await reader.read() + signal.throwIfAborted() + buffer += decoder.decode(chunk.value, { stream: !chunk.done }) + // Normalize only complete CRLF sequences, preserving a CR split across reads. + buffer = buffer.replace(/\r\n/g, '\n') + let boundary: number + while ((boundary = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + yield* parse(frame) + } + if (chunk.done) break + } + if (buffer.trim()) yield* parse(buffer) + if (!done) throw new Error('Gateway stream ended before completion') + } finally { + signal.removeEventListener('abort', cancel) + await reader.cancel().catch(() => {}) + reader.releaseLock() + } } -export function getModelStream( +export async function* getModelStream( modelId: string, system: string | SplitSystemPrompt, messages: { role: 'user' | 'assistant'; content: string }[], + options: { token: string; sessionId: string; signal: AbortSignal; correlation?: CailCorrelation }, + client: CailClient = gateway, ): AsyncGenerator { - const model = ALL_MODELS.find((m) => m.id === modelId) - if (!model) throw new Error(`Unknown model: ${modelId}`) - if (env.aiProvider && model.provider !== env.aiProvider) { - throw new Error(`Model provider '${model.provider}' not allowed by AI_PROVIDER=${env.aiProvider}`) + const correlation = options.correlation ?? correlationFromHeaders(new Headers()) + const requestId = correlation.request_id + try { + const response = await client.chatCompletions({ + model: modelId, + stream: true, + stream_options: { include_usage: true }, + messages: [ + { role: 'system', content: typeof system === 'string' ? system : `${system.staticPrompt}\n\n${system.dynamicContext}` }, + ...messages, + ], + }, { kind: 'jwt', token: options.token }, { + sessionId: options.sessionId, + signal: options.signal, + correlation, + }) + yield* readGatewayStream(response, options.signal) + logGatewayOutcome(requestId, 'ok') + } catch (error) { + logGatewayOutcome(requestId, options.signal.aborted ? 'cancelled' : 'error') + if (options.signal.aborted) throw error + if (error instanceof CailError) { + throw new CailError(error.code, 'Gateway request failed', error.status, { request_id: error.extras.request_id ?? requestId }, error.type) + } + throw new CailError('upstream_failure', 'Gateway request failed', 502, { request_id: requestId }) } - if (model.provider === 'anthropic') return streamAnthropic(system, messages, modelId) - if (model.provider === 'bedrock') return streamBedrock(flattenSystem(system), messages, modelId) - return streamOpenRouter(flattenSystem(system), messages, modelId) } diff --git a/apps/api/src/providers/openrouter.ts b/apps/api/src/providers/openrouter.ts deleted file mode 100644 index 1a5b404..0000000 --- a/apps/api/src/providers/openrouter.ts +++ /dev/null @@ -1,34 +0,0 @@ -import OpenAI from 'openai' -import { env } from '../env.js' - -const client = new OpenAI({ - baseURL: 'https://openrouter.ai/api/v1', - apiKey: env.openrouterApiKey, -}) - -export async function* streamOpenRouter( - systemPrompt: string, - messages: { role: 'user' | 'assistant'; content: string }[], - model: string = 'google/gemini-3.1-flash-lite-preview', -): AsyncGenerator { - const stream = await client.chat.completions.create({ - model, - stream: true, - messages: [ - { role: 'system', content: systemPrompt }, - ...messages, - ], - }) - - for await (const chunk of stream) { - const text = chunk.choices[0]?.delta?.content - if (text) yield text - } -} - -export const OPENROUTER_MODELS = [ - { id: 'moonshotai/kimi-k2.5', name: 'Kimi K2.5', provider: 'openrouter' }, - { id: 'z-ai/glm-5', name: 'GLM 5', provider: 'openrouter' }, - { id: 'google/gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash', provider: 'openrouter' }, - { id: 'qwen/qwen3.5-flash-02-23', name: 'Qwen 3.5 Flash', provider: 'openrouter' }, -] diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 3b437c1..a6de29e 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -88,7 +88,6 @@ admin.get('/users/all', async (c) => { status: u.status, emailVerified: u.emailVerified, createdAt: u.createdAt, - tokenCap: u.tokenCap, deckCount: deckCountMap.get(u.id) ?? 0, tokensUsed: tokenMap.get(u.id) ?? 0, lastActive: lastActiveMap.get(u.id) ?? null, @@ -130,9 +129,6 @@ admin.patch('/users/:id', async (c) => { updates.status = body.status } - if (body.tokenCap !== undefined && typeof body.tokenCap === 'number' && body.tokenCap >= 0) { - updates.tokenCap = body.tokenCap - } if (Object.keys(updates).length === 0) { return c.json({ error: 'No valid fields to update' }, 400) @@ -183,14 +179,12 @@ admin.get('/users/:id/usage', async (c) => { .orderBy(desc(sql`SUM(input_tokens + output_tokens)`)) const totalUsed = totalRow?.total ?? 0 - const cap = user.tokenCap ?? 1000000 return c.json({ userId: id, userName: user.name, - tokenCap: cap, totalUsed, - remaining: Math.max(0, cap - totalUsed), + source: 'historical_local_estimate', inputTotal: totalRow?.inputTotal ?? 0, outputTotal: totalRow?.outputTotal ?? 0, monthly, diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 64eac74..3933048 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -3,23 +3,23 @@ import { hash, verify } from '@node-rs/argon2' import { createId } from '@paralleldrive/cuid2' import { eq } from 'drizzle-orm' import { isValidCunyEmail } from '@slide-maker/shared' -import type { Session, User } from 'lucia' import { db } from '../db/index.js' import { users, emailVerifications, passwordResets } from '../db/schema.js' import { lucia } from '../auth/lucia.js' -import { authMiddleware } from '../middleware/auth.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' import { loginRateLimit, registerRateLimit, passwordChangeRateLimit, forgotPasswordRateLimit } from '../middleware/rate-limit.js' import { sendVerificationEmail, sendPasswordResetEmail } from '../email/index.js' -type AuthEnv = { - Variables: { - user: User - session: Session - } -} - const auth = new Hono() +// Password-era account lifecycle is available only in local development. +auth.use('*', async (c, next) => { + if (process.env.NODE_ENV === 'production' && !c.req.path.endsWith('/me')) { + return c.json({ error: 'Use CAIL sign-in' }, 403) + } + return next() +}) + // POST /register auth.post('/register', registerRateLimit, async (c) => { const body = await c.req.json() @@ -144,6 +144,7 @@ auth.post('/login', loginRateLimit, async (c) => { name: user.name, role: user.role, status: user.status, + authentication: c.get('canonicalSubject') ? 'institutional' : 'local', }, }) }) @@ -151,7 +152,7 @@ auth.post('/login', loginRateLimit, async (c) => { // POST /logout auth.post('/logout', authMiddleware, async (c) => { const session = c.get('session') - await lucia.invalidateSession(session.id) + if (session) await lucia.invalidateSession(session.id) const blankCookie = lucia.createBlankSessionCookie() c.header('Set-Cookie', blankCookie.serialize()) return c.json({ message: 'Logged out successfully' }) @@ -167,6 +168,7 @@ auth.get('/me', authMiddleware, async (c) => { name: user.name, role: user.role, status: user.status, + authentication: c.get('canonicalSubject') ? 'institutional' : 'local', }, }) }) diff --git a/apps/api/src/routes/chat.ts b/apps/api/src/routes/chat.ts index ce275ed..34b0caa 100644 --- a/apps/api/src/routes/chat.ts +++ b/apps/api/src/routes/chat.ts @@ -1,26 +1,18 @@ +import { correlationFromHeaders } from '@cuny-ai-lab/cail-log' import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' -import { eq, and, inArray, gte, sql } from 'drizzle-orm' +import { eq, and, inArray } from 'drizzle-orm' import { createId } from '@paralleldrive/cuid2' -import type { Session, User } from 'lucia' import { db } from '../db/index.js' -import { decks, deckAccess, slides, contentBlocks, chatMessages, templates, themes, uploadedFiles, users, tokenUsage, artifacts } from '../db/schema.js' -import { authMiddleware } from '../middleware/auth.js' +import { decks, deckAccess, slides, contentBlocks, chatMessages, templates, themes, uploadedFiles, artifacts } from '../db/schema.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' import { chatRateLimit } from '../middleware/rate-limit.js' -import { getModelStream, ALL_MODELS } from '../providers/index.js' +import { getModelStream, safeGatewayError } from '../providers/index.js' import { buildSystemPrompt } from '../prompts/system.js' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { debugBus } from '../debug/event-bus.js' -import { appendTranscript } from '../debug/transcript-log.js' -type AuthEnv = { - Variables: { - user: User - session: Session - } -} const chat = new Hono() @@ -43,7 +35,7 @@ chat.post('/', chatRateLimit, async (c) => { .where(and(eq(deckAccess.deckId, deckId), eq(deckAccess.userId, user.id))) .get() - if (!access) { + if (!access || access.role === 'viewer') { return c.json({ error: 'Not found or no access' }, 404) } @@ -337,21 +329,9 @@ chat.post('/', chatRateLimit, async (c) => { chatHistory.push({ role: 'user', content: message }) - // Determine provider from registered models (supports openrouter, anthropic, bedrock) - const provider = (ALL_MODELS.find((m) => m.id === modelId)?.provider || 'unknown') as string - - // Check token cap - const yearStart = new Date(new Date().getFullYear(), 0, 1) - const usage = await db.select({ total: sql`SUM(input_tokens + output_tokens)` }) - .from(tokenUsage) - .where(and(eq(tokenUsage.userId, user.id), gte(tokenUsage.createdAt, yearStart))) - .get() - - const userRow = await db.select().from(users).where(eq(users.id, user.id)).get() - const cap = userRow?.tokenCap ?? 1000000 - if ((usage?.total ?? 0) >= cap) { - return c.json({ error: 'Token limit reached. Contact an admin.' }, 429) - } + const provider = 'cail-gateway' + const gatewayToken = c.get('gatewayToken') + if (!gatewayToken) return c.json({ error: 'Institutional sign-in required' }, 401) // Save user message to DB const userMsgId = createId() @@ -368,46 +348,22 @@ chat.post('/', chatRateLimit, async (c) => { // Stream response return streamSSE(c, async (stream) => { let fullResponse = '' - const streamTimeout = setTimeout(() => { stream.close() }, 120_000) // 2 min hard cap - const streamId = createId() - const startedAt = Date.now() - let chunkIndex = 0 - let totalChars = 0 - + const controller = new AbortController() + let clientDisconnected = false + const abort = () => { clientDisconnected = true; controller.abort() } + c.req.raw.signal.addEventListener('abort', abort, { once: true }) + stream.onAbort(abort) + if (c.req.raw.signal.aborted) abort() + const streamTimeout = setTimeout(() => controller.abort(), 120_000) try { - const gen = getModelStream(modelId, systemParts, chatHistory) - - // Emit start event - debugBus.emit('stream:start', { - streamId, - userId: user.id, - userEmail: user.email, - deckId, - model: modelId, - provider, - systemPromptChars: systemParts.staticPrompt.length + systemParts.dynamicContext.length, - historyLength: chatHistory.length, - timestamp: new Date(startedAt).toISOString(), + const gen = getModelStream(modelId, systemParts, chatHistory, { + token: gatewayToken, sessionId: deckId, correlation: correlationFromHeaders(c.req.raw.headers), signal: controller.signal, }) - for await (const text of gen) { fullResponse += text - totalChars += text.length - await stream.writeSSE({ - data: JSON.stringify({ type: 'text', content: text }), - }) - // Emit chunk event - debugBus.emit('stream:chunk', { - streamId, - text, - chunkIndex: chunkIndex++, - elapsedMs: Date.now() - startedAt, - }) + await stream.writeSSE({ data: JSON.stringify({ type: 'text', content: text }) }) } - - await stream.writeSSE({ - data: JSON.stringify({ type: 'done' }), - }) + controller.signal.throwIfAborted() // Extract mutation blocks from assistant response (before persisting) const mutations = (() => { @@ -432,83 +388,15 @@ chat.post('/', chatRateLimit, async (c) => { createdAt: new Date(), }) - // Estimate and record token usage - const allInputLength = systemParts.staticPrompt.length + systemParts.dynamicContext.length + chatHistory.reduce((sum, m) => sum + (m.content?.length ?? 0), 0) - const inputTokens = Math.ceil(allInputLength / 4) - const outputTokens = Math.ceil(fullResponse.length / 4) - await db.insert(tokenUsage).values({ - id: createId(), - userId: user.id, - deckId, - provider, - model: modelId, - inputTokens, - outputTokens, - createdAt: new Date(), - }) - - // Emit done event - debugBus.emit('stream:done', { - streamId, - totalChars, - durationMs: Date.now() - startedAt, - inputTokens, - outputTokens, - mutations, - }) - - // Append transcript log - await appendTranscript({ - id: streamId, - timestamp: new Date().toISOString(), - userEmail: user.email, - deckId, - model: modelId, - provider, - systemPromptChars: systemParts.staticPrompt.length + systemParts.dynamicContext.length, - historyLength: chatHistory.length, - inputTokens, - outputTokens, - durationMs: Date.now() - startedAt, - userMessage: message, - assistantMessage: fullResponse, - mutations, - error: null, - }) - } catch (err: unknown) { - console.error('AI streaming error:', err) - const errorMessage = 'An error occurred while generating the response' - await stream.writeSSE({ - data: JSON.stringify({ type: 'error', message: errorMessage }), - }) - // Emit error event - debugBus.emit('stream:error', { - streamId, - error: (err as any)?.message ?? String(err), - elapsedMs: Date.now() - startedAt, - }) - // Append transcript with error - try { - await appendTranscript({ - id: streamId, - timestamp: new Date().toISOString(), - userEmail: user.email, - deckId, - model: modelId, - provider, - systemPromptChars: systemParts.staticPrompt.length + systemParts.dynamicContext.length, - historyLength: chatHistory.length, - inputTokens: 0, - outputTokens: 0, - durationMs: Date.now() - startedAt, - userMessage: message, - assistantMessage: fullResponse, - mutations: [], - error: (err as any)?.message ?? String(err), - }) - } catch {} + await stream.writeSSE({ data: JSON.stringify({ type: 'done' }) }) + } catch (error: unknown) { + if (!clientDisconnected) { + await stream.writeSSE({ data: JSON.stringify({ type: 'error', ...safeGatewayError(error) }) }) + } } finally { clearTimeout(streamTimeout) + c.req.raw.signal.removeEventListener('abort', abort) + controller.abort() } }) }) diff --git a/apps/api/src/routes/plan.ts b/apps/api/src/routes/plan.ts index b7f69e0..55e96f6 100644 --- a/apps/api/src/routes/plan.ts +++ b/apps/api/src/routes/plan.ts @@ -1,7 +1,7 @@ +import { correlationFromHeaders } from '@cuny-ai-lab/cail-log' import { Hono } from 'hono' import { eq, and } from 'drizzle-orm' import { createId } from '@paralleldrive/cuid2' -import type { Session, User } from 'lucia' import { type FidelityMode, type DeckPlan, @@ -12,12 +12,11 @@ import { LAYOUTS, } from '@slide-maker/shared' import { db, sqlite } from '../db/index.js' -import { decks, deckAccess, slides, contentBlocks, uploadedFiles, templates, themes, users, tokenUsage } from '../db/schema.js' -import { gte, sql } from 'drizzle-orm' -import { authMiddleware } from '../middleware/auth.js' +import { decks, deckAccess, slides, contentBlocks, uploadedFiles, templates, themes } from '../db/schema.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' import { checkDeckLock } from '../middleware/deck-lock.js' import { chatRateLimit } from '../middleware/rate-limit.js' -import { getModelStream, ALL_MODELS } from '../providers/index.js' +import { getModelStream, safeGatewayError, gatewayErrorStatus } from '../providers/index.js' import { parseOutline } from '../utils/outline-parser.js' import { estimateSlideCount } from '../utils/slide-budget.js' import { buildPlannerPrompt } from '../prompts/planner.js' @@ -26,12 +25,6 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -type AuthEnv = { - Variables: { - user: User - session: Session - } -} const __dirname = path.dirname(fileURLToPath(import.meta.url)) const UPLOADS_DIR = path.resolve(__dirname, '../../uploads') @@ -163,18 +156,8 @@ planRouter.post('/:id/plan', chatRateLimit, async (c) => { maxSlides: MAX_SLIDES_PER_DECK, }) - // Check token cap before calling AI - const provider = (ALL_MODELS.find(m => m.id === modelId)?.provider || 'unknown') as string - const yearStart = new Date(new Date().getFullYear(), 0, 1) - const usage = await db.select({ total: sql`SUM(input_tokens + output_tokens)` }) - .from(tokenUsage) - .where(and(eq(tokenUsage.userId, user.id), gte(tokenUsage.createdAt, yearStart))) - .get() - const userRow = await db.select().from(users).where(eq(users.id, user.id)).get() - const cap = userRow?.tokenCap ?? 1000000 - if ((usage?.total ?? 0) >= cap) { - return c.json({ error: 'Token limit reached. Contact an admin.' }, 429) - } + const gatewayToken = c.get('gatewayToken') + if (!gatewayToken) return c.json({ error: 'Institutional sign-in required' }, 401) // Call AI model and collect full response const messages: { role: 'user' | 'assistant'; content: string }[] = [ @@ -183,31 +166,14 @@ planRouter.post('/:id/plan', chatRateLimit, async (c) => { let fullResponse = '' try { - const gen = getModelStream(modelId, systemPrompt, messages) + const gen = getModelStream(modelId, systemPrompt, messages, { token: gatewayToken, sessionId: deckId, correlation: correlationFromHeaders(c.req.raw.headers), signal: AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(120_000)]) }) for await (const text of gen) { fullResponse += text } } catch (err: unknown) { - console.error('Planner AI error:', err) - return c.json({ error: 'Failed to generate plan' }, 500) + return c.json(safeGatewayError(err), gatewayErrorStatus(err)) } - // Record token usage - const userMsgContent = messages[0].content - const allInputLength = systemPrompt.length + userMsgContent.length - const inputTokens = Math.ceil(allInputLength / 4) - const outputTokens = Math.ceil(fullResponse.length / 4) - await db.insert(tokenUsage).values({ - id: createId(), - userId: user.id, - deckId, - provider, - model: modelId, - inputTokens, - outputTokens, - createdAt: new Date(), - }) - // Parse the AI response as JSON try { let cleaned = fullResponse.trim() @@ -216,7 +182,7 @@ planRouter.post('/:id/plan', chatRateLimit, async (c) => { } planData = JSON.parse(cleaned) } catch { - return c.json({ error: 'AI returned invalid JSON. Try again or use a different model.', raw: fullResponse.slice(0, 500) }, 422) + return c.json({ error: 'AI returned invalid JSON. Try again or use a different model.' }, 422) } } diff --git a/apps/api/src/routes/providers.ts b/apps/api/src/routes/providers.ts index 3106964..1f32912 100644 --- a/apps/api/src/routes/providers.ts +++ b/apps/api/src/routes/providers.ts @@ -1,49 +1,26 @@ import { Hono } from 'hono' -import type { Session, User } from 'lucia' -import { env } from '../env.js' -import { ANTHROPIC_MODELS } from '../providers/anthropic.js' -import { OPENROUTER_MODELS } from '../providers/openrouter.js' -import { BEDROCK_MODELS } from '../providers/bedrock.js' -import { authMiddleware } from '../middleware/auth.js' - -type AuthEnv = { - Variables: { - user: User - session: Session - } -} +import { gateway, safeGatewayError, gatewayErrorStatus } from '../providers/index.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' const providers = new Hono() - -// GET / — List available models (filtered by which API keys are configured) -providers.get('/', authMiddleware, (c) => { - const user = c.get('user') - const isAdmin = user?.role === 'admin' - - const list: any[] = [] - - if (env.anthropicApiKey) { - list.push(...ANTHROPIC_MODELS) - } - - if (env.openrouterApiKey) { - list.push(...OPENROUTER_MODELS) +providers.use('*', authMiddleware) +providers.get('/', async (c) => { + try { + const catalog = await gateway.getCatalogSnapshot({ modality: 'text', signal: c.req.raw.signal }) + return c.json({ models: catalog.data.filter(model => model.streaming).map(model => ({ + id: model.id, name: model.name ?? model.id, provider: 'cail-gateway', + })) }) + } catch (error) { + return c.json(safeGatewayError(error), gatewayErrorStatus(error)) } - - // Expose AWS Bedrock models if region is configured (credentials are resolved via SDK) - if (env.awsRegion) { - list.push(...BEDROCK_MODELS) +}) +providers.get('/quota', async (c) => { + const token = c.get('gatewayToken') + if (!token) return c.json({ error: 'Institutional sign-in required' }, 401) + try { + return c.json(await gateway.getQuota({ kind: 'jwt', token }, { signal: c.req.raw.signal })) + } catch (error) { + return c.json(safeGatewayError(error), gatewayErrorStatus(error)) } - - // Optional provider filter from env/CLI - const providerFilter = env.aiProvider - - const models = list - .filter((m) => (providerFilter ? m.provider === providerFilter : true)) - .filter((m) => (isAdmin ? true : !m.adminOnly)) - .map((m) => ({ id: m.id, name: m.name, provider: m.provider })) - - return c.json({ models }) }) - export default providers diff --git a/apps/web/src/lib/components/admin/UserApprovalQueue.svelte b/apps/web/src/lib/components/admin/UserApprovalQueue.svelte index 5be1275..19c357a 100644 --- a/apps/web/src/lib/components/admin/UserApprovalQueue.svelte +++ b/apps/web/src/lib/components/admin/UserApprovalQueue.svelte @@ -70,8 +70,6 @@ let usageLoading = $state(false); // Inline editing - let editingCapId = $state(null); - let editingCapValue = $state(''); const filteredUsers = $derived.by(() => { let filtered = allUsers; @@ -187,32 +185,6 @@ } } - function startEditCap(userId: string, currentCap: number) { - editingCapId = userId; - editingCapValue = String(currentCap); - } - - async function saveCapEdit(userId: string) { - const newCap = parseInt(editingCapValue, 10); - if (isNaN(newCap) || newCap < 0) { - editingCapId = null; - return; - } - actionInProgress = userId; - error = ''; - try { - await api.updateUser(userId, { tokenCap: newCap }); - const user = allUsers.find((u) => u.id === userId); - if (user) user.tokenCap = newCap; - allUsers = [...allUsers]; - } catch (err: any) { - error = err.message || 'Failed to update token cap'; - } finally { - actionInProgress = null; - editingCapId = null; - } - } - let resetInProgress = $state(null); let resetMessage = $state(''); @@ -302,7 +274,7 @@
{formatTokens(stats.totalTokens)} - Total Tokens Used + Historical token estimates (year)
@@ -339,8 +311,7 @@ toggleSort('role')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('role')}>Role{sortIndicator('role')} toggleSort('status')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('status')}>Status{sortIndicator('status')} toggleSort('deckCount')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('deckCount')}>Decks{sortIndicator('deckCount')} - toggleSort('tokensUsed')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('tokensUsed')}>Tokens{sortIndicator('tokensUsed')} - Cap + toggleSort('tokensUsed')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('tokensUsed')}>Historical tokens{sortIndicator('tokensUsed')} toggleSort('lastActive')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('lastActive')}>Last Active{sortIndicator('lastActive')} Actions @@ -367,21 +338,6 @@ {u.deckCount} {formatTokens(u.tokensUsed)} - - {#if editingCapId === u.id} - { if (e.key === 'Enter') saveCapEdit(u.id); if (e.key === 'Escape') editingCapId = null; }} - onblur={() => saveCapEdit(u.id)} - /> - {:else} - - {/if} - {formatDate(u.lastActive)} {#if u.status === 'pending'} @@ -422,7 +378,7 @@