From e6fa23391537a7eebcd2961ed0b7d2f6474db790 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:04:52 +0800 Subject: [PATCH 1/2] feat: add first-class Projects --- DESIGN.md | 27 +- apps/app/src/main/ipc/hub-share.ts | 5 +- .../functions/_scheduled/deletion-worker.ts | 10 + apps/backend/functions/api/handles/check.ts | 11 +- apps/backend/functions/api/handles/claim.ts | 11 +- apps/backend/functions/api/hub/v1/projects.ts | 160 ++ .../api/hub/v1/sessions/[sid]/head.ts | 46 +- .../api/hub/v1/sessions/[sid]/index.ts | 28 +- .../functions/api/me/projects/[projectId].ts | 112 ++ .../functions/api/me/projects/index.ts | 100 ++ .../functions/api/me/sessions/[sid].ts | 87 +- .../api/owners/[handle]/projects/[slug].ts | 145 ++ .../api/owners/[handle]/projects/index.ts | 107 ++ apps/backend/functions/api/projects.ts | 100 ++ .../functions/api/teams/[teamId]/handle.ts | 47 + .../functions/api/teams/[teamId]/index.ts | 47 +- .../teams/[teamId]/projects/[projectId].ts | 129 ++ .../api/teams/[teamId]/projects/index.ts | 110 ++ apps/backend/functions/api/teams/index.ts | 160 +- apps/backend/migrations/0014_projects.sql | 1505 ++++++++++++++++ apps/backend/scripts/schema-smoke.mjs | 1214 ++++++++++++- apps/backend/src/auth/require.ts | 13 + apps/backend/src/discovery/sessions.ts | 12 +- apps/backend/src/discovery/store.ts | 10 + apps/backend/src/handles.ts | 237 ++- apps/backend/src/hub/auth.ts | 19 + apps/backend/src/hub/head.ts | 50 + apps/backend/src/hub/managed-row.ts | 91 + apps/backend/src/hub/management.ts | 156 +- apps/backend/src/hub/store.ts | 78 +- apps/backend/src/hub/wire.ts | 9 + apps/backend/src/projects/limits.ts | 20 + apps/backend/src/projects/query-sql.ts | 66 + apps/backend/src/projects/store.ts | 1541 +++++++++++++++++ apps/backend/src/projects/types.ts | 52 + apps/backend/src/projects/validators.ts | 166 ++ apps/backend/src/teams/store.ts | 111 +- apps/backend/src/teams/types.ts | 2 + apps/backend/src/teams/validators.ts | 18 +- apps/backend/tests/_helpers/fakes.ts | 1503 ++++++++++++++-- apps/backend/tests/discovery.test.ts | 2 + apps/backend/tests/e2e-roundtrip.test.ts | 29 +- apps/backend/tests/hub-management.test.ts | 93 +- apps/backend/tests/hub.test.ts | 296 +++- apps/backend/tests/me.test.ts | 13 +- apps/backend/tests/projects-api.test.ts | 1217 +++++++++++++ apps/backend/tests/teams-api.test.ts | 204 +++ apps/backend/tests/teams-store.test.ts | 76 + apps/cli/README.md | 22 +- apps/cli/src/cli.test.ts | 13 +- apps/cli/src/commands/projects.test.ts | 235 +++ apps/cli/src/commands/projects.ts | 313 ++++ apps/cli/src/commands/share-resume.test.ts | 250 ++- apps/cli/src/commands/share.ts | 238 ++- apps/cli/src/commands/show.ts | 3 +- apps/cli/src/commands/subscribe.test.ts | 125 +- apps/cli/src/commands/subscribe.ts | 90 +- apps/cli/src/commands/sync.test.ts | 1 + apps/cli/src/commands/teams.test.ts | 11 +- apps/cli/src/commands/teams.ts | 10 +- apps/cli/src/commands/visibility.test.ts | 85 +- apps/cli/src/commands/visibility.ts | 183 +- apps/cli/src/hub/auto-publish.test.ts | 249 ++- apps/cli/src/hub/auto-publish.ts | 268 ++- apps/cli/src/hub/client.test.ts | 128 ++ apps/cli/src/hub/client.ts | 134 +- apps/cli/src/hub/credentials.ts | 2 +- apps/cli/src/hub/materialize.ts | 134 +- apps/cli/src/hub/project-bindings.test.ts | 71 + apps/cli/src/hub/project-bindings.ts | 222 +++ apps/cli/src/hub/project-resolution.test.ts | 71 + apps/cli/src/hub/project-resolution.ts | 241 +++ apps/cli/src/hub/publish.ts | 8 + apps/cli/src/hub/share-pipeline.test.ts | 65 + apps/cli/src/hub/share-pipeline.ts | 12 +- apps/cli/src/hub/team-resolution.ts | 34 + apps/cli/src/index.ts | 7 +- apps/cli/src/subscriptions.test.ts | 50 +- apps/cli/src/subscriptions.ts | 138 +- apps/web/src/components/AccountMenu.tsx | 11 +- apps/web/src/components/Chrome.test.tsx | 22 +- apps/web/src/components/Chrome.tsx | 12 +- .../components/ManagedSessionList.test.tsx | 114 +- .../web/src/components/ManagedSessionList.tsx | 331 +++- apps/web/src/components/ProjectCard.test.tsx | 72 + apps/web/src/components/ProjectCard.tsx | 63 + apps/web/src/components/ProjectForm.tsx | 135 ++ .../src/components/SessionLanguageToggle.tsx | 15 + apps/web/src/components/TeamsSection.tsx | 49 +- .../src/components/WorkspaceFrame.test.tsx | 7 +- apps/web/src/components/WorkspaceFrame.tsx | 13 +- .../src/components/session/workbench.test.ts | 13 + apps/web/src/components/session/workbench.tsx | 13 + .../src/components/site/site-layout.test.tsx | 6 +- apps/web/src/components/site/site-layout.tsx | 14 +- .../src/content/docs/guides/data-sources.md | 2 +- apps/web/src/content/docs/reference/cli.md | 39 +- apps/web/src/lib/hub-api.ts | 17 + apps/web/src/lib/hub-management-api.test.ts | 27 +- apps/web/src/lib/hub-management-api.ts | 28 +- apps/web/src/lib/project-api.test.ts | 154 ++ apps/web/src/lib/project-api.ts | 196 +++ apps/web/src/lib/route.test.ts | 10 + apps/web/src/lib/route.ts | 14 +- apps/web/src/lib/security-headers.test.ts | 30 +- apps/web/src/lib/security-headers.ts | 41 +- apps/web/src/lib/session-messages.ts | 14 +- apps/web/src/lib/session-reader.test.ts | 18 +- apps/web/src/lib/team-api.ts | 13 +- apps/web/src/pages/Explore.test.tsx | 7 + apps/web/src/pages/Explore.tsx | 11 + apps/web/src/pages/Me.tsx | 11 +- apps/web/src/pages/MySessions.tsx | 6 +- apps/web/src/pages/Profile.tsx | 288 ++- apps/web/src/pages/Project.tsx | 283 +++ apps/web/src/pages/ProjectEditor.tsx | 271 +++ apps/web/src/pages/Projects.test.tsx | 62 + apps/web/src/pages/Projects.tsx | 303 ++++ apps/web/src/pages/Sessions.test.tsx | 6 +- apps/web/src/pages/Sessions.tsx | 14 +- apps/web/src/pages/Team.test.tsx | 32 + apps/web/src/pages/Team.tsx | 237 ++- apps/web/src/pages/WorkspaceIndexes.test.tsx | 4 + apps/web/src/pages/session-reader.tsx | 11 +- apps/web/src/routeTree.gen.ts | 130 +- .../-collection-scope-navigation.test.ts | 40 + apps/web/src/routes/-teams.$teamId.test.ts | 12 +- .../src/routes/@{$handle}.$projectSlug.tsx | 20 + apps/web/src/routes/@{$handle}.index.tsx | 21 + apps/web/src/routes/@{$handle}.tsx | 23 +- .../src/routes/projects.$projectId.edit.tsx | 26 + apps/web/src/routes/projects.index.tsx | 69 + apps/web/src/routes/projects.new.tsx | 25 + apps/web/src/routes/sessions.tsx | 27 + apps/web/src/routes/teams.$teamId.tsx | 28 +- apps/web/src/styles/app.css | 30 + apps/web/src/styles/explore.css | 54 +- apps/web/src/styles/global.css | 12 +- apps/web/src/styles/projects.css | 686 ++++++++ apps/web/src/styles/session-feed.css | 99 +- apps/web/src/styles/session-language.css | 51 + apps/web/src/styles/workspace.css | 7 + apps/web/tests/ui-system-contract.test.ts | 3 +- .../core/src/projects/local-identity.test.ts | 39 + packages/core/src/projects/local-identity.ts | 53 +- packages/session-kit/package.json | 2 +- packages/session-kit/src/discovery.ts | 12 + packages/session-kit/src/edits.ts | 3 +- packages/session-kit/src/index.ts | 11 +- packages/session-kit/src/messages.ts | 2 +- packages/session-kit/src/records.test.ts | 58 +- packages/session-kit/src/records.ts | 128 +- packages/session-kit/src/view.ts | 5 +- 153 files changed, 17644 insertions(+), 729 deletions(-) create mode 100644 apps/backend/functions/api/hub/v1/projects.ts create mode 100644 apps/backend/functions/api/me/projects/[projectId].ts create mode 100644 apps/backend/functions/api/me/projects/index.ts create mode 100644 apps/backend/functions/api/owners/[handle]/projects/[slug].ts create mode 100644 apps/backend/functions/api/owners/[handle]/projects/index.ts create mode 100644 apps/backend/functions/api/projects.ts create mode 100644 apps/backend/functions/api/teams/[teamId]/handle.ts create mode 100644 apps/backend/functions/api/teams/[teamId]/projects/[projectId].ts create mode 100644 apps/backend/functions/api/teams/[teamId]/projects/index.ts create mode 100644 apps/backend/migrations/0014_projects.sql create mode 100644 apps/backend/src/hub/managed-row.ts create mode 100644 apps/backend/src/projects/limits.ts create mode 100644 apps/backend/src/projects/query-sql.ts create mode 100644 apps/backend/src/projects/store.ts create mode 100644 apps/backend/src/projects/types.ts create mode 100644 apps/backend/src/projects/validators.ts create mode 100644 apps/backend/tests/projects-api.test.ts create mode 100644 apps/cli/src/commands/projects.test.ts create mode 100644 apps/cli/src/commands/projects.ts create mode 100644 apps/cli/src/hub/project-bindings.test.ts create mode 100644 apps/cli/src/hub/project-bindings.ts create mode 100644 apps/cli/src/hub/project-resolution.test.ts create mode 100644 apps/cli/src/hub/project-resolution.ts create mode 100644 apps/cli/src/hub/team-resolution.ts create mode 100644 apps/web/src/components/ProjectCard.test.tsx create mode 100644 apps/web/src/components/ProjectCard.tsx create mode 100644 apps/web/src/components/ProjectForm.tsx create mode 100644 apps/web/src/lib/project-api.test.ts create mode 100644 apps/web/src/lib/project-api.ts create mode 100644 apps/web/src/pages/Project.tsx create mode 100644 apps/web/src/pages/ProjectEditor.tsx create mode 100644 apps/web/src/pages/Projects.test.tsx create mode 100644 apps/web/src/pages/Projects.tsx create mode 100644 apps/web/src/routes/-collection-scope-navigation.test.ts create mode 100644 apps/web/src/routes/@{$handle}.$projectSlug.tsx create mode 100644 apps/web/src/routes/@{$handle}.index.tsx create mode 100644 apps/web/src/routes/projects.$projectId.edit.tsx create mode 100644 apps/web/src/routes/projects.index.tsx create mode 100644 apps/web/src/routes/projects.new.tsx create mode 100644 apps/web/src/styles/projects.css create mode 100644 packages/core/src/projects/local-identity.test.ts diff --git a/DESIGN.md b/DESIGN.md index ecafc129..4543f977 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,21 +23,24 @@ 1. **Discovery** helps a visitor find a Session worth opening. 2. **Session pages** help a reader understand and continue the work. -3. **Profiles** establish authorship and collect a person’s Public Sessions. -4. **CLI** helps an author prepare, share, publish, and manage Sessions. -5. **Teams** let members keep shared work inside a named workspace before deciding whether the Team should publish it more broadly. +3. **Projects** give related Sessions a durable, reader-facing home under a User or Team namespace. +4. **Profiles** establish authorship and collect a person’s Public Projects and Sessions. +5. **CLI** helps an author prepare, associate, share, publish, and manage Sessions. +6. **Teams** let members keep shared Projects and Sessions inside a named workspace before deciding whether the Team should publish it more broadly. The public web must show the artifact before explaining the product. Real Sessions, authors, topics, and evidence carry more weight than feature illustrations. ### Public web -- **Header:** Compact and persistent on marketing and reading surfaces. The grouping rule is strict: the left side answers "where can I go" (Wordmark home + at most `Sessions` and `Docs`), the right side keeps exactly one primary action (`Publish`), one compact Session-language preference, plus one identity affordance (`Sign in` when signed out; the avatar menu when signed in). Search never appears in the header — it belongs inside the Sessions feed. `Blog` lives in the footer. Theme, `My Sessions`, `Teams`, account, and sign-out live inside the avatar menu, never as parallel header icons. On mobile, the Session-language preference moves into the navigation disclosure and every retained action uses a 44px target. -- **Avatar menu:** The avatar opens a popover menu (`Account`, `My Sessions`, `Teams` — or the contextual `Team · {name}` shortcut — a theme toggle, and `Sign out`). It is navigation only; authorization still comes from the server. +- **Header:** Compact and persistent on marketing and reading surfaces. The grouping rule is strict: the left side answers "where can I go" (Wordmark home + at most `Sessions` and `Projects`), the right side keeps exactly one primary action (`Publish`) plus one identity affordance (`Sign in` when signed out; the avatar menu when signed in). Search never appears in the header — it belongs inside the Sessions feed. `Docs` and `Blog` live with resource links. Theme, `My Sessions`, `Teams`, account, and sign-out live inside the avatar menu, never as parallel header icons. The homepage and resource pages never show a Session-language control: it belongs beside the bilingual Session content it changes. On mobile every retained action uses a 44px target. +- **Avatar menu:** The avatar opens a popover menu (`Account`, `Projects`, `My Sessions`, `Teams` — or the contextual `Team · {name}` shortcut — a theme toggle, and `Sign out`). It is navigation only; authorization still comes from the server. - **Homepage:** Featured-first editorial layout for visitors and crawlers. Signed-in visitors who open `/` stay on the real homepage; `/sessions` remains their explicit workspace, and the wordmark never triggers a delayed auth redirect. The hero pairs a concise promise with one real featured Session—never a screenshot of the desktop app. Primary CTA is `Explore Sessions` (linking to `/sessions`); secondary CTA is `Share Yours`. Both are prominent 48px controls without directional-arrow decoration. Search stays compact until the public corpus is large enough to make search-first discovery useful. - **Homepage feed:** Public Sessions appear immediately after the hero. Use information-rich rows or cards with author, agent, topic/project, Summary excerpt, evidence, and continuation state. Avoid equal-weight feature tiles. -- **Discovery:** One unified feed at `/sessions` with a scope switcher: `Public`, `Team · {name}` (one per membership), and `Mine`. Scope tabs render only after the server confirms membership — Team names never come from navigation state alone, and the Team scope is served `private, no-store` like every Team surface. `/explore` remains a compatibility redirect into `/sessions`. Search is prominent at the top of the Public scope; agent identity stays visible on every Session as source metadata rather than a separate filter. The persistent product navigation is `Sessions`, `My Sessions`, and `Teams`; the wordmark alone returns home. Public results use exactly two orders: `Top` (global quality, useful evidence, qualified reading, and recency) and `Recent` (newest first). Do not label a global ranking `For you`, and do not expose a separate `Trending` tab. Results remain readable without opening each Session. -- **Profile:** Author identity and recurring topics come first, followed by Public Sessions. Counts support the content; they are not the hero. +- **Discovery:** One unified feed at `/sessions` with a scope switcher: `Public`, `Team · {name}` (one per membership), and `Mine`. Scope tabs render only after the server confirms membership — Team names never come from navigation state alone, and the Team scope is served `private, no-store` like every Team surface. `/explore` remains a compatibility redirect into `/sessions`. One content toolbar keeps the scope switcher and Session-language preference together at the top; Search and order controls sit directly below for Public. Agent identity stays visible on every Session as source metadata rather than a separate filter. The persistent product navigation is `Sessions`, `Projects`, `My Sessions`, and `Teams`; the wordmark alone returns home. Public results use exactly two orders: `Top` (global quality, useful evidence, qualified reading, and recency) and `Recent` (newest first). Do not label a global ranking `For you`, and do not expose a separate `Trending` tab. Results remain readable without opening each Session. +- **Projects:** A Project is a durable metadata home for related Sessions, owned by exactly one User or Team. It stores a name, URL-safe slug, short context-first description, and optional GitHub URL; it stores no repository content. Canonical identity follows the existing handle namespace as `/@handle/project-slug`. Personal Project metadata and its Public Sessions may be public. Team Projects remain `private, no-store`, resolve only for current members, and never leak into public Profile, Discovery, search, RSS, previews, or OG metadata. Project ownership never replaces Session authorship. +- **Profile:** Author identity and recurring topics come first, followed by Public Projects and Public Sessions. Counts support the content; they are not the hero. - **Session page:** Summary establishes intent and outcome; conversation/tool activity shows process; files and diff show evidence; Resume/Fork is the primary action after reading. Source and continuation lineage remain visible. +- **Record fidelity:** Hub objects preserve validated provider JSON bytes, including key order, whitespace, and numeric lexemes. Local workspace and home paths are the only Share-time transform and use the reserved `$SPOOL_WS` / `$SPOOL_HOME` string tokens; Resume changes only provider Session identity fields and appends explicit lineage. Never describe parse/stringify reconstruction as byte-exact. - **Visibility:** `Link-only`, `Team · {name}`, `Public`, and `Withdrawn` are explicit text labels with icons. Never communicate visibility by icon alone at the publishing boundary. - **Alignment:** Editorial surfaces are predominantly left-aligned. A centered treatment is acceptable only for a short empty state or a focused search affordance—not as a substitute for public content. - **Width:** Marketing and Discovery shells use a ~1120px max width. Reading columns stay near 720px; timeline/diff workbenches may expand to the full shell. @@ -62,7 +65,7 @@ Border radius remains restrained: 10px for cards, 8px for inputs, 6px for rows a ### Responsive shell contract -- **Phone (`320–640px`):** Product and marketing headers show the Wordmark, the current account action (`Sign in` or avatar), and one 44px navigation disclosure. `Sessions`, `Docs`, `Publish`, Session language, theme, and — signed in — `My Sessions` and `Teams` remain available inside that disclosure; do not compress the complete desktop header into one row. +- **Phone (`320–640px`):** Product and marketing headers show the Wordmark, the current account action (`Sign in` or avatar), and one 44px navigation disclosure. `Sessions`, `Projects`, `Docs`, `Publish`, theme, and — signed in — `My Sessions` and `Teams` remain available inside that disclosure; do not compress the complete desktop header into one row. Session-language controls stay in the top content toolbar of Session-bearing pages, not in global navigation. - **Compact (`641–768px`):** The same disclosure remains available so touch targets do not compete for width. Page content may use two columns only when each control still has its intended minimum width. - **Desktop (`769px+`):** The full persistent navigation is visible. Marketing and Team shells remain capped near 1120px; account forms keep their narrower reading width. - **Overflow invariant:** At 320, 451, 768, and 1024px, `scrollWidth` must not exceed the viewport. Long Team names, emails, badges, and action labels wrap, truncate, or move below identity instead of widening an ancestor. @@ -256,7 +259,7 @@ Semantic colors are tuned for contrast against the void palette — never use Ta - **Primary continuation action:** one accent `Resume in [agent]` button that opens a clone-style popup (GitHub `Code` button pattern). The popup offers one command per situation — `Spool CLI installed` (`spool resume `) as the default tab, and `First time — install Spool` (install script + resume) one tab away — each with its own copy action. Never force the curl bootstrap inline on the page. Explain that resuming creates new work and leaves the source unchanged. - **Stars and published forks:** Public Sessions expose a GitHub-style `Star` control and counts beside Resume. Stars require a signed-in viewer and are removed when the target leaves Public visibility. `Forks` is not a second write path: it counts direct descendants created through `spool resume`, verified by the server with the single-use Resume grant, that were later published and remain Public. A client-declared lineage may support legacy display, but never increments the public fork count; Team and Link-only descendants do not affect it either. These signals do not alter `Top` ranking in the first release. - **Session titles and Summaries:** the reading surface prefers the summary-derived task title (what the Session accomplished), falling back to the first-prompt-derived label only for legacy Sessions. Titles are stored bilingually (`title` English, `title_zh` Simplified Chinese, ≤96 chars) and new Summary documents carry complete English and Simplified Chinese Markdown bodies. One locale snapshot selects title and Summary together. Each Summary must orient an unfamiliar technical reader with background, motivation, the concrete problem, work performed, validation, and outcome before project-local detail; it must not assume the reader already knows the conversation. -- **Session language:** A reader can switch explicitly between English and Simplified Chinese from the persistent shell. The preference is available signed in or out, persists on the device, and applies atomically to Session titles, Summary bodies, and feed excerpts. It does not imply that product chrome has been fully translated. +- **Session language:** A reader can switch explicitly between English and Simplified Chinese from a top content toolbar on Session-bearing surfaces. The homepage, Docs, legal pages, generic account pages, and global navigation do not show the control because those surfaces do not visibly change. The preference is available signed in or out, persists on the device, and applies atomically to Session titles, Summary bodies, and feed excerpts. It does not imply that product chrome has been fully translated. - **Token cost:** when recorded usage exists, show the estimated API cost as quiet mono metadata (`$1.87 · 2.4M tokens`) beside publication facts — evidence, not a vanity metric. Costs are computed at publish time from a vendored model-pricing snapshot (LiteLLM-style, refreshed by editing the file); the read path never fetches pricing. - **Link-only Session:** Same reader, but label it `Link-only` and exclude discovery navigation that implies public listing. - **Withdrawn:** Keep a stable unavailable page with no leaked title, Summary, author, or content. @@ -266,7 +269,9 @@ Semantic colors are tuned for contrast against the void palette — never use Ta - **Tenant model:** Each Team is a durable workspace, not a filter over personal content. Resource ownership and authorization come from the server; a client-side Team switcher is only navigation context. - **Roles:** `Owner`, `Admin`, and `Member` are single, explicit roles. Owners manage the workspace and ownership; Admins manage members, invitations, and Team Sessions; Members can read Team Sessions and contribute their own. The last Owner cannot leave, be removed, or be demoted until ownership is transferred. - **Creation:** Creating a Team immediately creates its WorkOS Organization membership and makes the creator an Owner. Show the durable Team name and membership state only after the complete operation succeeds. -- **Navigation:** The workspace sidebar's `Teams` disclosure lists only server-confirmed memberships and links each name directly to `/teams/{id}`; mobile expands those children in the navigation menu. `/teams` is the focused `Create team` surface, not a second list-page hop. `/teams/{id}` uses `Sessions`, `Members`, and `Settings` sections; `Sessions` defaults to a recent-activity feed ordered newest first. The global account menu may provide Team shortcuts, but must not imply that switching scope changes authorization. +- **Navigation:** The workspace sidebar exposes `Projects` as a first-class destination. Its `Teams` disclosure lists only server-confirmed memberships and links each name directly to `/teams/{id}`; mobile expands those children in the navigation menu. `/teams` is the focused `Create team` surface, not a second list-page hop. `/teams/{id}` uses route-addressable `Sessions`, `Projects`, `Members`, and `Settings` sections; Sessions and Projects default newest first. The global account menu may provide Team shortcuts, but must not imply that switching scope changes authorization. +- **Project assignment:** Every hosted Session belongs to exactly one Project in the same tenant. A personal Session belongs to its author's personal Project; a Team-owned Session belongs to a Project owned by that Team while preserving the individual author. Moving a Session between Projects never changes its records, visibility, authorship, stars, or verified-fork lineage. A Personal → Team ownership transfer selects the Team Project in the same atomic mutation. +- **CLI:** `spool share` and `spool subscribe` resolve the exact local Project of the selected Session, then require an explicit Hub Project binding. Interactive use may select or create a Project; non-interactive use fails closed without `--project` or an existing binding, and `--yes` never chooses a Project. Bindings are account-, tenant-, Hub-, and local-identity-specific. Re-sharing preserves the existing remote Project unless the author performs an explicit management move. - **Members:** The Members surface shows identity, role, join state, and pending invitations. Invitations name their recipient and intended role; Owners/Admins can resend or revoke them. Destructive membership actions require explicit confirmation and explain the effect on access. - **Session ownership:** Moving a personal Session into a Team is an ownership transfer. State that the Team keeps the Session if the author later leaves. Team Owners/Admins may manage Team-owned Sessions; attribution still names the original author while that identity exists. - **Responsive layout:** At 320px, use one column and move row actions below identity/content; every interactive target is at least 44px. At 768px, two-column forms are allowed. Desktop may use a 240px Team rail plus a main column, within the 1120px shell. @@ -481,5 +486,7 @@ In dense lists, prefer compact facts over repeated pronouns: | 2026-07-25 | Summaries are bilingual and context-first | A new Summary carries complete English and Simplified Chinese bodies beside bilingual task-outcome titles; locale selects both atomically. Each body first explains the system, motivation, and reader-relevant problem, then implementation, validation, and result. Legacy single-language bodies remain readable. | | 2026-07-25 | Human guidance is a sparse reading projection | Human prompts can be read without downloading the full raw Session. Provider-aware immutable guidance metadata counts call-side tools and visible reply characters, while prompt/reply records are fetched sparsely and agent prose opens on demand in an accessible modal. | | 2026-07-25 | Session language is an explicit reader choice | Browser locale remains the first-visit default, but readers can switch English and Simplified Chinese from the persistent shell. The device preference controls bilingual titles, Summaries, and feed excerpts together; it is available signed in or out and does not claim full product-chrome localization. | +| 2026-07-26 | Projects are hosted Session namespaces | A Project is metadata, not a code host: exactly one User or Team owns its slug, description, optional GitHub link, and related Sessions. Every hosted Session has a same-tenant Project while retaining its individual author. Personal Projects use `/@handle/slug`; Team Projects remain member-only and never enter public projections. | +| 2026-07-26 | Session language stays beside Session content | Supersedes the persistent-shell placement above: the stored bilingual preference is unchanged, but its control appears only in the top content toolbar of Session feeds/readers/Project feeds. Marketing, resource, legal, account, and global navigation surfaces omit a switch that would have no visible effect. | | 2026-07-18 | Geist Sans for chrome; Geist Mono for records | The font split distinguishes product interface from authentic Session content, commands, paths, and URLs. | | 2026-07-18 | Icons follow adjacent-role sizing | Local consistency within a row or toolbar matters more than a rigid global icon whitelist. | diff --git a/apps/app/src/main/ipc/hub-share.ts b/apps/app/src/main/ipc/hub-share.ts index f2bca86f..2fb91c16 100644 --- a/apps/app/src/main/ipc/hub-share.ts +++ b/apps/app/src/main/ipc/hub-share.ts @@ -21,6 +21,7 @@ import { isDiscoverySessionSid, isResumableSessionProvider, parseSessionText, + sessionRecordData, type SessionProvider, } from '@spool-lab/session-kit' // Type-only: share-kit's runtime bundle needs a DOM at import time and @@ -206,7 +207,9 @@ export function registerHubShareIpc(deps: HubShareIpcDeps = {}): void { async (_e, args: { sessionUuid: string }): Promise => { try { const { prepared, card } = await prepareEntry(args.sessionUuid, deps) - const secrets = scanRecordsForSecrets(prepared.records.map((record) => record.data)) + const secrets = scanRecordsForSecrets( + prepared.records.map((record) => sessionRecordData(record)), + ) return { ok: true, prepared: { diff --git a/apps/backend/functions/_scheduled/deletion-worker.ts b/apps/backend/functions/_scheduled/deletion-worker.ts index fd72fa34..854ed0ff 100644 --- a/apps/backend/functions/_scheduled/deletion-worker.ts +++ b/apps/backend/functions/_scheduled/deletion-worker.ts @@ -414,6 +414,16 @@ async function deleteHubContent( env.DB.prepare('DELETE FROM hub_sessions WHERE owner_user_id=? AND team_id IS NULL').bind( userId, ), + env.DB.prepare( + `/* account-deletion:delete-personal-project-receipts */ + DELETE FROM project_creation_requests + WHERE owner_user_id=? AND owner_team_id IS NULL`, + ).bind(userId), + env.DB.prepare( + `/* account-deletion:delete-personal-projects */ + DELETE FROM projects + WHERE owner_user_id=? AND owner_team_id IS NULL`, + ).bind(userId), ]) } diff --git a/apps/backend/functions/api/handles/check.ts b/apps/backend/functions/api/handles/check.ts index 12ea282b..d8f3ea9a 100644 --- a/apps/backend/functions/api/handles/check.ts +++ b/apps/backend/functions/api/handles/check.ts @@ -14,14 +14,9 @@ export const onRequestGet: PagesFunction = async (ctx) => { const v = validateHandle(handle) const headers = { 'cache-control': ccPublicRevalidate(10) } if (!v.ok) return jsonOk({ available: false, reason: v.reason }, { headers }) - // NOTE: `handles.handle` is the table PK, so once a row exists the - // value is occupied for INSERT purposes even after `released_at` is - // set. This query matches the design intent (released handles are - // re-claimable) — when the release flow ships, the claim path must - // switch to INSERT … ON CONFLICT(handle) DO UPDATE SET released_at=NULL. - const row = await ctx.env.DB.prepare( - 'SELECT 1 FROM handles WHERE handle=? AND released_at IS NULL', - ) + // Handles are permanent URL tombstones. Account/Team deletion makes the + // route inactive but never lets a different owner inherit old links. + const row = await ctx.env.DB.prepare('SELECT 1 FROM handles WHERE handle=?') .bind(v.handle) .first() return jsonOk({ available: !row }, { headers }) diff --git a/apps/backend/functions/api/handles/claim.ts b/apps/backend/functions/api/handles/claim.ts index dd9bfc06..0abb7a72 100644 --- a/apps/backend/functions/api/handles/claim.ts +++ b/apps/backend/functions/api/handles/claim.ts @@ -37,14 +37,17 @@ export const onRequestPost: PagesFunction = async (ctx) => { if (!v.ok) throw new ApiError('UNPROCESSABLE', v.reason) const [existing, prior] = await Promise.all([ - ctx.env.DB.prepare('SELECT user_id FROM handles WHERE handle=? AND released_at IS NULL') + ctx.env.DB.prepare('SELECT user_id, team_id, released_at FROM handles WHERE handle=?') .bind(v.handle) - .first<{ user_id: string }>(), + .first<{ user_id: string | null; team_id: string | null; released_at: number | null }>(), ctx.env.DB.prepare('SELECT handle FROM handles WHERE user_id=? AND released_at IS NULL') .bind(user.id) .first<{ handle: string }>(), ]) - if (existing && existing.user_id !== user.id) { + if ( + existing && + (existing.user_id !== user.id || existing.team_id !== null || existing.released_at !== null) + ) { throw new ApiError('CONFLICT', 'handle taken') } if (prior && prior.handle !== v.handle) { @@ -57,7 +60,7 @@ export const onRequestPost: PagesFunction = async (ctx) => { // of letting jsonError surface it as 500 INTERNAL. try { await ctx.env.DB.prepare( - 'INSERT INTO handles (handle, user_id, claimed_at) VALUES (?, ?, ?)', + 'INSERT INTO handles (handle, user_id, team_id, claimed_at) VALUES (?, ?, NULL, ?)', ) .bind(v.handle, user.id, Date.now()) .run() diff --git a/apps/backend/functions/api/hub/v1/projects.ts b/apps/backend/functions/api/hub/v1/projects.ts new file mode 100644 index 00000000..3a174109 --- /dev/null +++ b/apps/backend/functions/api/hub/v1/projects.ts @@ -0,0 +1,160 @@ +import type { PagesFunction } from '@cloudflare/workers-types' +import { z } from 'zod' + +import { ApiError, jsonError, jsonOk } from '../../../../src/errors' +import { requireHubUser } from '../../../../src/hub/auth' +import { activeTeamRole, type HubEnv } from '../../../../src/hub/head' +import { TEAM_ID_RE } from '../../../../src/hub/wire' +import { PROJECT_CREATE_RATE, PROJECT_LIST_RATE } from '../../../../src/projects/limits' +import { + createProjectIdempotently, + ensureProjectTenantHandle, + listHubProjectsForUser, + parseProjectListPageOptions, + serializeHubProject, + serializeProject, +} from '../../../../src/projects/store' +import { + CreateProjectBody, + requireProjectIdempotencyKey, + slugFromName, +} from '../../../../src/projects/validators' +import { checkRate } from '../../../../src/rate-limit' + +const HubProjectOwner = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('user'), id: z.string().min(1).max(192) }).strict(), + z.object({ kind: z.literal('team'), id: z.string().regex(TEAM_ID_RE) }).strict(), +]) + +const HubCreateProjectBody = CreateProjectBody.extend({ + owner: HubProjectOwner.optional(), + // Rolling-upgrade alias for clients from the first Projects preview. + teamId: z.string().regex(TEAM_ID_RE).nullable().optional(), + idempotency_key: z.string().optional(), +}) + .strict() + .superRefine((value, ctx) => { + if (value.owner === undefined && value.teamId === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['owner'], + message: 'owner is required', + }) + return + } + if ( + value.owner !== undefined && + value.teamId !== undefined && + (value.owner.kind !== (value.teamId === null ? 'user' : 'team') || + (value.owner.kind === 'team' && value.owner.id !== value.teamId)) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['owner'], + message: 'owner and teamId must identify the same tenant', + }) + } + }) + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const user = await requireHubUser(ctx.request, ctx.env) + const rate = await checkRate(ctx.env.RATE, { ...PROJECT_LIST_RATE, key: user.id }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const page = await listHubProjectsForUser( + ctx.env.DB, + user.id, + await parseProjectListPageOptions(ctx.request, ['hub-project-list', user.id]), + ) + return jsonOk( + { + actor: { id: user.id }, + projects: page.rows.map(serializeHubProject), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +export const onRequestPost: PagesFunction = async (ctx) => { + try { + const user = await requireHubUser(ctx.request, ctx.env) + const rate = await checkRate(ctx.env.RATE, { ...PROJECT_CREATE_RATE, key: user.id }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const parsed = HubCreateProjectBody.safeParse(await readJson(ctx.request)) + if (!parsed.success) { + throw new ApiError('UNPROCESSABLE', 'invalid Project', { issues: parsed.error.issues }) + } + const idempotencyKey = requireProjectIdempotencyKey(ctx.request, parsed.data.idempotency_key) + const canonicalOwner = + parsed.data.owner ?? + (parsed.data.teamId === null + ? { kind: 'user' as const, id: user.id } + : { kind: 'team' as const, id: parsed.data.teamId! }) + if (canonicalOwner.kind === 'user' && canonicalOwner.id !== user.id) { + throw new ApiError('NOT_FOUND') + } + const teamId = canonicalOwner.kind === 'team' ? canonicalOwner.id : null + const teamRole = teamId === null ? null : await activeTeamRole(ctx.env.DB, teamId, user.id) + if (teamId !== null && teamRole !== 'owner' && teamRole !== 'admin') { + throw new ApiError(teamRole === null ? 'NOT_FOUND' : 'FORBIDDEN') + } + const now = Date.now() + await ensureProjectTenantHandle(ctx.env.DB, { + actorUserId: user.id, + tenant: teamId === null ? { userId: user.id, teamId: null } : { userId: null, teamId }, + now, + }) + const input = { + name: parsed.data.name, + slug: parsed.data.slug ?? slugFromName(parsed.data.name), + description: parsed.data.description, + github_url: parsed.data.github_url, + } + const tenant = + teamId === null + ? ({ userId: user.id, teamId: null } as const) + : ({ userId: null, teamId } as const) + const result = await createProjectIdempotently(ctx.env.DB, { + actorUserId: user.id, + tenant, + input, + idempotencyKey, + now, + }) + return jsonOk( + { + project: await serializeProject(ctx.env.DB, result.project, { canManage: true }), + }, + { + status: result.replayed ? 200 : 201, + headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' }, + }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +function privateProjectError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +async function readJson(request: Request): Promise { + try { + return await request.json() + } catch { + throw new ApiError('BAD_REQUEST', 'invalid json') + } +} diff --git a/apps/backend/functions/api/hub/v1/sessions/[sid]/head.ts b/apps/backend/functions/api/hub/v1/sessions/[sid]/head.ts index ebbd372c..f6692288 100644 --- a/apps/backend/functions/api/hub/v1/sessions/[sid]/head.ts +++ b/apps/backend/functions/api/hub/v1/sessions/[sid]/head.ts @@ -1,4 +1,4 @@ -import type { PagesFunction } from '@cloudflare/workers-types' +import type { D1PreparedStatement, PagesFunction } from '@cloudflare/workers-types' import { costForUsage, isDiscoverySessionSid } from '@spool-lab/session-kit' import { auditAfterCommit } from '../../../../../../src/audit-after-commit' @@ -34,6 +34,10 @@ import { teamStorageBytes, } from '../../../../../../src/hub/store' import { parseHeadBody, requireSid, TEAM_QUOTA_BYTES } from '../../../../../../src/hub/wire' +import { + ensureProjectTenantHandle, + prepareAuthorizedDefaultProjectInsert, +} from '../../../../../../src/projects/store' import { publicBaseUrl } from '../../../../../../src/public-url' // Step 3 of the share handshake: commit the head. Re-runs the same @@ -49,12 +53,8 @@ export const onRequestPost: PagesFunction = async (ctx) => { const body = await parseHeadBody(ctx.request) const existing = await getHubSession(ctx.env.DB, sid) - const { missing, aliasOids, teamId, teamRole } = await validateHead( - ctx.env.DB, - user.id, - sid, - body, - ) + const { missing, aliasOids, teamId, teamRole, projectId, projectNeedsInsert } = + await validateHead(ctx.env.DB, user.id, sid, body) if (missing.length > 0) { throw new ApiError('CONFLICT', 'objects missing — upload before committing', { missing: missing.slice(0, 50), @@ -96,6 +96,7 @@ export const onRequestPost: PagesFunction = async (ctx) => { : null const accessChanged = existing === null || existing.team_id !== teamId || currentVisibility !== effectiveVisibility + const projectChanged = existing === null || existing.project_id !== projectId const requestedStorageVisibility: 'private' | 'unlisted' = effectiveVisibility === 'team' ? 'private' : 'unlisted' const committedStorageVisibility: 'private' | 'unlisted' = @@ -106,6 +107,7 @@ export const onRequestPost: PagesFunction = async (ctx) => { const requireTeamManager = teamId !== null && ((existingTeamOwned && accessChanged) || + (existingTeamOwned && projectChanged) || (!existingTeamOwned && effectiveVisibility !== 'team')) if (requireTeamManager && teamRole !== 'owner' && teamRole !== 'admin') { throw new ApiError('FORBIDDEN') @@ -160,17 +162,21 @@ export const onRequestPost: PagesFunction = async (ctx) => { spoolFileOid: body.spoolFileOid, costUsd: cost?.usd ?? null, totalTokens: cost?.totalTokens ?? null, + projectId, now, actorUserId: user.id, expectedTeamId: existing?.team_id ?? null, + expectedProjectId: existing?.project_id ?? null, expectedVisibility: existing?.visibility ?? 'unlisted', expectedWithdrawnAt: existing?.withdrawn_at ?? null, expectedRoot: existing?.root ?? null, expectedUpdatedAt: existing?.updated_at ?? null, expectedPublished: wasPublic, targetTeamId: teamId, + targetProjectId: projectId, targetVisibility: requestedStorageVisibility, changeAccess: accessChanged, + changeProject: projectChanged, clearWithdrawal: existing?.team_id == null && existing?.withdrawn_at != null, requireTeamManager, } as const @@ -178,7 +184,26 @@ export const onRequestPost: PagesFunction = async (ctx) => { existing === null ? prepareAuthorizedHeadInsert(ctx.env.DB, sessionWrite) : prepareAuthorizedHeadUpdate(ctx.env.DB, sessionWrite) - const statements = [sessionCommit] + const statements: D1PreparedStatement[] = [] + // A Project may have been synthesized by the rolling-deploy compatibility + // trigger while the previous Worker was still serving traffic. Repair the + // tenant route identity before every commit, not only on first creation. + await ensureProjectTenantHandle(ctx.env.DB, { + actorUserId: user.id, + tenant: teamId === null ? { userId: user.id, teamId: null } : { userId: null, teamId }, + now, + }) + if (projectNeedsInsert) { + statements.push( + prepareAuthorizedDefaultProjectInsert(ctx.env.DB, { + actorUserId: user.id, + tenant: teamId === null ? { userId: user.id, teamId: null } : { userId: null, teamId }, + now, + }), + ) + } + const sessionCommitIndex = statements.length + statements.push(sessionCommit) // A legacy client may recommit Summary/card metadata against the same // immutable record root. Preserve a server-backfilled projection when // that old view cannot carry guidance; a new root without guidance must @@ -281,7 +306,9 @@ export const onRequestPost: PagesFunction = async (ctx) => { } throw error } - if ((results[0]?.meta.changes ?? 0) === 0) throw new ApiError('NOT_FOUND') + if ((results[sessionCommitIndex]?.meta.changes ?? 0) === 0) { + throw new ApiError('NOT_FOUND') + } auditAfterCommit(ctx, { user_id: user.id, @@ -292,6 +319,7 @@ export const onRequestPost: PagesFunction = async (ctx) => { count: body.count, visibility: effectiveVisibility, team_id: teamId, + project_id: projectId, }, }) diff --git a/apps/backend/functions/api/hub/v1/sessions/[sid]/index.ts b/apps/backend/functions/api/hub/v1/sessions/[sid]/index.ts index 65722b61..bcee4a1c 100644 --- a/apps/backend/functions/api/hub/v1/sessions/[sid]/index.ts +++ b/apps/backend/functions/api/hub/v1/sessions/[sid]/index.ts @@ -5,14 +5,17 @@ import { isPublishedToDiscovery, } from '../../../../../../src/discovery/projection' import { jsonError, jsonOk } from '../../../../../../src/errors' +import { optionalHubUser } from '../../../../../../src/hub/auth' import { getSessionGuidance } from '../../../../../../src/hub/guidance' import { + activeTeamRole, isTeamOnlySession, requireReadableSession, type HubEnv, } from '../../../../../../src/hub/head' import { getHubAuthor } from '../../../../../../src/hub/store' import { requireSid } from '../../../../../../src/hub/wire' +import { getProjectById, serializeProject } from '../../../../../../src/projects/store' // Public head meta. Deliberately uncached (middleware defaults /api/ to // no-store): a withdraw must take effect on the next load. The bulky, @@ -22,13 +25,31 @@ export const onRequestGet: PagesFunction = async (ctx) => { try { const sid = requireSid(ctx.params['sid']) const session = await requireReadableSession(ctx.request, ctx.env, sid) - const author = await getHubAuthor(ctx.env.DB, session.owner_user_id) + const teamOnly = isTeamOnlySession(session) + const optionalReader = + session.team_id !== null && !teamOnly ? await optionalHubUser(ctx.request, ctx.env) : null + const isCurrentTeamMember = + session.team_id !== null && + optionalReader !== null && + (await activeTeamRole(ctx.env.DB, session.team_id, optionalReader.id)) !== null + // Team Projects are private tenant metadata. A Team-owned Session may be + // deliberately disclosed as Public or Link-only, but that never turns its + // Project, owner handle, or aggregate Session count into a public + // projection. Authenticated current members retain that context for CLI + // re-share and Team workspace management. + const exposeProject = session.team_id === null || teamOnly || isCurrentTeamMember + const [author, projectRow] = await Promise.all([ + getHubAuthor(ctx.env.DB, session.owner_user_id), + exposeProject + ? getProjectById(ctx.env.DB, session.project_id, { includeArchived: true }) + : Promise.resolve(null), + ]) + if (exposeProject && !projectRow) throw new Error('Session Project missing') const team = session.team_id ? await ctx.env.DB.prepare('SELECT id, name FROM teams WHERE id=? AND archived_at IS NULL') .bind(session.team_id) .first<{ id: string; name: string }>() : null - const teamOnly = isTeamOnlySession(session) const published = teamOnly ? false : await isPublishedToDiscovery(ctx.env.DB, sid) const summaryMd = session.note_md const guidance = await getSessionGuidance(ctx.env.DB, session) @@ -59,9 +80,10 @@ export const onRequestGet: PagesFunction = async (ctx) => { updatedAt: session.updated_at, visibility: teamOnly ? 'team' : published ? 'public' : 'link-only', team, + project: projectRow ? await serializeProject(ctx.env.DB, projectRow) : null, author, }, - teamOnly + session.team_id !== null ? { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } } : undefined, ) diff --git a/apps/backend/functions/api/me/projects/[projectId].ts b/apps/backend/functions/api/me/projects/[projectId].ts new file mode 100644 index 00000000..b0b7ee73 --- /dev/null +++ b/apps/backend/functions/api/me/projects/[projectId].ts @@ -0,0 +1,112 @@ +import type { D1Database, KVNamespace, PagesFunction } from '@cloudflare/workers-types' + +import { auditAfterCommit } from '../../../../src/audit-after-commit' +import { requireUser } from '../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../src/errors' +import { serializeManagedSession } from '../../../../src/hub/management' +import { + getPersonalProject, + isProjectConstraintError, + listProjectSessions, + parseProjectSessionPageOptions, + serializeProject, + updateProject, +} from '../../../../src/projects/store' +import { parseUpdateProjectBody, requireProjectId } from '../../../../src/projects/validators' + +type Env = { DB: D1Database; SESSIONS: KVNamespace; RATE: KVNamespace } +type Params = 'projectId' + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const projectId = requireProjectId(ctx.params.projectId) + const project = await getPersonalProject(ctx.env.DB, projectId, user.id) + if (!project) throw new ApiError('NOT_FOUND') + const page = await listProjectSessions( + ctx.env.DB, + projectId, + { + kind: 'personal', + userId: user.id, + }, + await parseProjectSessionPageOptions(ctx.request, ['personal-project', user.id, projectId]), + ) + return jsonOk( + { + project: await serializeProject( + ctx.env.DB, + { + ...project, + session_count: Number( + ( + await ctx.env.DB.prepare( + 'SELECT COUNT(*) AS count FROM hub_sessions WHERE project_id=? AND withdrawn_at IS NULL', + ) + .bind(projectId) + .first<{ count: number }>() + )?.count ?? 0, + ), + }, + { canManage: true }, + ), + sessions: await Promise.all( + page.rows.map((row) => serializeManagedSession(ctx.env.DB, row)), + ), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +export const onRequestPatch: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const projectId = requireProjectId(ctx.params.projectId) + if (!(await getPersonalProject(ctx.env.DB, projectId, user.id))) { + throw new ApiError('NOT_FOUND') + } + const input = await parseUpdateProjectBody(ctx.request) + let project + try { + project = await updateProject(ctx.env.DB, { + projectId, + actorUserId: user.id, + tenant: { userId: user.id, teamId: null }, + input, + now: Date.now(), + }) + } catch (error) { + if (isProjectConstraintError(error)) { + throw new ApiError('CONFLICT', 'Project slug is already in use') + } + throw error + } + auditAfterCommit(ctx, { + user_id: user.id, + action: input.archived ? 'project.archive' : 'project.update', + target_id: projectId, + }) + return jsonOk( + { project: await serializeProject(ctx.env.DB, project, { canManage: true }) }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +function privateProjectError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/apps/backend/functions/api/me/projects/index.ts b/apps/backend/functions/api/me/projects/index.ts new file mode 100644 index 00000000..51edf864 --- /dev/null +++ b/apps/backend/functions/api/me/projects/index.ts @@ -0,0 +1,100 @@ +import type { D1Database, KVNamespace, PagesFunction } from '@cloudflare/workers-types' + +import { auditAfterCommit } from '../../../../src/audit-after-commit' +import { requireUser } from '../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../src/errors' +import { PROJECT_CREATE_RATE, PROJECT_LIST_RATE } from '../../../../src/projects/limits' +import { + createProjectIdempotently, + ensureProjectTenantHandle, + listPersonalProjects, + parseProjectListPageOptions, + projectOwner, + serializeProject, + serializeProjectWithOwner, +} from '../../../../src/projects/store' +import { + parseCreateProjectBody, + requireProjectIdempotencyKey, +} from '../../../../src/projects/validators' +import { checkRate } from '../../../../src/rate-limit' + +type Env = { DB: D1Database; SESSIONS: KVNamespace; RATE: KVNamespace } + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const rate = await checkRate(ctx.env.RATE, { ...PROJECT_LIST_RATE, key: user.id }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const page = await listPersonalProjects( + ctx.env.DB, + user.id, + await parseProjectListPageOptions(ctx.request, ['personal-project-list', user.id]), + ) + const owner = page.rows[0] ? await projectOwner(ctx.env.DB, page.rows[0]) : null + return jsonOk( + { + projects: + owner === null + ? [] + : page.rows.map((row) => serializeProjectWithOwner(row, owner, { canManage: true })), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +export const onRequestPost: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const rate = await checkRate(ctx.env.RATE, { ...PROJECT_CREATE_RATE, key: user.id }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const input = await parseCreateProjectBody(ctx.request) + const idempotencyKey = requireProjectIdempotencyKey(ctx.request) + const now = Date.now() + await ensureProjectTenantHandle(ctx.env.DB, { + actorUserId: user.id, + tenant: { userId: user.id, teamId: null }, + now, + }) + const result = await createProjectIdempotently(ctx.env.DB, { + actorUserId: user.id, + tenant: { userId: user.id, teamId: null }, + input, + idempotencyKey, + now, + }) + if (!result.replayed) { + auditAfterCommit(ctx, { + user_id: user.id, + action: 'project.create', + target_id: result.project.id, + details: { owner: 'user' }, + }) + } + return jsonOk( + { project: await serializeProject(ctx.env.DB, result.project, { canManage: true }) }, + { + status: result.replayed ? 200 : 201, + headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' }, + }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +function privateProjectError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/apps/backend/functions/api/me/sessions/[sid].ts b/apps/backend/functions/api/me/sessions/[sid].ts index 2ab4bb7c..f413912d 100644 --- a/apps/backend/functions/api/me/sessions/[sid].ts +++ b/apps/backend/functions/api/me/sessions/[sid].ts @@ -1,4 +1,4 @@ -import type { PagesFunction } from '@cloudflare/workers-types' +import type { D1PreparedStatement, PagesFunction } from '@cloudflare/workers-types' import { isDiscoverySessionSid } from '@spool-lab/session-kit' import { z } from 'zod' @@ -28,13 +28,22 @@ import { presentTeamOids, teamStorageBytes, } from '../../../../src/hub/store' -import { requireSid, TEAM_ID_RE, TEAM_QUOTA_BYTES } from '../../../../src/hub/wire' +import { PROJECT_ID_RE, requireSid, TEAM_ID_RE, TEAM_QUOTA_BYTES } from '../../../../src/hub/wire' +import { + activeProjectForTenant, + ensureProjectTenantHandle, + prepareAuthorizedDefaultProjectInsert, + resolveDefaultProject, +} from '../../../../src/projects/store' +import type { ProjectTenant } from '../../../../src/projects/types' import { checkRate } from '../../../../src/rate-limit' const VisibilityBody = z .object({ visibility: z.enum(['public', 'link-only', 'team']), team_id: z.string().regex(TEAM_ID_RE).nullable().optional(), + project_id: z.string().regex(PROJECT_ID_RE).nullable().optional(), + expected_project_id: z.string().regex(PROJECT_ID_RE).nullable().optional(), }) .strict() @@ -81,6 +90,48 @@ export const onRequestPatch: PagesFunction = async (ctx) => { if (targetRole === null) throw new ApiError('NOT_FOUND') } } + const isPersonalToTeamTransfer = session.team_id === null && targetTeamId !== null + if ( + isPersonalToTeamTransfer && + (typeof body.data.project_id !== 'string' || + typeof body.data.expected_project_id !== 'string') + ) { + throw new ApiError( + 'UNPROCESSABLE', + 'Moving a Session to a Team requires project_id and expected_project_id', + ) + } + if ( + body.data.expected_project_id !== undefined && + session.project_id !== body.data.expected_project_id + ) { + throw new ApiError('CONFLICT', 'Session Project changed; review the current Project') + } + const targetTenant: ProjectTenant = + targetTeamId === null + ? { userId: user.id, teamId: null } + : { userId: null, teamId: targetTeamId } + let targetProjectId: string + let projectNeedsInsert = false + if ( + body.data.project_id === undefined && + session.team_id === targetTeamId && + typeof session.project_id === 'string' + ) { + targetProjectId = session.project_id + if (!(await activeProjectForTenant(ctx.env.DB, targetProjectId, targetTenant))) { + throw new ApiError('CONFLICT', 'Session Project is archived or unavailable') + } + } else if (typeof body.data.project_id === 'string') { + targetProjectId = body.data.project_id + if (!(await activeProjectForTenant(ctx.env.DB, targetProjectId, targetTenant))) { + throw new ApiError('NOT_FOUND') + } + } else { + const fallback = await resolveDefaultProject(ctx.env.DB, targetTenant) + targetProjectId = fallback.projectId + projectNeedsInsert = fallback.needsInsert + } const requireTargetManager = targetTeamId !== null && (session.team_id !== null || body.data.visibility !== 'team') if (requireTargetManager && targetRole !== 'owner' && targetRole !== 'admin') { @@ -141,22 +192,40 @@ export const onRequestPatch: PagesFunction = async (ctx) => { ) const targetVisibility: 'private' | 'unlisted' = body.data.visibility === 'team' ? 'private' : 'unlisted' - const statements = [ + const statements: D1PreparedStatement[] = [] + if (projectNeedsInsert) { + await ensureProjectTenantHandle(ctx.env.DB, { + actorUserId: user.id, + tenant: targetTenant, + now, + }) + statements.push( + prepareAuthorizedDefaultProjectInsert(ctx.env.DB, { + actorUserId: user.id, + tenant: targetTenant, + now, + }), + ) + } + const visibilityUpdateIndex = statements.length + statements.push( prepareAuthorizedVisibilityUpdate(ctx.env.DB, { sid, actorUserId: user.id, expectedTeamId: session.team_id, + expectedProjectId: session.project_id, expectedVisibility: session.visibility, expectedPublished: wasPublic, expectedRoot: session.root, expectedUpdatedAt: session.updated_at, targetTeamId, + targetProjectId, targetVisibility, lineageJson: safeLineageJson, requireTargetManager, now, }), - ] + ) if (targetTeamId && aliasOids.length > 0) { statements.push( ...prepareAuthorizedPersonalObjectAliases(ctx.env.DB, { @@ -230,12 +299,18 @@ export const onRequestPatch: PagesFunction = async (ctx) => { } throw error } - if ((results[0]?.meta.changes ?? 0) === 0) throw new ApiError('NOT_FOUND') + if ((results[visibilityUpdateIndex]?.meta.changes ?? 0) === 0) { + throw new ApiError('NOT_FOUND') + } auditAfterCommit(ctx, { user_id: user.id, action: 'hub-visibility', target_id: sid, - details: { visibility: body.data.visibility, team_id: targetTeamId }, + details: { + visibility: body.data.visibility, + team_id: targetTeamId, + project_id: targetProjectId, + }, }) const changed = await getHubSession(ctx.env.DB, sid) diff --git a/apps/backend/functions/api/owners/[handle]/projects/[slug].ts b/apps/backend/functions/api/owners/[handle]/projects/[slug].ts new file mode 100644 index 00000000..40bae352 --- /dev/null +++ b/apps/backend/functions/api/owners/[handle]/projects/[slug].ts @@ -0,0 +1,145 @@ +import type { D1Database, KVNamespace, PagesFunction } from '@cloudflare/workers-types' + +import { optionalUser } from '../../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../../src/errors' +import { serializeManagedSession } from '../../../../../src/hub/management' +import { + getProjectBySlugForOwner, + listProjectSessions, + listPublicProjectSessions, + parseProjectSessionPageOptions, + resolveHandleOwner, + serializeProject, +} from '../../../../../src/projects/store' +import { requireOwnerHandle, requireProjectSlug } from '../../../../../src/projects/validators' + +type Env = { DB: D1Database; SESSIONS: KVNamespace } +type Params = 'handle' | 'slug' + +export const onRequestGet: PagesFunction = async (ctx) => { + let privateResponse = false + try { + const handle = requireOwnerHandle(ctx.params.handle) + const slug = requireProjectSlug(ctx.params.slug) + const viewer = await optionalUser(ctx.request, ctx.env) + const owner = await resolveHandleOwner(ctx.env.DB, handle) + if (!owner) throw new ApiError('NOT_FOUND') + + if (owner.kind === 'team') { + privateResponse = true + if (!viewer) throw new ApiError('NOT_FOUND') + const membership = await ctx.env.DB.prepare( + `SELECT m.role FROM team_memberships m + JOIN teams t ON t.id=m.team_id + WHERE m.team_id=? AND m.user_id=? + AND t.archived_at IS NULL AND t.deletion_pending_until IS NULL`, + ) + .bind(owner.id, viewer.id) + .first<{ role: string }>() + if (!membership) throw new ApiError('NOT_FOUND') + const project = await getProjectBySlugForOwner(ctx.env.DB, owner, slug) + if (!project) throw new ApiError('NOT_FOUND') + const page = await listProjectSessions( + ctx.env.DB, + project.id, + { + kind: 'team', + teamId: owner.id, + userId: viewer.id, + }, + await parseProjectSessionPageOptions(ctx.request, [ + 'owner-team-project', + owner.id, + viewer.id, + project.id, + ]), + ) + const canManage = membership.role === 'owner' || membership.role === 'admin' + return jsonOk( + { + owner, + project: await serializeProject( + ctx.env.DB, + { + ...project, + session_count: Number( + ( + await ctx.env.DB.prepare( + 'SELECT COUNT(*) AS count FROM hub_sessions WHERE project_id=? AND withdrawn_at IS NULL', + ) + .bind(project.id) + .first<{ count: number }>() + )?.count ?? 0, + ), + }, + { canManage }, + ), + sessions: await Promise.all( + page.rows.map((session) => serializeManagedSession(ctx.env.DB, session, canManage)), + ), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } + + const project = await getProjectBySlugForOwner(ctx.env.DB, owner, slug) + if (!project) throw new ApiError('NOT_FOUND') + const page = await listPublicProjectSessions( + ctx.env.DB, + project.id, + owner.id, + await parseProjectSessionPageOptions(ctx.request, [ + 'owner-public-project', + owner.id, + project.id, + ]), + ) + const publicCount = Number( + ( + await ctx.env.DB.prepare( + `SELECT COUNT(*) AS count + FROM hub_sessions s + JOIN hub_session_discovery d ON d.sid=s.sid + WHERE s.project_id=? AND s.owner_user_id=? + AND s.team_id IS NULL AND s.visibility='unlisted' + AND s.withdrawn_at IS NULL`, + ) + .bind(project.id, owner.id) + .first<{ count: number }>() + )?.count ?? 0, + ) + return jsonOk( + { + owner, + project: await serializeProject( + ctx.env.DB, + { + ...project, + session_count: publicCount, + }, + { canManage: viewer?.id === owner.id }, + ), + sessions: await Promise.all( + page.rows.map((session) => serializeManagedSession(ctx.env.DB, session, false)), + ), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'no-store' } }, + ) + } catch (error) { + return privateResponse ? privateError(error) : jsonError(error) + } +} + +function privateError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/apps/backend/functions/api/owners/[handle]/projects/index.ts b/apps/backend/functions/api/owners/[handle]/projects/index.ts new file mode 100644 index 00000000..b3f422a0 --- /dev/null +++ b/apps/backend/functions/api/owners/[handle]/projects/index.ts @@ -0,0 +1,107 @@ +import type { D1Database, KVNamespace, PagesFunction } from '@cloudflare/workers-types' + +import { optionalUser } from '../../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../../src/errors' +import { serializeManagedSession } from '../../../../../src/hub/management' +import { PROJECT_LIST_RATE } from '../../../../../src/projects/limits' +import { + fullTenantProjectListOptions, + listPublicProjectsForUser, + listPublicSessionsForUser, + listTeamProjects, + parseProjectSessionPageOptions, + resolveHandleOwner, + serializeProjectWithOwner, +} from '../../../../../src/projects/store' +import { requireOwnerHandle } from '../../../../../src/projects/validators' +import { checkRate } from '../../../../../src/rate-limit' +import { clientIp } from '../../../../../src/request' + +type Env = { DB: D1Database; SESSIONS: KVNamespace; RATE: KVNamespace } +type Params = 'handle' + +export const onRequestGet: PagesFunction = async (ctx) => { + let privateResponse = false + try { + const handle = requireOwnerHandle(ctx.params.handle) + const viewer = await optionalUser(ctx.request, ctx.env) + const rate = await checkRate(ctx.env.RATE, { + ...PROJECT_LIST_RATE, + key: viewer ? viewer.id : `ip:${clientIp(ctx.request)}`, + }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const owner = await resolveHandleOwner(ctx.env.DB, handle) + if (!owner) throw new ApiError('NOT_FOUND') + + if (owner.kind === 'team') { + privateResponse = true + if (!viewer) throw new ApiError('NOT_FOUND') + const projectsPage = await listTeamProjects( + ctx.env.DB, + owner.id, + viewer.id, + fullTenantProjectListOptions(), + ) + if (projectsPage === null) throw new ApiError('NOT_FOUND') + const membership = await ctx.env.DB.prepare( + 'SELECT role FROM team_memberships WHERE team_id=? AND user_id=?', + ) + .bind(owner.id, viewer.id) + .first<{ role: string }>() + if (!membership) throw new ApiError('NOT_FOUND') + const canManage = membership.role === 'owner' || membership.role === 'admin' + const serializedProjects = projectsPage.rows.map((project) => + serializeProjectWithOwner(project, owner, { canManage }), + ) + return jsonOk( + { + owner, + projects: serializedProjects, + session_count: 0, + sessions: [], + next_cursor: null, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } + + const [projects, sessionsPage] = await Promise.all([ + listPublicProjectsForUser(ctx.env.DB, owner.id), + parseProjectSessionPageOptions(ctx.request, ['owner-public-sessions', owner.id]).then( + (options) => listPublicSessionsForUser(ctx.env.DB, owner.id, options), + ), + ]) + const serializedProjects = projects.map((project) => + serializeProjectWithOwner(project, owner, { canManage: viewer?.id === owner.id }), + ) + return jsonOk( + { + owner, + projects: serializedProjects, + sessions: await Promise.all( + sessionsPage.rows.map((session) => serializeManagedSession(ctx.env.DB, session, false)), + ), + session_count: serializedProjects.reduce( + (count, project) => count + project.session_count, + 0, + ), + next_cursor: sessionsPage.nextCursor, + }, + { headers: { 'cache-control': 'no-store' } }, + ) + } catch (error) { + return privateResponse ? privateError(error) : jsonError(error) + } +} + +function privateError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/apps/backend/functions/api/projects.ts b/apps/backend/functions/api/projects.ts new file mode 100644 index 00000000..8c7a2c09 --- /dev/null +++ b/apps/backend/functions/api/projects.ts @@ -0,0 +1,100 @@ +import type { D1Database, KVNamespace, PagesFunction } from '@cloudflare/workers-types' + +import { base64urlFromBuffer } from '../../src/auth/pkce' +import { ApiError, jsonError, jsonOk } from '../../src/errors' +import { PROJECT_LIST_RATE } from '../../src/projects/limits' +import { + listPublicProjects, + serializePublicProject, + type PublicProjectCursor, +} from '../../src/projects/store' +import { checkRate } from '../../src/rate-limit' +import { clientIp } from '../../src/request' + +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 50 + +type Env = { DB: D1Database; RATE: KVNamespace } + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const rate = await checkRate(ctx.env.RATE, { + ...PROJECT_LIST_RATE, + key: `ip:${clientIp(ctx.request)}`, + }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const { cursor, limit } = parseOptions(ctx.request) + const rows = await listPublicProjects(ctx.env.DB, { after: cursor, limit }) + const hasMore = rows.length > limit + const page = hasMore ? rows.slice(0, limit) : rows + const last = page.at(-1) + return jsonOk( + { + projects: page.map((row) => ({ + ...serializePublicProject(row), + last_session_at: row.last_session_at, + })), + next_cursor: + hasMore && last + ? encodeCursor({ lastSessionAt: last.last_session_at, id: last.id }) + : null, + }, + { headers: { 'cache-control': 'no-store' } }, + ) + } catch (error) { + return jsonError(error) + } +} + +function parseOptions(request: Request): { cursor: PublicProjectCursor | null; limit: number } { + const url = new URL(request.url) + if (url.searchParams.getAll('cursor').length > 1 || url.searchParams.getAll('limit').length > 1) { + throw new ApiError('BAD_REQUEST', 'cursor and limit may be provided at most once') + } + const cursorValue = url.searchParams.get('cursor') + const limitValue = url.searchParams.get('limit') + let limit = DEFAULT_LIMIT + if (limitValue !== null) { + if (!/^\d+$/.test(limitValue)) throw new ApiError('BAD_REQUEST', 'invalid limit') + limit = Number(limitValue) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new ApiError('BAD_REQUEST', `limit must be between 1 and ${MAX_LIMIT}`) + } + } + return { cursor: cursorValue === null ? null : decodeCursor(cursorValue), limit } +} + +function encodeCursor(value: PublicProjectCursor): string { + const bytes = new TextEncoder().encode( + JSON.stringify({ v: 1, t: value.lastSessionAt, i: value.id }), + ) + const buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return base64urlFromBuffer(buffer) +} + +function decodeCursor(value: string): PublicProjectCursor { + if (value.length > 512 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new ApiError('BAD_REQUEST', 'malformed cursor') + } + try { + const standard = value.replaceAll('-', '+').replaceAll('_', '/') + const binary = atob(standard.padEnd(Math.ceil(standard.length / 4) * 4, '=')) + const parsed = JSON.parse( + new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), + ) as Record + if ( + parsed['v'] !== 1 || + typeof parsed['t'] !== 'number' || + !Number.isSafeInteger(parsed['t']) || + parsed['t'] < 0 || + typeof parsed['i'] !== 'string' || + parsed['i'].length > 256 + ) { + throw new Error('invalid') + } + return { lastSessionAt: parsed['t'], id: parsed['i'] } + } catch { + throw new ApiError('BAD_REQUEST', 'malformed cursor') + } +} diff --git a/apps/backend/functions/api/teams/[teamId]/handle.ts b/apps/backend/functions/api/teams/[teamId]/handle.ts new file mode 100644 index 00000000..78bd45e0 --- /dev/null +++ b/apps/backend/functions/api/teams/[teamId]/handle.ts @@ -0,0 +1,47 @@ +import type { PagesFunction } from '@cloudflare/workers-types' + +import { requireUser } from '../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../src/errors' +import { activeTeamHandle, changeTeamHandle, validateHandle } from '../../../../src/handles' +import { requireTeamAccess } from '../../../../src/teams/auth' +import type { TeamApiEnv } from '../../../../src/teams/env' +import { requireTeamId } from '../../../../src/teams/validators' + +type Params = 'teamId' + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const teamId = requireTeamId(ctx.params.teamId) + await requireTeamAccess(ctx.env.DB, teamId, user.id) + return jsonOk( + { handle: await activeTeamHandle(ctx.env.DB, teamId) }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return jsonError(error) + } +} + +export const onRequestPut: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const teamId = requireTeamId(ctx.params.teamId) + await requireTeamAccess(ctx.env.DB, teamId, user.id, 'team:identity') + const body = (await ctx.request.json().catch(() => null)) as { handle?: unknown } | null + const parsed = validateHandle(body?.handle) + if (!parsed.ok) throw new ApiError('UNPROCESSABLE', parsed.reason) + await changeTeamHandle(ctx.env.DB, { + teamId, + actorUserId: user.id, + handle: parsed.handle, + now: Date.now(), + }) + return jsonOk( + { handle: parsed.handle }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return jsonError(error) + } +} diff --git a/apps/backend/functions/api/teams/[teamId]/index.ts b/apps/backend/functions/api/teams/[teamId]/index.ts index ba1bb3ad..88405995 100644 --- a/apps/backend/functions/api/teams/[teamId]/index.ts +++ b/apps/backend/functions/api/teams/[teamId]/index.ts @@ -3,6 +3,7 @@ import type { PagesFunction } from '@cloudflare/workers-types' import { auditAfterCommit } from '../../../../src/audit-after-commit' import { requireUser } from '../../../../src/auth/require' import { ApiError, jsonError, jsonOk } from '../../../../src/errors' +import { changeTeamHandle } from '../../../../src/handles' import { checkRate } from '../../../../src/rate-limit' import { requireTeamAccess } from '../../../../src/teams/auth' import { @@ -38,25 +39,41 @@ export const onRequestPatch: PagesFunction = async (ctx) => try { const user = await requireUser(ctx.request, ctx.env) const teamId = requireTeamId(ctx.params.teamId) - const { name } = await parseUpdateTeamBody(ctx.request) - const { team: current } = await requireTeamAccess(ctx.env.DB, teamId, user.id, 'team:update') + const update = await parseUpdateTeamBody(ctx.request) + const permission = update.handle === undefined ? 'team:update' : 'team:identity' + const { team: current } = await requireTeamAccess(ctx.env.DB, teamId, user.id, permission) const rate = await checkRate(ctx.env.RATE, { ...TEAM_NAME_UPDATE_RATE, key: teamId, }) if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') - const client = createWorkosTeamClient(ctx.env) - await client.updateOrganization(current.workos_organization_id, name) - try { - const updated = await updateLocalTeamName(ctx.env.DB, teamId, user.id, name, Date.now()) - if (!updated) throw new ApiError('FORBIDDEN') - } catch (error) { - await client - .updateOrganization(current.workos_organization_id, current.name) - .catch((compensationError: unknown) => { - console.error('failed to compensate WorkOS organization rename', compensationError) - }) - throw error + if (update.name !== undefined) { + const client = createWorkosTeamClient(ctx.env) + await client.updateOrganization(current.workos_organization_id, update.name) + try { + const updated = await updateLocalTeamName( + ctx.env.DB, + teamId, + user.id, + update.name, + Date.now(), + ) + if (!updated) throw new ApiError('FORBIDDEN') + } catch (error) { + await client + .updateOrganization(current.workos_organization_id, current.name) + .catch((compensationError: unknown) => { + console.error('failed to compensate WorkOS organization rename', compensationError) + }) + throw error + } + } else if (update.handle !== undefined) { + await changeTeamHandle(ctx.env.DB, { + teamId, + actorUserId: user.id, + handle: update.handle, + now: Date.now(), + }) } const team = await getTeamForMember(ctx.env.DB, teamId, user.id) if (!team) throw new ApiError('NOT_FOUND') @@ -64,7 +81,7 @@ export const onRequestPatch: PagesFunction = async (ctx) => user_id: user.id, action: 'team.update', target_id: teamId, - details: { fields: ['name'] }, + details: { fields: [update.name === undefined ? 'handle' : 'name'] }, }) return jsonOk({ team }) } catch (error) { diff --git a/apps/backend/functions/api/teams/[teamId]/projects/[projectId].ts b/apps/backend/functions/api/teams/[teamId]/projects/[projectId].ts new file mode 100644 index 00000000..48cfc447 --- /dev/null +++ b/apps/backend/functions/api/teams/[teamId]/projects/[projectId].ts @@ -0,0 +1,129 @@ +import type { PagesFunction } from '@cloudflare/workers-types' + +import { auditAfterCommit } from '../../../../../src/audit-after-commit' +import { requireUser } from '../../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../../src/errors' +import { serializeManagedSession } from '../../../../../src/hub/management' +import { + getTeamProject, + isProjectConstraintError, + listProjectSessions, + parseProjectSessionPageOptions, + serializeProject, + updateProject, +} from '../../../../../src/projects/store' +import { + parseUpdateProjectBody, + requireProjectId, + requireProjectTeamId, +} from '../../../../../src/projects/validators' +import { requireTeamAccess } from '../../../../../src/teams/auth' +import type { TeamApiEnv } from '../../../../../src/teams/env' + +type Params = 'teamId' | 'projectId' + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const teamId = requireProjectTeamId(ctx.params.teamId) + const projectId = requireProjectId(ctx.params.projectId) + const access = await requireTeamAccess(ctx.env.DB, teamId, user.id) + const canManage = access.membership.role === 'owner' || access.membership.role === 'admin' + const project = await getTeamProject(ctx.env.DB, projectId, teamId) + if (!project) throw new ApiError('NOT_FOUND') + const page = await listProjectSessions( + ctx.env.DB, + projectId, + { + kind: 'team', + teamId, + userId: user.id, + }, + await parseProjectSessionPageOptions(ctx.request, [ + 'team-project', + teamId, + user.id, + projectId, + ]), + ) + return jsonOk( + { + project: await serializeProject( + ctx.env.DB, + { + ...project, + session_count: Number( + ( + await ctx.env.DB.prepare( + 'SELECT COUNT(*) AS count FROM hub_sessions WHERE project_id=? AND withdrawn_at IS NULL', + ) + .bind(projectId) + .first<{ count: number }>() + )?.count ?? 0, + ), + }, + { canManage }, + ), + sessions: await Promise.all( + page.rows.map((row) => serializeManagedSession(ctx.env.DB, row, canManage)), + ), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +export const onRequestPatch: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const teamId = requireProjectTeamId(ctx.params.teamId) + const projectId = requireProjectId(ctx.params.projectId) + await requireTeamAccess(ctx.env.DB, teamId, user.id, 'team:update') + if (!(await getTeamProject(ctx.env.DB, projectId, teamId))) { + throw new ApiError('NOT_FOUND') + } + const input = await parseUpdateProjectBody(ctx.request) + let project + try { + project = await updateProject(ctx.env.DB, { + projectId, + actorUserId: user.id, + tenant: { userId: null, teamId }, + input, + now: Date.now(), + }) + } catch (error) { + if (isProjectConstraintError(error)) { + throw new ApiError('CONFLICT', 'Project slug is already in use') + } + throw error + } + auditAfterCommit(ctx, { + user_id: user.id, + action: input.archived ? 'project.archive' : 'project.update', + target_id: projectId, + details: { team_id: teamId }, + }) + return jsonOk( + { project: await serializeProject(ctx.env.DB, project, { canManage: true }) }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +function privateProjectError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/apps/backend/functions/api/teams/[teamId]/projects/index.ts b/apps/backend/functions/api/teams/[teamId]/projects/index.ts new file mode 100644 index 00000000..945fac4a --- /dev/null +++ b/apps/backend/functions/api/teams/[teamId]/projects/index.ts @@ -0,0 +1,110 @@ +import type { PagesFunction } from '@cloudflare/workers-types' + +import { auditAfterCommit } from '../../../../../src/audit-after-commit' +import { requireUser } from '../../../../../src/auth/require' +import { ApiError, jsonError, jsonOk } from '../../../../../src/errors' +import { PROJECT_CREATE_RATE, PROJECT_LIST_RATE } from '../../../../../src/projects/limits' +import { + createProjectIdempotently, + ensureProjectTenantHandle, + listTeamProjects, + parseProjectListPageOptions, + projectOwner, + serializeProject, + serializeProjectWithOwner, +} from '../../../../../src/projects/store' +import { + parseCreateProjectBody, + requireProjectIdempotencyKey, + requireProjectTeamId, +} from '../../../../../src/projects/validators' +import { checkRate } from '../../../../../src/rate-limit' +import { requireTeamAccess } from '../../../../../src/teams/auth' +import type { TeamApiEnv } from '../../../../../src/teams/env' + +type Params = 'teamId' + +export const onRequestGet: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const teamId = requireProjectTeamId(ctx.params.teamId) + const access = await requireTeamAccess(ctx.env.DB, teamId, user.id) + const rate = await checkRate(ctx.env.RATE, { ...PROJECT_LIST_RATE, key: user.id }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const page = await listTeamProjects( + ctx.env.DB, + teamId, + user.id, + await parseProjectListPageOptions(ctx.request, ['team-project-list', teamId, user.id]), + ) + if (page === null) throw new ApiError('NOT_FOUND') + const canManage = access.membership.role === 'owner' || access.membership.role === 'admin' + const owner = page.rows[0] ? await projectOwner(ctx.env.DB, page.rows[0]) : null + return jsonOk( + { + projects: + owner === null + ? [] + : page.rows.map((row) => serializeProjectWithOwner(row, owner, { canManage })), + next_cursor: page.nextCursor, + }, + { headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +export const onRequestPost: PagesFunction = async (ctx) => { + try { + const user = await requireUser(ctx.request, ctx.env) + const teamId = requireProjectTeamId(ctx.params.teamId) + await requireTeamAccess(ctx.env.DB, teamId, user.id, 'team:update') + const rate = await checkRate(ctx.env.RATE, { ...PROJECT_CREATE_RATE, key: user.id }) + if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS') + const input = await parseCreateProjectBody(ctx.request) + const idempotencyKey = requireProjectIdempotencyKey(ctx.request) + const now = Date.now() + await ensureProjectTenantHandle(ctx.env.DB, { + actorUserId: user.id, + tenant: { userId: null, teamId }, + now, + }) + const result = await createProjectIdempotently(ctx.env.DB, { + actorUserId: user.id, + tenant: { userId: null, teamId }, + input, + idempotencyKey, + now, + }) + if (!result.replayed) { + auditAfterCommit(ctx, { + user_id: user.id, + action: 'project.create', + target_id: result.project.id, + details: { owner: 'team', team_id: teamId }, + }) + } + return jsonOk( + { project: await serializeProject(ctx.env.DB, result.project, { canManage: true }) }, + { + status: result.replayed ? 200 : 201, + headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' }, + }, + ) + } catch (error) { + return privateProjectError(error) + } +} + +function privateProjectError(error: unknown): Response { + const response = jsonError(error) + const headers = new Headers(response.headers) + headers.set('cache-control', 'private, no-store') + headers.set('vary', 'Cookie, Authorization') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/apps/backend/functions/api/teams/index.ts b/apps/backend/functions/api/teams/index.ts index 458a0d65..968096e4 100644 --- a/apps/backend/functions/api/teams/index.ts +++ b/apps/backend/functions/api/teams/index.ts @@ -3,6 +3,11 @@ import type { PagesFunction } from '@cloudflare/workers-types' import { auditAfterCommit } from '../../../src/audit-after-commit' import { requireUser } from '../../../src/auth/require' import { ApiError, jsonError, jsonOk } from '../../../src/errors' +import { + assertHandleAvailable, + changeTeamHandle, + chooseAvailableTeamHandle, +} from '../../../src/handles' import { checkRate } from '../../../src/rate-limit' import { completeWorkosCleanup, @@ -11,6 +16,7 @@ import { import type { TeamApiEnv } from '../../../src/teams/env' import { MAX_ACTIVE_TEAMS_CREATED_PER_USER, TEAM_CREATE_RATE } from '../../../src/teams/limits' import { + adoptTeamCreationHandle, beginTeamCreationRequest, completeTeamCreationRequest, countActiveTeamsCreatedByUser, @@ -43,7 +49,28 @@ export const onRequestPost: PagesFunction = async (ctx) => { if (user.deletion_pending_until != null) { throw new ApiError('CONFLICT', 'cancel account deletion before creating a Team') } - const { name } = await parseCreateTeamBody(ctx.request) + const { name, handle } = await parseCreateTeamBody(ctx.request) + const withRequiredHandle = async (teamId: string, requestedHandle: string) => { + const current = await getTeamForMember(ctx.env.DB, teamId, user.id) + if (!current) throw new ApiError('INTERNAL', 'created team could not be loaded') + if (current.handle !== null && current.handle !== requestedHandle) { + throw new ApiError('CONFLICT', 'Team creation handle does not match its receipt') + } + if (current.handle === null) { + await assertHandleAvailable(ctx.env.DB, requestedHandle) + await changeTeamHandle(ctx.env.DB, { + teamId, + actorUserId: user.id, + handle: requestedHandle, + now: Date.now(), + }) + } + const reloaded = (await getTeamForMember(ctx.env.DB, teamId, user.id)) ?? current + if (reloaded.handle !== requestedHandle) { + throw new ApiError('INTERNAL', 'created Team is missing its requested handle') + } + return reloaded + } const idempotencyKey = requireIdempotencyKey(ctx.request) let creation = await getTeamCreationRequest(ctx.env.DB, user.id, idempotencyKey) let resuming = creation !== null @@ -62,11 +89,20 @@ export const onRequestPost: PagesFunction = async (ctx) => { ) { throw new ApiError('CONFLICT', 'active team limit reached') } + const teamId = newTeamId() + const requestedHandle = + handle ?? + (await chooseAvailableTeamHandle(ctx.env.DB, { + label: name, + teamId, + })) + if (handle !== undefined) await assertHandleAvailable(ctx.env.DB, requestedHandle) const begun = await beginTeamCreationRequest(ctx.env.DB, { userId: user.id, idempotencyKey, - teamId: newTeamId(), + teamId, name, + requestedHandle, now: Date.now(), }) creation = begun.request @@ -75,26 +111,105 @@ export const onRequestPost: PagesFunction = async (ctx) => { throw new ApiError('CONFLICT', 'Idempotency-Key was already used for another Team') } } - const existingTeam = resuming + let existingTeam = resuming ? await getTeamForMember(ctx.env.DB, creation.team_id, user.id) : null + if (creation.requested_handle === null) { + const requestedHandle = + existingTeam?.handle ?? + handle ?? + (await chooseAvailableTeamHandle(ctx.env.DB, { + label: name, + teamId: creation.team_id, + })) + if (existingTeam?.handle == null) { + await assertHandleAvailable(ctx.env.DB, requestedHandle) + } + creation = await adoptTeamCreationHandle( + ctx.env.DB, + user.id, + idempotencyKey, + requestedHandle, + Date.now(), + ) + } + const requestedHandle = creation.requested_handle + if (requestedHandle === null) { + throw new ApiError('INTERNAL', 'Team creation request is missing handle intent') + } + if (handle !== undefined && requestedHandle !== handle) { + throw new ApiError('CONFLICT', 'Idempotency-Key was already used with another handle') + } if (existingTeam) { + existingTeam = await withRequiredHandle(existingTeam.id, requestedHandle) if (creation.status !== 'completed') { await completeTeamCreationRequest( ctx.env.DB, user.id, idempotencyKey, creation.team_id, + requestedHandle, Date.now(), ) } return jsonOk({ team: existingTeam }) } - const workosUserId = await getWorkosUserId(ctx.env.DB, user.id) const client = createWorkosTeamClient(ctx.env) const teamId = creation.team_id let organizationId = creation.workos_organization_id + + const recoverConcurrentSuccess = async () => { + const row = await getTeamById(ctx.env.DB, teamId) + if (!row || (organizationId !== null && row.workos_organization_id !== organizationId)) { + return null + } + const memberTeam = await getTeamForMember(ctx.env.DB, teamId, user.id) + if (!memberTeam) return null + const team = await withRequiredHandle(teamId, requestedHandle) + await completeTeamCreationRequest( + ctx.env.DB, + user.id, + idempotencyKey, + teamId, + requestedHandle, + Date.now(), + ) + return team + } + const abandonTerminalAttempt = async (detail: string) => { + const recoveredBeforeFailure = await recoverConcurrentSuccess() + if (recoveredBeforeFailure) return recoveredBeforeFailure + const failed = await failTeamCreationRequest(ctx.env.DB, user.id, idempotencyKey, Date.now()) + if (!failed) { + const recoveredAfterFailure = await recoverConcurrentSuccess() + if (recoveredAfterFailure) return recoveredAfterFailure + throw new ApiError('CONFLICT', 'Team creation state changed; retry the same request') + } + if (organizationId !== null) { + const organizationToDelete = organizationId + await client + .deleteOrganization(organizationToDelete) + .then(() => + completeWorkosCleanup(ctx.env.DB, 'organization.delete', organizationToDelete), + ) + .catch((compensationError: unknown) => { + console.error('failed to compensate WorkOS organization creation', compensationError) + }) + } + throw new ApiError('CONFLICT', detail) + } + try { + // This saves an upstream round trip in the common conflict case. The + // atomic handles INSERT below remains the actual race boundary. + await assertHandleAvailable(ctx.env.DB, requestedHandle) + } catch (error) { + if (!(error instanceof ApiError) || error.code !== 'CONFLICT') throw error + const recovered = await abandonTerminalAttempt('handle taken') + return jsonOk({ team: recovered }) + } + + const workosUserId = await getWorkosUserId(ctx.env.DB, user.id) if (!organizationId) { let organization try { @@ -123,15 +238,6 @@ export const onRequestPost: PagesFunction = async (ctx) => { // Both WorkOS mutations use stable idempotency keys. A timeout leaves the // request pending; replaying the same browser key resumes by the unique // WorkOS external_id instead of creating a second Organization. - const recoverConcurrentSuccess = async () => { - const row = await getTeamById(ctx.env.DB, teamId) - if (!row || row.workos_organization_id !== organizationId) return null - const team = await getTeamForMember(ctx.env.DB, teamId, user.id) - if (!team) return null - await completeTeamCreationRequest(ctx.env.DB, user.id, idempotencyKey, teamId, Date.now()) - return team - } - let membership try { membership = await client.createMembership(organizationId, workosUserId) @@ -152,39 +258,35 @@ export const onRequestPost: PagesFunction = async (ctx) => { workosMembershipUpdatedAt: membership.updated_at ? Date.parse(membership.updated_at) : null, idempotencyKey, ownerUserId: user.id, + requestedHandle, now: Date.now(), }) } catch (error) { const recovered = await recoverConcurrentSuccess() if (recovered) return jsonOk({ team: recovered }) + if ( + error instanceof Error && + /UNIQUE constraint failed: handles(?:\.handle|\.team_id)?/i.test(error.message) + ) { + const terminalRecovery = await abandonTerminalAttempt('handle taken') + return jsonOk({ team: terminalRecovery }) + } throw error } if (!created) { const recovered = await recoverConcurrentSuccess() if (recovered) return jsonOk({ team: recovered }) - const failed = await failTeamCreationRequest(ctx.env.DB, user.id, idempotencyKey, Date.now()) - if (!failed) { - const recovered = await recoverConcurrentSuccess() - if (recovered) return jsonOk({ team: recovered }) - throw new ApiError('CONFLICT', 'Team creation state changed; retry the same request') - } - await client - .deleteOrganization(organizationId) - .then(() => completeWorkosCleanup(ctx.env.DB, 'organization.delete', organizationId)) - .catch((compensationError: unknown) => { - console.error('failed to compensate WorkOS organization creation', compensationError) - }) - throw new ApiError('CONFLICT', 'active team limit reached') + const terminalRecovery = await abandonTerminalAttempt('active team limit reached') + return jsonOk({ team: terminalRecovery }) } - const team = await getTeamForMember(ctx.env.DB, teamId, user.id) - if (!team) throw new ApiError('INTERNAL', 'created team could not be loaded') + const teamWithHandle = await withRequiredHandle(teamId, requestedHandle) auditAfterCommit(ctx, { user_id: user.id, action: 'team.create', target_id: teamId, }) - return jsonOk({ team }, { status: 201 }) + return jsonOk({ team: teamWithHandle }, { status: 201 }) } catch (error) { return jsonError(error) } diff --git a/apps/backend/migrations/0014_projects.sql b/apps/backend/migrations/0014_projects.sql new file mode 100644 index 00000000..093dc003 --- /dev/null +++ b/apps/backend/migrations/0014_projects.sql @@ -0,0 +1,1505 @@ +-- 0014_projects.sql +-- +-- Stable owner handles and hosted Projects. +-- +-- A handle is a permanent route identity shared by users and Teams. Releasing +-- a handle retires it; the primary-key tombstone intentionally prevents a +-- different owner from reclaiming an old URL. +-- +-- A Project belongs to exactly one tenant. hub_sessions.owner_user_id remains +-- the original human author/writer while hub_sessions.team_id remains the +-- durable Team tenant, so project ownership is represented independently. + +-- Rebuild the original user-only handles table in place. No table has an +-- inbound foreign key to handles, so this preserves every historical claim +-- without changing the user/profile query surface. +ALTER TABLE handles RENAME TO handles_legacy_0014; + +CREATE TABLE handles ( + handle TEXT PRIMARY KEY COLLATE NOCASE, + user_id TEXT REFERENCES users(id) ON DELETE RESTRICT, + team_id TEXT REFERENCES teams(id) ON DELETE RESTRICT, + claimed_at INTEGER NOT NULL, + released_at INTEGER, + CHECK ( + (user_id IS NOT NULL AND team_id IS NULL) OR + (user_id IS NULL AND team_id IS NOT NULL) + ), + CHECK (released_at IS NULL OR released_at >= claimed_at) +); + +INSERT INTO handles (handle, user_id, team_id, claimed_at, released_at) +SELECT + legacy.handle, + legacy.user_id, + NULL, + legacy.claimed_at, + CASE + -- The v1 table did not enforce one active handle per user. Retain the + -- alphabetically first active route (the same deterministic choice used by + -- public attribution) and preserve any duplicates as retired tombstones. + WHEN + legacy.released_at IS NULL AND + legacy.handle <> ( + SELECT MIN(candidate.handle COLLATE NOCASE) + FROM handles_legacy_0014 candidate + WHERE + candidate.user_id = legacy.user_id AND + candidate.released_at IS NULL + ) + THEN legacy.claimed_at + ELSE legacy.released_at + END +FROM handles_legacy_0014 legacy; + +DROP TABLE handles_legacy_0014; + +CREATE INDEX handles_user ON handles(user_id); +CREATE INDEX handles_team ON handles(team_id); +CREATE UNIQUE INDEX handles_active_user + ON handles(user_id) + WHERE user_id IS NOT NULL AND released_at IS NULL; +CREATE UNIQUE INDEX handles_active_team + ON handles(team_id) + WHERE team_id IS NOT NULL AND released_at IS NULL; + +-- Claims are immutable identities. The only lifecycle mutation is the first +-- transition from active to released; once released, a handle remains a +-- permanent tombstone. +CREATE TRIGGER handles_identity_immutable +BEFORE UPDATE OF handle, user_id, team_id ON handles +WHEN + NEW.handle IS NOT OLD.handle OR + NEW.user_id IS NOT OLD.user_id OR + NEW.team_id IS NOT OLD.team_id +BEGIN + SELECT RAISE(ABORT, 'handle identity is immutable'); +END; + +CREATE TRIGGER handles_release_monotonic +BEFORE UPDATE OF released_at ON handles +WHEN OLD.released_at IS NOT NULL AND NEW.released_at IS NOT OLD.released_at +BEGIN + SELECT RAISE(ABORT, 'released handle is a permanent tombstone'); +END; + +CREATE TRIGGER handles_no_delete +BEFORE DELETE ON handles +BEGIN + SELECT RAISE(ABORT, 'handle tombstones cannot be deleted'); +END; + +-- A Team creation operation chooses its route before it creates any WorkOS +-- resources. Existing receipts remain nullable until the backfill at the end +-- of this migration so an in-flight pre-0014 operation can be resumed. +ALTER TABLE team_creation_requests + ADD COLUMN requested_handle TEXT COLLATE NOCASE + CHECK ( + requested_handle IS NULL OR ( + length(requested_handle) BETWEEN 3 AND 32 AND + requested_handle = lower(requested_handle) AND + substr(requested_handle, 1, 1) GLOB '[a-z]' AND + requested_handle NOT GLOB '*[^a-z0-9_-]*' + ) + ); + +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + owner_user_id TEXT REFERENCES users(id) ON DELETE RESTRICT, + owner_team_id TEXT REFERENCES teams(id) ON DELETE RESTRICT, + slug TEXT NOT NULL COLLATE NOCASE, + name TEXT NOT NULL, + description TEXT, + github_url TEXT, + created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + archived_at INTEGER, + CHECK ( + (owner_user_id IS NOT NULL AND owner_team_id IS NULL) OR + (owner_user_id IS NULL AND owner_team_id IS NOT NULL) + ), + CHECK ( + length(id) BETWEEN 16 AND 200 AND + id GLOB 'project_*' AND + id NOT GLOB '*[^0-9A-Za-z_-]*' + ), + CHECK ( + length(slug) BETWEEN 1 AND 80 AND + slug = lower(slug) AND + slug NOT GLOB '*[^a-z0-9-]*' AND + substr(slug, 1, 1) GLOB '[a-z0-9]' AND + substr(slug, -1, 1) GLOB '[a-z0-9]' + ), + CHECK (length(trim(name)) BETWEEN 1 AND 120), + CHECK (updated_at >= created_at), + CHECK (archived_at IS NULL OR archived_at >= created_at) +); + +CREATE UNIQUE INDEX projects_user_slug + ON projects(owner_user_id, slug) + WHERE owner_user_id IS NOT NULL; +CREATE UNIQUE INDEX projects_team_slug + ON projects(owner_team_id, slug) + WHERE owner_team_id IS NOT NULL; +CREATE INDEX projects_user_updated + ON projects(owner_user_id, updated_at DESC, id) + WHERE owner_user_id IS NOT NULL; +CREATE INDEX projects_team_updated + ON projects(owner_team_id, updated_at DESC, id) + WHERE owner_team_id IS NOT NULL; + +CREATE TRIGGER projects_tenant_immutable +BEFORE UPDATE OF owner_user_id, owner_team_id ON projects +WHEN + NEW.owner_user_id IS NOT OLD.owner_user_id OR + NEW.owner_team_id IS NOT OLD.owner_team_id +BEGIN + SELECT RAISE(ABORT, 'project tenant is immutable'); +END; + +-- Identity retirement follows the existing soft-delete/archive columns. Team +-- archival happens through several WorkOS reconciliation paths; putting the +-- cleanup here keeps all of them consistent. Unarchiving never revives a +-- retired handle or Project URL. +CREATE TRIGGER users_retire_projects +AFTER UPDATE OF deleted_at ON users +WHEN OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL +BEGIN + UPDATE handles + SET released_at = NEW.deleted_at + WHERE user_id = NEW.id AND released_at IS NULL; + + UPDATE projects + SET + archived_at = NEW.deleted_at, + updated_at = MAX(updated_at, NEW.deleted_at) + WHERE owner_user_id = NEW.id AND archived_at IS NULL; +END; + +CREATE TRIGGER teams_retire_projects +AFTER UPDATE OF archived_at ON teams +WHEN OLD.archived_at IS NULL AND NEW.archived_at IS NOT NULL +BEGIN + UPDATE handles + SET released_at = NEW.archived_at + WHERE team_id = NEW.id AND released_at IS NULL; + + UPDATE projects + SET + archived_at = NEW.archived_at, + updated_at = MAX(updated_at, NEW.archived_at) + WHERE owner_team_id = NEW.id AND archived_at IS NULL; +END; + +-- POST /api/hub/v1/projects is idempotent within the actor + owner tenant +-- scope. owner_scope is redundant on purpose: it gives SQLite a non-null +-- composite primary key while the CHECK keeps it derived from the real FKs. +CREATE TABLE project_creation_requests ( + actor_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + owner_scope TEXT NOT NULL, + owner_user_id TEXT REFERENCES users(id) ON DELETE RESTRICT, + owner_team_id TEXT REFERENCES teams(id) ON DELETE RESTRICT, + idempotency_key TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + request_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (actor_user_id, owner_scope, idempotency_key), + UNIQUE (project_id), + CHECK ( + ( + owner_user_id IS NOT NULL AND + owner_team_id IS NULL AND + owner_scope = 'user:' || owner_user_id + ) OR ( + owner_user_id IS NULL AND + owner_team_id IS NOT NULL AND + owner_scope = 'team:' || owner_team_id + ) + ), + CHECK (length(idempotency_key) BETWEEN 8 AND 200), + CHECK ( + length(request_hash) = 64 AND + request_hash NOT GLOB '*[^0-9a-f]*' + ) +); +CREATE INDEX project_creation_requests_project + ON project_creation_requests(project_id); + +ALTER TABLE hub_sessions + ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE RESTRICT; + +-- Every tenant with an existing Session receives an empty deterministic +-- fallback Project. New Hub code uses the same IDs for old clients that do not +-- yet provide an explicit Project. +INSERT INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at +) +SELECT + 'project_default_user_' || s.owner_user_id, + s.owner_user_id, + NULL, + 'sessions', + 'Sessions', + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + NULL, + s.owner_user_id, + MIN(s.created_at), + MAX(s.updated_at), + NULL +FROM hub_sessions s +WHERE s.team_id IS NULL +GROUP BY s.owner_user_id; + +INSERT INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at +) +SELECT + 'project_default_team_' || s.team_id, + NULL, + s.team_id, + 'sessions', + 'Sessions', + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + NULL, + t.created_by_user_id, + MIN(s.created_at), + MAX(s.updated_at), + NULL +FROM hub_sessions s +JOIN teams t ON t.id = s.team_id +WHERE s.team_id IS NOT NULL +GROUP BY s.team_id, t.created_by_user_id; + +-- Known React Vapor Sessions are a reviewed pair from the bilingual production +-- backfill. The SELECT stays tenant-relative so a restored/staging database +-- cannot accidentally attach them to a hard-coded user id. +INSERT INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at +) +SELECT + 'project_user_' || s.owner_user_id || '_react-vapor', + s.owner_user_id, + NULL, + 'react-vapor', + 'React Vapor', + 'React Vapor explores a compiler-driven React execution model that reduces runtime reconciliation while preserving compatibility with existing React code and third-party packages.', + NULL, + s.owner_user_id, + MIN(s.created_at), + MAX(s.updated_at), + NULL +FROM hub_sessions s +WHERE + s.team_id IS NULL AND + s.sid IN ( + 'claude_52d60289-1a34-41ff-bf63-a77593a53d8a', + 'claude_9cea282a-d9cf-434d-83f4-633cca085faf' + ) +GROUP BY s.owner_user_id; + +-- Existing Spool implementation Sessions are grouped by their actual owner. +-- The avatar Session author also receives a personal Spool Project so any of +-- that author's personal Sessions have a stable reviewed destination. +INSERT INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at +) +SELECT + 'project_user_' || s.owner_user_id || '_spool', + s.owner_user_id, + NULL, + 'spool', + 'Spool', + 'Spool turns local coding-agent Sessions into durable, shareable records that people can read, search, and resume across tools.', + 'https://github.com/paperboytm/spool', + s.owner_user_id, + MIN(s.created_at), + MAX(s.updated_at), + NULL +FROM hub_sessions s +WHERE s.owner_user_id IN ( + SELECT owner_user_id + FROM hub_sessions + WHERE sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' + UNION + SELECT owner_user_id + FROM hub_sessions + WHERE sid IN ( + 'codex_019f88f4-7675-7fb1-b7b1-c9fb283fe866', + 'codex_019f8919-cd74-7ef1-8a2c-b26bef86e595', + 'codex_019f89dc-54e9-7eb1-97cc-753269f594cb', + 'codex_019f8a35-c2dd-7b72-a754-839cf3efae86', + 'codex_019f8e14-8152-7412-98c7-ab55a1e32de3' + ) +) +GROUP BY s.owner_user_id; + +-- Resolve the reviewed Paperboy tenant by the Team-owned avatar Session first, +-- falling back to the oldest live Team named Paperboy for fresh/staging copies. +INSERT INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at +) +SELECT + 'project_team_' || t.id || '_paperboy', + NULL, + t.id, + 'paperboy', + 'Paperboy', + 'Paperboy is the Team workspace for product and infrastructure work that does not belong to a more specific Project.', + NULL, + t.created_by_user_id, + t.created_at, + MAX(t.updated_at, COALESCE((SELECT MAX(updated_at) FROM hub_sessions WHERE team_id = t.id), t.updated_at)), + NULL +FROM teams t +WHERE t.id = COALESCE( + ( + SELECT team_id + FROM hub_sessions + WHERE + sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' AND + team_id IS NOT NULL + LIMIT 1 + ), + ( + SELECT id + FROM teams + WHERE lower(name) = 'paperboy' + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + created_at, + id + LIMIT 1 + ) +); + +INSERT INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at +) +SELECT + 'project_team_' || t.id || '_avatar-generator', + NULL, + t.id, + 'avatar-generator', + 'Avatar Generator', + 'Paperboy''s avatar generator turns eight fixed retro-futurist illustrations into reproducible default avatars by varying color palettes without redrawing their structure.', + NULL, + t.created_by_user_id, + t.created_at, + MAX(t.updated_at, COALESCE((SELECT MAX(updated_at) FROM hub_sessions WHERE team_id = t.id), t.updated_at)), + NULL +FROM teams t +WHERE t.id = COALESCE( + ( + SELECT team_id + FROM hub_sessions + WHERE + sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' AND + team_id IS NOT NULL + LIMIT 1 + ), + ( + SELECT id + FROM teams + WHERE lower(name) = 'paperboy' + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + created_at, + id + LIMIT 1 + ) +); + +-- Preserve already-retired owner state when upgrading a database whose user +-- deletion or Team archival happened before Projects existed. +UPDATE projects +SET + archived_at = ( + SELECT u.deleted_at + FROM users u + WHERE u.id = projects.owner_user_id + ), + updated_at = MAX( + updated_at, + ( + SELECT u.deleted_at + FROM users u + WHERE u.id = projects.owner_user_id + ) + ) +WHERE + owner_user_id IS NOT NULL AND + EXISTS ( + SELECT 1 + FROM users u + WHERE u.id = projects.owner_user_id AND u.deleted_at IS NOT NULL + ); + +UPDATE projects +SET + archived_at = ( + SELECT t.archived_at + FROM teams t + WHERE t.id = projects.owner_team_id + ), + updated_at = MAX( + updated_at, + ( + SELECT t.archived_at + FROM teams t + WHERE t.id = projects.owner_team_id + ) + ) +WHERE + owner_team_id IS NOT NULL AND + EXISTS ( + SELECT 1 + FROM teams t + WHERE t.id = projects.owner_team_id AND t.archived_at IS NOT NULL + ); + +-- Start every row at its tenant's deterministic fallback, then apply the +-- reviewed business grouping from broad to specific. +UPDATE hub_sessions +SET project_id = CASE + WHEN team_id IS NULL + THEN 'project_default_user_' || owner_user_id + ELSE 'project_default_team_' || team_id +END; + +UPDATE hub_sessions +SET project_id = 'project_user_' || owner_user_id || '_spool' +WHERE + team_id IS NULL AND + owner_user_id IN ( + SELECT owner_user_id + FROM hub_sessions + WHERE sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' + ); + +UPDATE hub_sessions +SET project_id = 'project_user_' || owner_user_id || '_spool' +WHERE + team_id IS NULL AND + sid IN ( + 'codex_019f88f4-7675-7fb1-b7b1-c9fb283fe866', + 'codex_019f8919-cd74-7ef1-8a2c-b26bef86e595', + 'codex_019f89dc-54e9-7eb1-97cc-753269f594cb', + 'codex_019f8a35-c2dd-7b72-a754-839cf3efae86', + 'codex_019f8e14-8152-7412-98c7-ab55a1e32de3' + ); + +UPDATE hub_sessions +SET project_id = 'project_user_' || owner_user_id || '_react-vapor' +WHERE + team_id IS NULL AND + sid IN ( + 'claude_52d60289-1a34-41ff-bf63-a77593a53d8a', + 'claude_9cea282a-d9cf-434d-83f4-633cca085faf' + ); + +UPDATE hub_sessions +SET project_id = 'project_team_' || team_id || '_paperboy' +WHERE team_id = COALESCE( + ( + SELECT team_id + FROM hub_sessions + WHERE + sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' AND + team_id IS NOT NULL + LIMIT 1 + ), + ( + SELECT id + FROM teams + WHERE lower(name) = 'paperboy' + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + created_at, + id + LIMIT 1 + ) +); + +UPDATE hub_sessions +SET project_id = 'project_team_' || team_id || '_avatar-generator' +WHERE + team_id = COALESCE( + ( + SELECT team_id + FROM hub_sessions + WHERE + sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' AND + team_id IS NOT NULL + LIMIT 1 + ), + ( + SELECT id + FROM teams + WHERE lower(name) = 'paperboy' + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + created_at, + id + LIMIT 1 + ) + ) AND + ( + sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' OR + EXISTS ( + SELECT 1 + FROM hub_session_discovery d + WHERE + d.sid = hub_sessions.sid AND + ( + lower(COALESCE(d.title, '')) LIKE '%avatar%' OR + lower(COALESCE(d.title, '')) LIKE '%summary%' OR + lower(COALESCE(d.title, '')) LIKE '%title%' OR + COALESCE(d.title, '') LIKE '%头像%' OR + COALESCE(d.title, '') LIKE '%摘要%' OR + COALESCE(d.title, '') LIKE '%标题%' + ) + ) + ); + +-- Controlled route seeds. They deliberately bypass the public reserved-word +-- validator, but still obey permanent global route ownership. +-- +-- The seed phase is replay-safe and deliberately fail-closed: +-- * an already-active exact claim is retained without mutation; +-- * a different active handle owned by the intended owner is released as a +-- permanent tombstone before the controlled handle is claimed; +-- * an exact handle already claimed by another owner, or already released by +-- any owner, aborts the migration instead of silently choosing a new URL. +CREATE TABLE controlled_handle_seeds_0014 ( + handle TEXT PRIMARY KEY COLLATE NOCASE, + user_id TEXT REFERENCES users(id) ON DELETE RESTRICT, + team_id TEXT REFERENCES teams(id) ON DELETE RESTRICT, + claimed_at INTEGER NOT NULL, + CHECK ( + (user_id IS NOT NULL AND team_id IS NULL) OR + (user_id IS NULL AND team_id IS NOT NULL) + ) +); + +INSERT INTO controlled_handle_seeds_0014 (handle, user_id, team_id, claimed_at) +SELECT + 'evan', + u.id, + NULL, + COALESCE( + (SELECT MIN(s.created_at) FROM hub_sessions s WHERE s.owner_user_id = u.id), + u.created_at + ) +FROM users u +WHERE + u.deleted_at IS NULL AND + u.id = ( + SELECT s.owner_user_id + FROM hub_sessions s + WHERE + s.team_id IS NULL AND + s.sid IN ( + 'codex_019f88f4-7675-7fb1-b7b1-c9fb283fe866', + 'codex_019f8919-cd74-7ef1-8a2c-b26bef86e595', + 'codex_019f89dc-54e9-7eb1-97cc-753269f594cb', + 'codex_019f8a35-c2dd-7b72-a754-839cf3efae86', + 'codex_019f8e14-8152-7412-98c7-ab55a1e32de3' + ) + ORDER BY + CASE s.sid + WHEN 'codex_019f88f4-7675-7fb1-b7b1-c9fb283fe866' THEN 0 + WHEN 'codex_019f8919-cd74-7ef1-8a2c-b26bef86e595' THEN 1 + WHEN 'codex_019f89dc-54e9-7eb1-97cc-753269f594cb' THEN 2 + WHEN 'codex_019f8a35-c2dd-7b72-a754-839cf3efae86' THEN 3 + ELSE 4 + END + LIMIT 1 + ); + +INSERT INTO controlled_handle_seeds_0014 (handle, user_id, team_id, claimed_at) +SELECT + 'xinyao', + u.id, + NULL, + COALESCE( + (SELECT MIN(s.created_at) FROM hub_sessions s WHERE s.owner_user_id = u.id), + u.created_at + ) +FROM users u +WHERE + u.deleted_at IS NULL AND + u.id = ( + SELECT s.owner_user_id + FROM hub_sessions s + WHERE + s.team_id IS NULL AND + s.sid IN ( + 'claude_52d60289-1a34-41ff-bf63-a77593a53d8a', + 'claude_9cea282a-d9cf-434d-83f4-633cca085faf' + ) + ORDER BY s.sid + LIMIT 1 + ); + +INSERT INTO controlled_handle_seeds_0014 (handle, user_id, team_id, claimed_at) +SELECT + 'vivian-kong', + u.id, + NULL, + COALESCE( + (SELECT MIN(s.created_at) FROM hub_sessions s WHERE s.owner_user_id = u.id), + u.created_at + ) +FROM users u +WHERE + u.deleted_at IS NULL AND + u.id = ( + SELECT s.owner_user_id + FROM hub_sessions s + WHERE s.sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' + LIMIT 1 + ); + +INSERT INTO controlled_handle_seeds_0014 (handle, user_id, team_id, claimed_at) +SELECT + 'paperboy', + NULL, + t.id, + t.created_at +FROM teams t +WHERE + t.archived_at IS NULL AND + t.id = COALESCE( + ( + SELECT team_id + FROM hub_sessions + WHERE + sid = 'codex_019f845e-2b39-7862-8fb6-287f0af11d12' AND + team_id IS NOT NULL + LIMIT 1 + ), + ( + SELECT id + FROM teams + WHERE lower(name) = 'paperboy' + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + created_at, + id + LIMIT 1 + ) + ); + +CREATE TABLE controlled_handle_seed_guard_0014 ( + ok INTEGER NOT NULL CONSTRAINT controlled_handle_seed_conflict CHECK (ok = 1) +); +INSERT INTO controlled_handle_seed_guard_0014 (ok) +SELECT CASE + WHEN + -- One owner cannot be assigned two controlled routes. + EXISTS ( + SELECT 1 + FROM controlled_handle_seeds_0014 first_seed + JOIN controlled_handle_seeds_0014 second_seed + ON first_seed.handle <> second_seed.handle + WHERE + ( + first_seed.user_id IS NOT NULL AND + first_seed.user_id = second_seed.user_id + ) OR ( + first_seed.team_id IS NOT NULL AND + first_seed.team_id = second_seed.team_id + ) + ) OR + -- A released exact route is a tombstone, and an exact route belonging to + -- another owner must never be reassigned. + EXISTS ( + SELECT 1 + FROM controlled_handle_seeds_0014 seed + JOIN handles existing ON existing.handle = seed.handle + WHERE NOT ( + existing.released_at IS NULL AND + ( + ( + seed.user_id IS NOT NULL AND + existing.user_id = seed.user_id AND + existing.team_id IS NULL + ) OR ( + seed.team_id IS NOT NULL AND + existing.user_id IS NULL AND + existing.team_id = seed.team_id + ) + ) + ) + ) + THEN 0 + ELSE 1 +END; +DROP TABLE controlled_handle_seed_guard_0014; + +-- Migrate an intended owner's previous active route to the controlled route. +-- The previous row remains queryable forever as a released tombstone. +UPDATE handles +SET released_at = MAX( + handles.claimed_at, + ( + SELECT seed.claimed_at + FROM controlled_handle_seeds_0014 seed + WHERE + ( + seed.user_id IS NOT NULL AND + seed.user_id = handles.user_id AND + handles.team_id IS NULL + ) OR ( + seed.team_id IS NOT NULL AND + seed.team_id = handles.team_id AND + handles.user_id IS NULL + ) + ) +) +WHERE + handles.released_at IS NULL AND + EXISTS ( + SELECT 1 + FROM controlled_handle_seeds_0014 seed + WHERE + seed.handle <> handles.handle AND + ( + ( + seed.user_id IS NOT NULL AND + seed.user_id = handles.user_id AND + handles.team_id IS NULL + ) OR ( + seed.team_id IS NOT NULL AND + seed.team_id = handles.team_id AND + handles.user_id IS NULL + ) + ) + ); + +INSERT INTO handles (handle, user_id, team_id, claimed_at, released_at) +SELECT seed.handle, seed.user_id, seed.team_id, seed.claimed_at, NULL +FROM controlled_handle_seeds_0014 seed +WHERE NOT EXISTS ( + SELECT 1 + FROM handles existing + WHERE + existing.handle = seed.handle AND + existing.released_at IS NULL AND + ( + ( + seed.user_id IS NOT NULL AND + existing.user_id = seed.user_id AND + existing.team_id IS NULL + ) OR ( + seed.team_id IS NOT NULL AND + existing.user_id IS NULL AND + existing.team_id = seed.team_id + ) + ) +); + +CREATE TABLE controlled_handle_seed_post_guard_0014 ( + ok INTEGER NOT NULL CONSTRAINT controlled_handle_seed_postcondition CHECK (ok = 1) +); +INSERT INTO controlled_handle_seed_post_guard_0014 (ok) +SELECT CASE + WHEN EXISTS ( + SELECT 1 + FROM controlled_handle_seeds_0014 seed + WHERE + NOT EXISTS ( + SELECT 1 + FROM handles exact + WHERE + exact.handle = seed.handle AND + exact.released_at IS NULL AND + ( + ( + seed.user_id IS NOT NULL AND + exact.user_id = seed.user_id AND + exact.team_id IS NULL + ) OR ( + seed.team_id IS NOT NULL AND + exact.user_id IS NULL AND + exact.team_id = seed.team_id + ) + ) + ) OR + EXISTS ( + SELECT 1 + FROM handles other + WHERE + other.released_at IS NULL AND + other.handle <> seed.handle AND + ( + ( + seed.user_id IS NOT NULL AND + other.user_id = seed.user_id AND + other.team_id IS NULL + ) OR ( + seed.team_id IS NOT NULL AND + other.user_id IS NULL AND + other.team_id = seed.team_id + ) + ) + ) + ) + THEN 0 + ELSE 1 +END; +DROP TABLE controlled_handle_seed_post_guard_0014; +DROP TABLE controlled_handle_seeds_0014; + +-- Every active Team, plus every remaining user who owns an active Project, +-- receives a deterministic route: a normalized readable name plus an owner-id +-- suffix. Teams need a handle before their first Project/Session is created. +-- The common suffix is eight stable id characters; an existing/tombstoned +-- collision or a same-run candidate collision expands it to sixteen stable +-- characters. The final UNIQUE constraints fail closed if even the expanded +-- candidate collides. +CREATE TABLE project_handle_candidates_0014 ( + owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user', 'team')), + owner_id TEXT NOT NULL, + user_id TEXT REFERENCES users(id) ON DELETE RESTRICT, + team_id TEXT REFERENCES teams(id) ON DELETE RESTRICT, + base TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + PRIMARY KEY (owner_kind, owner_id), + CHECK ( + (user_id IS NOT NULL AND team_id IS NULL) OR + (user_id IS NULL AND team_id IS NOT NULL) + ) +); + +INSERT INTO project_handle_candidates_0014 + (owner_kind, owner_id, user_id, team_id, base, claimed_at) +SELECT DISTINCT + 'user', + u.id, + u.id, + NULL, + COALESCE( + NULLIF(trim(u.display_name), ''), + NULLIF(trim(u.name), ''), + 'user' + ), + (SELECT MIN(candidate.created_at) FROM projects candidate WHERE candidate.owner_user_id = u.id) +FROM projects p +JOIN users u ON u.id = p.owner_user_id +WHERE + p.owner_user_id IS NOT NULL AND + p.owner_team_id IS NULL AND + p.archived_at IS NULL AND + u.deleted_at IS NULL AND + NOT EXISTS ( + SELECT 1 + FROM handles h + WHERE h.user_id = u.id AND h.released_at IS NULL + ); + +INSERT INTO project_handle_candidates_0014 + (owner_kind, owner_id, user_id, team_id, base, claimed_at) +SELECT DISTINCT + 'team', + t.id, + NULL, + t.id, + COALESCE(NULLIF(trim(t.name), ''), 'team'), + t.created_at +FROM teams t +WHERE + t.archived_at IS NULL AND + NOT EXISTS ( + SELECT 1 + FROM handles h + WHERE h.team_id = t.id AND h.released_at IS NULL + ); + +UPDATE project_handle_candidates_0014 +SET base = lower(trim(base)); + +UPDATE project_handle_candidates_0014 +SET base = replace(replace(replace(replace(replace(base, ' ', '-'), '.', '-'), '+', '-'), '/', '-'), '@', '-'); + +UPDATE project_handle_candidates_0014 +SET base = trim(replace(replace(replace(base, '--', '-'), '--', '-'), '--', '-'), '-_'); + +UPDATE project_handle_candidates_0014 +SET base = CASE + WHEN + length(base) = 0 OR + substr(base, 1, 1) NOT GLOB '[a-z]' OR + base GLOB '*[^a-z0-9_-]*' + THEN CASE owner_kind WHEN 'user' THEN 'user' ELSE 'team' END + ELSE base +END; + +WITH encoded AS ( + SELECT + candidate.*, + CASE + WHEN + length(candidate.owner_id) >= 8 AND + lower(substr(candidate.owner_id, 1, 8)) NOT GLOB '*[^a-z0-9_-]*' + THEN lower(substr(candidate.owner_id, 1, 8)) + ELSE lower(substr(hex(candidate.owner_id) || '00000000', 1, 8)) + END AS short_suffix, + CASE + WHEN + length(candidate.owner_id) >= 16 AND + lower(substr(candidate.owner_id, 1, 8) || substr(candidate.owner_id, -8)) NOT GLOB + '*[^a-z0-9_-]*' + THEN lower(substr(candidate.owner_id, 1, 8) || substr(candidate.owner_id, -8)) + ELSE lower( + substr(hex(candidate.owner_id) || '00000000', 1, 8) || + substr('00000000' || hex(candidate.owner_id), -8) + ) + END AS long_suffix + FROM project_handle_candidates_0014 candidate +), +named AS ( + SELECT + encoded.*, + substr(encoded.base, 1, 22) || '-' || encoded.short_suffix AS short_handle, + substr(encoded.base, 1, 14) || '-' || encoded.long_suffix AS long_handle + FROM encoded +), +resolved AS ( + SELECT + named.*, + CASE + WHEN + EXISTS (SELECT 1 FROM handles existing WHERE existing.handle = named.short_handle) OR + (SELECT COUNT(*) FROM named peer WHERE peer.short_handle = named.short_handle) > 1 + THEN named.long_handle + ELSE named.short_handle + END AS handle + FROM named +) +INSERT INTO handles (handle, user_id, team_id, claimed_at, released_at) +SELECT handle, user_id, team_id, claimed_at, NULL +FROM resolved +ORDER BY owner_kind, owner_id; + +DROP TABLE project_handle_candidates_0014; + +CREATE TABLE project_handle_guard_0014 ( + ok INTEGER NOT NULL CHECK (ok = 1) +); +INSERT INTO project_handle_guard_0014 (ok) +SELECT CASE + WHEN EXISTS ( + SELECT 1 + FROM projects p + WHERE + p.archived_at IS NULL AND + ( + ( + p.owner_user_id IS NOT NULL AND + NOT EXISTS ( + SELECT 1 + FROM handles h + WHERE h.user_id = p.owner_user_id AND h.released_at IS NULL + ) + ) OR ( + p.owner_team_id IS NOT NULL AND + NOT EXISTS ( + SELECT 1 + FROM handles h + WHERE h.team_id = p.owner_team_id AND h.released_at IS NULL + ) + ) + ) + ) + THEN 0 + ELSE 1 +END; +DROP TABLE project_handle_guard_0014; + +CREATE INDEX hub_sessions_owner_project_updated + ON hub_sessions(owner_user_id, project_id, updated_at DESC, sid); +CREATE INDEX hub_sessions_team_project_updated + ON hub_sessions(team_id, project_id, updated_at DESC, sid) + WHERE team_id IS NOT NULL; + +-- SQLite cannot strengthen an added column to NOT NULL without rebuilding the +-- parent of several live foreign keys. The migration backfills every row and +-- these triggers provide the same invariant for all future writes while +-- retaining the original hub_sessions primary-key identity. +-- +-- Production applies D1 migrations before it replaces the Pages deployment. +-- During that rolling window the old Hub writer still omits project_id. Repair +-- that exact legacy shape inside the same statement instead of breaking every +-- share until the new Worker is live (or indefinitely if its deploy fails). +CREATE TRIGGER hub_sessions_project_legacy_insert +AFTER INSERT ON hub_sessions +WHEN NEW.project_id IS NULL +BEGIN + -- Users created by the old deployment after this migration were not present + -- in the handle backfill. Give a first-time Personal sharer a deterministic + -- route before creating their fallback Project. Current user ids are 16 + -- lowercase hex characters, so both candidates satisfy the handle grammar. + INSERT OR IGNORE INTO handles (handle, user_id, team_id, claimed_at, released_at) + SELECT + 'user-' || lower(NEW.owner_user_id), + NEW.owner_user_id, + NULL, + NEW.created_at, + NULL + FROM users owner + WHERE + NEW.team_id IS NULL AND + owner.id = NEW.owner_user_id AND + owner.deleted_at IS NULL AND + NOT EXISTS ( + SELECT 1 FROM handles handle + WHERE handle.user_id = NEW.owner_user_id AND handle.released_at IS NULL + ); + + INSERT OR IGNORE INTO handles (handle, user_id, team_id, claimed_at, released_at) + SELECT + 'u-' || lower(NEW.owner_user_id), + NEW.owner_user_id, + NULL, + NEW.created_at, + NULL + FROM users owner + WHERE + NEW.team_id IS NULL AND + owner.id = NEW.owner_user_id AND + owner.deleted_at IS NULL AND + NOT EXISTS ( + SELECT 1 FROM handles handle + WHERE handle.user_id = NEW.owner_user_id AND handle.released_at IS NULL + ); + + SELECT CASE + WHEN + NEW.team_id IS NULL AND + NOT EXISTS ( + SELECT 1 FROM handles handle + WHERE handle.user_id = NEW.owner_user_id AND handle.released_at IS NULL + ) + THEN RAISE(ABORT, 'hub session project owner handle is required') + END; + + INSERT OR IGNORE INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at + ) + SELECT + 'project_default_user_' || NEW.owner_user_id, + NEW.owner_user_id, + NULL, + 'sessions', + 'Sessions', + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + NULL, + NEW.owner_user_id, + NEW.created_at, + NEW.updated_at, + NULL + FROM users owner + WHERE + NEW.team_id IS NULL AND + owner.id = NEW.owner_user_id AND + owner.deleted_at IS NULL; + + INSERT OR IGNORE INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at + ) + SELECT + 'project_default_team_' || NEW.team_id, + NULL, + NEW.team_id, + 'sessions', + 'Sessions', + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + NULL, + team.created_by_user_id, + NEW.created_at, + NEW.updated_at, + NULL + FROM teams team + WHERE + NEW.team_id IS NOT NULL AND + team.id = NEW.team_id AND + team.archived_at IS NULL; + + UPDATE hub_sessions + SET project_id = CASE + WHEN NEW.team_id IS NULL THEN ( + SELECT project.id + FROM projects project + WHERE + project.owner_user_id = NEW.owner_user_id AND + project.owner_team_id IS NULL AND + project.archived_at IS NULL AND + ( + project.id = 'project_default_user_' || NEW.owner_user_id OR + project.slug = 'sessions' + ) + ORDER BY + CASE + WHEN project.id = 'project_default_user_' || NEW.owner_user_id THEN 0 + ELSE 1 + END, + project.created_at, + project.id + LIMIT 1 + ) + ELSE ( + SELECT project.id + FROM projects project + WHERE + project.owner_user_id IS NULL AND + project.owner_team_id = NEW.team_id AND + project.archived_at IS NULL AND + ( + project.id = 'project_default_team_' || NEW.team_id OR + project.slug = 'sessions' + ) + ORDER BY + CASE + WHEN project.id = 'project_default_team_' || NEW.team_id THEN 0 + ELSE 1 + END, + project.created_at, + project.id + LIMIT 1 + ) + END + WHERE sid = NEW.sid AND project_id IS NULL; +END; + +CREATE TRIGGER hub_sessions_project_required_update +BEFORE UPDATE OF project_id ON hub_sessions +WHEN NEW.project_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'hub session project is required'); +END; + +CREATE TRIGGER hub_sessions_project_tenant_insert +BEFORE INSERT ON hub_sessions +WHEN NEW.project_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM projects p + WHERE + p.id = NEW.project_id AND + p.archived_at IS NULL AND + ( + ( + p.owner_user_id = NEW.owner_user_id AND + p.owner_team_id IS NULL AND + NEW.team_id IS NULL + ) OR ( + p.owner_user_id IS NULL AND + p.owner_team_id = NEW.team_id AND + NEW.team_id IS NOT NULL + ) + ) +) +BEGIN + SELECT RAISE(ABORT, 'hub session project tenant mismatch'); +END; + +CREATE TRIGGER hub_sessions_project_tenant_update +BEFORE UPDATE OF project_id, owner_user_id, team_id ON hub_sessions +WHEN + NEW.project_id IS NOT NULL AND + NOT ( + OLD.team_id IS NULL AND + NEW.team_id IS NOT NULL AND + NEW.owner_user_id IS OLD.owner_user_id AND + NEW.project_id IS OLD.project_id + ) AND + NOT EXISTS ( + SELECT 1 + FROM projects p + WHERE + p.id = NEW.project_id AND + p.archived_at IS NULL AND + ( + ( + p.owner_user_id = NEW.owner_user_id AND + p.owner_team_id IS NULL AND + NEW.team_id IS NULL + ) OR ( + p.owner_user_id IS NULL AND + p.owner_team_id = NEW.team_id AND + NEW.team_id IS NOT NULL + ) + ) +) +BEGIN + SELECT RAISE(ABORT, 'hub session project tenant mismatch'); +END; + +-- The pre-0014 writer transfers a Personal Session by changing team_id while +-- leaving every unknown column untouched. Permit only that precise transition, +-- then switch the row to the target Team's fallback Project atomically. +CREATE TRIGGER hub_sessions_project_legacy_team_transfer +AFTER UPDATE OF team_id ON hub_sessions +WHEN + OLD.team_id IS NULL AND + NEW.team_id IS NOT NULL AND + NEW.owner_user_id IS OLD.owner_user_id AND + NEW.project_id IS OLD.project_id +BEGIN + INSERT OR IGNORE INTO projects ( + id, + owner_user_id, + owner_team_id, + slug, + name, + description, + github_url, + created_by_user_id, + created_at, + updated_at, + archived_at + ) + SELECT + 'project_default_team_' || NEW.team_id, + NULL, + NEW.team_id, + 'sessions', + 'Sessions', + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + NULL, + team.created_by_user_id, + NEW.created_at, + NEW.updated_at, + NULL + FROM teams team + WHERE + team.id = NEW.team_id AND + team.archived_at IS NULL; + + UPDATE hub_sessions + SET project_id = ( + SELECT project.id + FROM projects project + WHERE + project.owner_user_id IS NULL AND + project.owner_team_id = NEW.team_id AND + project.archived_at IS NULL AND + ( + project.id = 'project_default_team_' || NEW.team_id OR + project.slug = 'sessions' + ) + ORDER BY + CASE + WHEN project.id = 'project_default_team_' || NEW.team_id THEN 0 + ELSE 1 + END, + project.created_at, + project.id + LIMIT 1 + ) + WHERE sid = NEW.sid AND project_id IS NEW.project_id; +END; + +-- Turn an incomplete backfill into a migration failure instead of shipping a +-- nullable or cross-tenant row. This temporary guard is dropped immediately. +CREATE TABLE project_backfill_guard_0014 ( + ok INTEGER NOT NULL CHECK (ok = 1) +); +INSERT INTO project_backfill_guard_0014 (ok) +SELECT CASE + WHEN EXISTS ( + SELECT 1 + FROM hub_sessions s + LEFT JOIN projects p ON p.id = s.project_id + WHERE + s.project_id IS NULL OR + p.id IS NULL OR + ( + s.team_id IS NULL AND + NOT ( + p.owner_user_id = s.owner_user_id AND + p.owner_team_id IS NULL + ) + ) OR + ( + s.team_id IS NOT NULL AND + NOT ( + p.owner_user_id IS NULL AND + p.owner_team_id = s.team_id + ) + ) + ) + THEN 0 + ELSE 1 +END; +DROP TABLE project_backfill_guard_0014; + +-- Every active Team has received a handle above. Capture it on old creation +-- receipts, then freeze handle intent once chosen. Old Pages deployments omit +-- requested_handle, so NULL must remain a legal rolling-deploy input. +UPDATE team_creation_requests +SET requested_handle = ( + SELECT h.handle + FROM handles h + WHERE h.team_id = team_creation_requests.team_id + AND h.released_at IS NULL + LIMIT 1 +) +WHERE + requested_handle IS NULL AND + EXISTS ( + SELECT 1 + FROM handles h + WHERE h.team_id = team_creation_requests.team_id + AND h.released_at IS NULL + ); + +CREATE TRIGGER team_creation_requests_handle_immutable +BEFORE UPDATE OF requested_handle ON team_creation_requests +WHEN + OLD.requested_handle IS NOT NULL AND + NEW.requested_handle IS NOT OLD.requested_handle +BEGIN + SELECT RAISE(ABORT, 'team creation handle is immutable'); +END; + +-- Complete an in-flight Team created by a pre-0014 Pages deployment with a +-- stable emergency handle. Normal 0014+ creation already carries an explicit +-- requested_handle and bypasses this compatibility trigger. The second +-- candidate is only a collision fallback; both are derived from the random +-- Team id and remain within the public handle grammar. +CREATE TRIGGER teams_legacy_creation_handle +AFTER INSERT ON teams +WHEN EXISTS ( + SELECT 1 + FROM team_creation_requests request + WHERE request.team_id = NEW.id AND request.requested_handle IS NULL +) +BEGIN + INSERT OR IGNORE INTO handles (handle, user_id, team_id, claimed_at, released_at) + VALUES ( + 'team-' || lower(substr(NEW.id, -27)), + NULL, + NEW.id, + NEW.created_at, + NULL + ); + + INSERT OR IGNORE INTO handles (handle, user_id, team_id, claimed_at, released_at) + SELECT + 't-' || lower(substr(NEW.id, -30)), + NULL, + NEW.id, + NEW.created_at, + NULL + WHERE NOT EXISTS ( + SELECT 1 FROM handles handle + WHERE handle.team_id = NEW.id AND handle.released_at IS NULL + ); + + SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 FROM handles handle + WHERE handle.team_id = NEW.id AND handle.released_at IS NULL + ) + THEN RAISE(ABORT, 'legacy Team handle allocation failed') + END; + + UPDATE team_creation_requests + SET + requested_handle = ( + SELECT handle.handle + FROM handles handle + WHERE handle.team_id = NEW.id AND handle.released_at IS NULL + LIMIT 1 + ), + updated_at = MAX(updated_at, NEW.created_at) + WHERE + team_id = NEW.id AND + requested_handle IS NULL AND + EXISTS ( + SELECT 1 FROM handles handle + WHERE handle.team_id = NEW.id AND handle.released_at IS NULL + ); +END; diff --git a/apps/backend/scripts/schema-smoke.mjs b/apps/backend/scripts/schema-smoke.mjs index 547f5936..46dbdf2d 100644 --- a/apps/backend/scripts/schema-smoke.mjs +++ b/apps/backend/scripts/schema-smoke.mjs @@ -1,10 +1,11 @@ import { spawn } from 'node:child_process' -import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { incrementQualifiedReadIfLive, listDiscoveryPage } from '../src/discovery/store.ts' +import { HUB_PROJECTS_LIST_SQL } from '../src/projects/query-sql.ts' const appDir = dirname(dirname(fileURLToPath(import.meta.url))) const migrationsDir = join(appDir, 'migrations') @@ -17,13 +18,16 @@ if (migrationNames.length === 0) { throw new Error(`No SQL migrations found in ${migrationsDir}`) } -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const wranglerEntry = join(appDir, 'node_modules', 'wrangler', 'bin', 'wrangler.js') function runWrangler(args) { return new Promise((resolve, reject) => { - const child = spawn(pnpm, ['exec', 'wrangler', ...args], { + const noProxy = [process.env.NO_PROXY, process.env.no_proxy, '127.0.0.1', 'localhost'] + .filter(Boolean) + .join(',') + const child = spawn(process.execPath, [wranglerEntry, ...args], { cwd: appDir, - env: { ...process.env, NO_COLOR: '1' }, + env: { ...process.env, NO_COLOR: '1', NO_PROXY: noProxy, no_proxy: noProxy }, stdio: ['ignore', 'pipe', 'pipe'], }) @@ -73,6 +77,34 @@ async function executeJson(stateDir, command) { return payload.flatMap((execution) => execution.results ?? []) } +async function executeFile(stateDir, file) { + await runWrangler([ + 'd1', + 'execute', + 'spool-share-db', + '--local', + '--persist-to', + stateDir, + '--config', + 'wrangler.toml', + '--file', + file, + ]) +} + +async function expectD1FileFailure(stateDir, file, expectedMessage) { + try { + await executeFile(stateDir, file) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!message.includes(expectedMessage)) { + throw new Error(`Expected D1 failure containing ${expectedMessage}, received:\n${message}`) + } + return + } + throw new Error(`Expected D1 file to fail: ${file}`) +} + async function expectD1Failure(stateDir, command, expectedMessage) { try { await executeJson(stateDir, command) @@ -160,6 +192,601 @@ function sqlLiteral(value) { throw new Error(`Unsupported discovery SQL binding: ${typeof value}`) } +function bindSql(sql, params) { + let parameterIndex = 0 + const bound = sql.replaceAll('?', () => { + if (parameterIndex >= params.length) { + throw new Error('SQL has more placeholders than bound parameters') + } + return sqlLiteral(params[parameterIndex++]) + }) + if (parameterIndex !== params.length) { + throw new Error('SQL has fewer placeholders than bound parameters') + } + return bound +} + +async function verifyProjectBackfill() { + const projectMigrationName = '0014_projects.sql' + const projectMigrationIndex = migrationNames.indexOf(projectMigrationName) + if (projectMigrationIndex === -1) { + throw new Error(`Missing ${projectMigrationName}`) + } + if (projectMigrationIndex !== migrationNames.length - 1) { + throw new Error(`${projectMigrationName} must remain the newest migration for its legacy smoke`) + } + + const legacyStateDir = await mkdtemp(join(tmpdir(), 'spool-d1-project-backfill-smoke-')) + const legacySchemaFile = join(legacyStateDir, 'schema-through-0013.sql') + + try { + const legacyMigrationBodies = await Promise.all( + migrationNames + .slice(0, projectMigrationIndex) + .map((name) => readFile(join(migrationsDir, name), 'utf8')), + ) + await writeFile(legacySchemaFile, `${legacyMigrationBodies.join('\n\n')}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + await executeFile(legacyStateDir, legacySchemaFile) + + await executeJson( + legacyStateDir, + `INSERT INTO users (id,email,name,created_at,last_signin_at) + VALUES + ('legacy-react','react@example.test','React Author',1,1), + ('legacy-spool','spool@example.test','Spool Author',1,1), + ('legacy-avatar','avatar@example.test','Avatar Author',1,1), + ('legacy-generic','generic@example.test','Generic Builder',1,1), + ('legacy-nameless','private-local-part@example.test',NULL,1,1), + ('legacy-old','old@example.test','Legacy Holder',1,1); + INSERT INTO handles (handle,user_id,claimed_at,released_at) + VALUES + ('alpha-old-handle','legacy-old',1,NULL), + ('zeta-old-handle','legacy-old',2,NULL), + ('xinyao','legacy-react',3,NULL), + ('spool-before-controlled','legacy-spool',3,NULL), + ('retired-spool-handle','legacy-spool',1,2); + INSERT INTO teams + (id,workos_organization_id,name,created_by_user_id,created_at,updated_at) + VALUES + ('team_legacy_paperboy','org_legacy_paperboy','Paperboy','legacy-avatar',1,2), + ('team_legacy_generic','org_legacy_generic','Research Team','legacy-avatar',1,2), + ('team_legacy_empty','org_legacy_empty','Empty Team','legacy-avatar',1,2); + INSERT INTO team_creation_requests + (user_id,idempotency_key,team_id,normalized_name,status,workos_organization_id,created_at,updated_at) + VALUES ( + 'legacy-avatar', + 'legacy-empty-team-create', + 'team_legacy_empty', + 'Empty Team', + 'completed', + 'org_legacy_empty', + 1, + 2 + ); + INSERT INTO hub_sessions + (sid,owner_user_id,root,record_count,visibility,team_id,created_at,updated_at) + VALUES + ( + 'claude_52d60289-1a34-41ff-bf63-a77593a53d8a', + 'legacy-react', + '${'1'.repeat(64)}', + 1, + 'unlisted', + NULL, + 10, + 11 + ), + ( + 'claude_9cea282a-d9cf-434d-83f4-633cca085faf', + 'legacy-react', + '${'2'.repeat(64)}', + 1, + 'unlisted', + NULL, + 12, + 13 + ), + ( + 'pi_019f75ad-6e1b-7825-8af2-a3f76d33b91d', + 'legacy-react', + '${'3'.repeat(64)}', + 1, + 'unlisted', + NULL, + 14, + 15 + ), + ( + 'codex_019f88f4-7675-7fb1-b7b1-c9fb283fe866', + 'legacy-spool', + '${'4'.repeat(64)}', + 1, + 'unlisted', + NULL, + 16, + 17 + ), + ( + 'codex_019f8919-cd74-7ef1-8a2c-b26bef86e595', + 'legacy-spool', + '${'5'.repeat(64)}', + 1, + 'unlisted', + NULL, + 18, + 19 + ), + ( + 'codex_019f89dc-54e9-7eb1-97cc-753269f594cb', + 'legacy-spool', + '${'6'.repeat(64)}', + 1, + 'unlisted', + NULL, + 20, + 21 + ), + ( + 'codex_019f8a35-c2dd-7b72-a754-839cf3efae86', + 'legacy-spool', + '${'7'.repeat(64)}', + 1, + 'unlisted', + NULL, + 22, + 23 + ), + ( + 'codex_019f8e14-8152-7412-98c7-ab55a1e32de3', + 'legacy-spool', + '${'8'.repeat(64)}', + 1, + 'unlisted', + NULL, + 24, + 25 + ), + ( + 'codex_019f845e-2b39-7862-8fb6-287f0af11d12', + 'legacy-avatar', + '${'9'.repeat(64)}', + 1, + 'private', + 'team_legacy_paperboy', + 26, + 27 + ), + ( + 'claude_team_summary', + 'legacy-avatar', + '${'a'.repeat(64)}', + 1, + 'private', + 'team_legacy_paperboy', + 28, + 29 + ), + ( + 'codex_avatar_personal_legacy', + 'legacy-avatar', + '${'c'.repeat(64)}', + 1, + 'unlisted', + NULL, + 29, + 30 + ), + ( + 'claude_team_other', + 'legacy-avatar', + '${'b'.repeat(64)}', + 1, + 'private', + 'team_legacy_paperboy', + 30, + 31 + ), + ( + 'codex_generic_owner', + 'legacy-generic', + '${'d'.repeat(64)}', + 1, + 'unlisted', + NULL, + 32, + 33 + ), + ( + 'codex_old_owner', + 'legacy-old', + '${'e'.repeat(64)}', + 1, + 'unlisted', + NULL, + 34, + 35 + ), + ( + 'codex_nameless_owner', + 'legacy-nameless', + '${'0'.repeat(64)}', + 1, + 'unlisted', + NULL, + 35, + 36 + ), + ( + 'codex_generic_team', + 'legacy-avatar', + '${'f'.repeat(64)}', + 1, + 'private', + 'team_legacy_generic', + 36, + 37 + ); + INSERT INTO hub_session_discovery + (sid,agent,title,search_text,published_at,updated_at) + VALUES + ('claude_team_summary','claude','Improve bilingual Summary','summary',28,29), + ('claude_team_other','claude','Repair deployment routing','deployment',30,31);`, + ) + + await executeFile(legacyStateDir, join(migrationsDir, projectMigrationName)) + + const nullProjects = await executeJson( + legacyStateDir, + 'SELECT COUNT(*) AS count FROM hub_sessions WHERE project_id IS NULL;', + ) + if (nullProjects[0]?.count !== 0) { + throw new Error(`Project backfill left nullable Sessions: ${JSON.stringify(nullProjects)}`) + } + + const assignmentRows = await executeJson( + legacyStateDir, + 'SELECT sid,project_id FROM hub_sessions ORDER BY sid;', + ) + const assignments = Object.fromEntries(assignmentRows.map((row) => [row.sid, row.project_id])) + const expectedAssignments = { + 'claude_52d60289-1a34-41ff-bf63-a77593a53d8a': 'project_user_legacy-react_react-vapor', + 'claude_9cea282a-d9cf-434d-83f4-633cca085faf': 'project_user_legacy-react_react-vapor', + 'pi_019f75ad-6e1b-7825-8af2-a3f76d33b91d': 'project_default_user_legacy-react', + 'codex_019f88f4-7675-7fb1-b7b1-c9fb283fe866': 'project_user_legacy-spool_spool', + 'codex_019f8919-cd74-7ef1-8a2c-b26bef86e595': 'project_user_legacy-spool_spool', + 'codex_019f89dc-54e9-7eb1-97cc-753269f594cb': 'project_user_legacy-spool_spool', + 'codex_019f8a35-c2dd-7b72-a754-839cf3efae86': 'project_user_legacy-spool_spool', + 'codex_019f8e14-8152-7412-98c7-ab55a1e32de3': 'project_user_legacy-spool_spool', + 'codex_019f845e-2b39-7862-8fb6-287f0af11d12': + 'project_team_team_legacy_paperboy_avatar-generator', + codex_avatar_personal_legacy: 'project_user_legacy-avatar_spool', + codex_generic_owner: 'project_default_user_legacy-generic', + codex_nameless_owner: 'project_default_user_legacy-nameless', + codex_old_owner: 'project_default_user_legacy-old', + codex_generic_team: 'project_default_team_team_legacy_generic', + claude_team_summary: 'project_team_team_legacy_paperboy_avatar-generator', + claude_team_other: 'project_team_team_legacy_paperboy_paperboy', + } + const assignmentMismatches = Object.entries(expectedAssignments).filter( + ([sid, projectId]) => assignments[sid] !== projectId, + ) + if ( + assignmentRows.length !== Object.keys(expectedAssignments).length || + assignmentMismatches.length !== 0 + ) { + throw new Error( + `Project backfill assignments are invalid.\nMismatches: ${JSON.stringify(assignmentMismatches)}\nActual: ${JSON.stringify(assignments)}`, + ) + } + + const projectDescriptionRows = await executeJson( + legacyStateDir, + `SELECT slug,COUNT(*) AS project_count,COUNT(DISTINCT description) AS description_count, + MIN(description) AS description + FROM projects + GROUP BY slug + ORDER BY slug;`, + ) + const expectedProjectDescriptions = { + 'avatar-generator': { + count: 1, + description: + "Paperboy's avatar generator turns eight fixed retro-futurist illustrations into reproducible default avatars by varying color palettes without redrawing their structure.", + }, + paperboy: { + count: 1, + description: + 'Paperboy is the Team workspace for product and infrastructure work that does not belong to a more specific Project.', + }, + 'react-vapor': { + count: 1, + description: + 'React Vapor explores a compiler-driven React execution model that reduces runtime reconciliation while preserving compatibility with existing React code and third-party packages.', + }, + sessions: { + count: 8, + description: + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + }, + spool: { + count: 2, + description: + 'Spool turns local coding-agent Sessions into durable, shareable records that people can read, search, and resume across tools.', + }, + } + const descriptionMismatches = projectDescriptionRows.filter((row) => { + const expected = expectedProjectDescriptions[row.slug] + return ( + expected === undefined || + row.project_count !== expected.count || + row.description_count !== 1 || + row.description !== expected.description + ) + }) + if ( + projectDescriptionRows.length !== Object.keys(expectedProjectDescriptions).length || + descriptionMismatches.length !== 0 + ) { + throw new Error( + `Project backfill descriptions are invalid: ${JSON.stringify(projectDescriptionRows)}`, + ) + } + + const seededHandles = await executeJson( + legacyStateDir, + `SELECT handle,user_id,team_id,claimed_at,released_at + FROM handles + ORDER BY handle;`, + ) + if ( + !seededHandles.some( + (row) => + row.handle === 'evan' && + row.user_id === 'legacy-spool' && + row.team_id === null && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'xinyao' && + row.user_id === 'legacy-react' && + row.team_id === null && + row.claimed_at === 3 && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'vivian-kong' && + row.user_id === 'legacy-avatar' && + row.team_id === null && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'paperboy' && + row.user_id === null && + row.team_id === 'team_legacy_paperboy' && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'generic-builder-legacy-g' && + row.user_id === 'legacy-generic' && + row.team_id === null && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'research-team-team_leg' && + row.user_id === null && + row.team_id === 'team_legacy_generic' && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'empty-team-team_leg' && + row.user_id === null && + row.team_id === 'team_legacy_empty' && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'user-legacy-n' && + row.user_id === 'legacy-nameless' && + row.team_id === null && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'alpha-old-handle' && + row.user_id === 'legacy-old' && + row.team_id === null && + row.released_at === null, + ) || + !seededHandles.some( + (row) => + row.handle === 'zeta-old-handle' && + row.user_id === 'legacy-old' && + row.team_id === null && + row.released_at === 2, + ) || + !seededHandles.some( + (row) => + row.handle === 'spool-before-controlled' && + row.user_id === 'legacy-spool' && + row.claimed_at === 3 && + row.released_at === 16, + ) || + !seededHandles.some( + (row) => + row.handle === 'retired-spool-handle' && + row.user_id === 'legacy-spool' && + row.released_at === 2, + ) + ) { + throw new Error(`Handle migration/seeds are invalid: ${JSON.stringify(seededHandles)}`) + } + const handleCollisions = await executeJson( + legacyStateDir, + `SELECT 'handle' AS kind,lower(handle) AS identity,COUNT(*) AS count + FROM handles + GROUP BY lower(handle) + HAVING COUNT(*) > 1 + UNION ALL + SELECT 'active-user' AS kind,user_id AS identity,COUNT(*) AS count + FROM handles + WHERE user_id IS NOT NULL AND released_at IS NULL + GROUP BY user_id + HAVING COUNT(*) > 1 + UNION ALL + SELECT 'active-team' AS kind,team_id AS identity,COUNT(*) AS count + FROM handles + WHERE team_id IS NOT NULL AND released_at IS NULL + GROUP BY team_id + HAVING COUNT(*) > 1;`, + ) + if (handleCollisions.length !== 0) { + throw new Error(`Handle migration left collisions: ${JSON.stringify(handleCollisions)}`) + } + const legacyTeamReceipt = await executeJson( + legacyStateDir, + `SELECT requested_handle + FROM team_creation_requests + WHERE user_id='legacy-avatar' AND idempotency_key='legacy-empty-team-create';`, + ) + if (legacyTeamReceipt[0]?.requested_handle !== 'empty-team-team_leg') { + throw new Error( + `Legacy Team receipt was not backfilled with its handle: ${JSON.stringify(legacyTeamReceipt)}`, + ) + } + const missingProjectOwnerHandles = await executeJson( + legacyStateDir, + `SELECT COUNT(*) AS count + FROM ( + SELECT DISTINCT 'user' AS owner_kind,p.owner_user_id AS owner_id + FROM projects p + WHERE + p.archived_at IS NULL AND + p.owner_user_id IS NOT NULL AND + NOT EXISTS ( + SELECT 1 + FROM handles h + WHERE h.user_id = p.owner_user_id AND h.released_at IS NULL + ) + UNION ALL + SELECT DISTINCT 'team' AS owner_kind,p.owner_team_id AS owner_id + FROM projects p + WHERE + p.archived_at IS NULL AND + p.owner_team_id IS NOT NULL AND + NOT EXISTS ( + SELECT 1 + FROM handles h + WHERE h.team_id = p.owner_team_id AND h.released_at IS NULL + ) + );`, + ) + if (missingProjectOwnerHandles[0]?.count !== 0) { + throw new Error( + `Active Project owners are missing handles: ${JSON.stringify(missingProjectOwnerHandles)}`, + ) + } + + const legacyForeignKeyViolations = await executeJson( + legacyStateDir, + 'PRAGMA foreign_key_check;', + ) + if (legacyForeignKeyViolations.length !== 0) { + throw new Error( + `Project backfill foreign-key violations: ${JSON.stringify(legacyForeignKeyViolations)}`, + ) + } + } finally { + await rm(legacyStateDir, { force: true, recursive: true }) + } +} + +async function verifyControlledHandleConflict() { + const projectMigrationName = '0014_projects.sql' + const projectMigrationIndex = migrationNames.indexOf(projectMigrationName) + if (projectMigrationIndex === -1) { + throw new Error(`Missing ${projectMigrationName}`) + } + + const conflictStateDir = await mkdtemp( + join(tmpdir(), 'spool-d1-controlled-handle-conflict-smoke-'), + ) + const legacySchemaFile = join(conflictStateDir, 'schema-through-0013.sql') + + try { + const legacyMigrationBodies = await Promise.all( + migrationNames + .slice(0, projectMigrationIndex) + .map((name) => readFile(join(migrationsDir, name), 'utf8')), + ) + await writeFile(legacySchemaFile, `${legacyMigrationBodies.join('\n\n')}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + await executeFile(conflictStateDir, legacySchemaFile) + + await executeJson( + conflictStateDir, + `INSERT INTO users (id,email,name,created_at,last_signin_at) + VALUES + ('intended-owner','intended@example.test','Intended Owner',1,1), + ('route-squatter','squatter@example.test','Route Squatter',1,1); + INSERT INTO handles (handle,user_id,claimed_at,released_at) + VALUES + ('react-before-controlled','intended-owner',2,NULL), + ('xinyao','route-squatter',2,NULL); + INSERT INTO hub_sessions + (sid,owner_user_id,root,record_count,visibility,created_at,updated_at) + VALUES ( + 'claude_52d60289-1a34-41ff-bf63-a77593a53d8a', + 'intended-owner', + '${'1'.repeat(64)}', + 1, + 'unlisted', + 10, + 11 + );`, + ) + + await expectD1FileFailure( + conflictStateDir, + join(migrationsDir, projectMigrationName), + 'controlled_handle_seed_conflict', + ) + + const preservedClaims = await executeJson( + conflictStateDir, + `SELECT handle,user_id,released_at + FROM handles + WHERE handle IN ('react-before-controlled','xinyao') + ORDER BY handle;`, + ) + if ( + preservedClaims.length !== 2 || + preservedClaims[0]?.handle !== 'react-before-controlled' || + preservedClaims[0]?.user_id !== 'intended-owner' || + preservedClaims[0]?.released_at !== null || + preservedClaims[1]?.handle !== 'xinyao' || + preservedClaims[1]?.user_id !== 'route-squatter' || + preservedClaims[1]?.released_at !== null + ) { + throw new Error( + `Controlled handle conflict mutated legacy claims: ${JSON.stringify(preservedClaims)}`, + ) + } + } finally { + await rm(conflictStateDir, { force: true, recursive: true }) + } +} + const stateDir = await mkdtemp(join(tmpdir(), 'spool-d1-schema-smoke-')) try { @@ -217,8 +844,8 @@ try { (id,operation,resource_id,team_id,user_id,next_attempt_at,created_at,updated_at) VALUES ('woc_durable','membership.delete','om_durable','team_durable','durable-user',1,1,1); INSERT INTO team_creation_requests - (user_id,idempotency_key,team_id,normalized_name,status,workos_organization_id,created_at,updated_at) - VALUES ('durable-user','create_durable_0001','team_durable','Durable','completed','org_durable',1,1); + (user_id,idempotency_key,team_id,normalized_name,requested_handle,status,workos_organization_id,created_at,updated_at) + VALUES ('durable-user','create_durable_0001','team_durable','Durable','active-team-handle','completed','org_durable',1,1); INSERT INTO team_invitation_requests (team_id,invited_by_user_id,idempotency_key,invitation_id,normalized_email,desired_role,status,workos_invitation_id,created_at,updated_at) VALUES ('team_durable','durable-user','invite_durable_0001','tinv_durable','invitee@example.test','member','completed','inv_durable',1,1); @@ -226,7 +853,451 @@ try { (organization_id,membership_id,workos_user_id,reason,workos_updated_at,team_id,user_id,previous_role,denied_at,event_id) VALUES ('org_denied','om_denied','workos_denied','inactive',1,NULL,NULL,NULL,1,'event_denied'); INSERT INTO workos_user_denials (workos_user_id,denied_at,event_id) - VALUES ('workos_deleted',1,'event_user_deleted');`, + VALUES ('workos_deleted',1,'event_user_deleted'); + INSERT INTO projects + (id,owner_user_id,owner_team_id,slug,name,created_by_user_id,created_at,updated_at) + VALUES + ('project_default_user_durable-user','durable-user',NULL,'sessions','Sessions','durable-user',1,1), + ('project_user_durable-user_secondary','durable-user',NULL,'secondary','Secondary','durable-user',1,1), + ('project_default_team_team_durable',NULL,'team_durable','sessions','Sessions','durable-user',1,1); + INSERT INTO project_creation_requests + (actor_user_id,owner_scope,owner_user_id,owner_team_id,idempotency_key,project_id, + request_hash,created_at) + VALUES ( + 'durable-user', + 'user:durable-user', + 'durable-user', + NULL, + 'project-create-user-0001', + 'project_default_user_durable-user', + '${'e'.repeat(64)}', + 1 + );`, + ) + + const handleColumns = await executeJson(stateDir, 'PRAGMA table_info(handles);') + if ( + JSON.stringify(handleColumns.map((column) => column.name)) !== + JSON.stringify(['handle', 'user_id', 'team_id', 'claimed_at', 'released_at']) + ) { + throw new Error(`Missing global handle schema: ${JSON.stringify(handleColumns)}`) + } + const teamCreationColumns = await executeJson( + stateDir, + 'PRAGMA table_info(team_creation_requests);', + ) + if (!teamCreationColumns.some((column) => column.name === 'requested_handle')) { + throw new Error(`Missing Team creation handle intent: ${JSON.stringify(teamCreationColumns)}`) + } + const projectColumns = await executeJson(stateDir, 'PRAGMA table_info(projects);') + if ( + JSON.stringify(projectColumns.map((column) => column.name)) !== + JSON.stringify([ + 'id', + 'owner_user_id', + 'owner_team_id', + 'slug', + 'name', + 'description', + 'github_url', + 'created_by_user_id', + 'created_at', + 'updated_at', + 'archived_at', + ]) + ) { + throw new Error(`Missing Project schema: ${JSON.stringify(projectColumns)}`) + } + const sessionColumns = await executeJson(stateDir, 'PRAGMA table_info(hub_sessions);') + if (!sessionColumns.some((column) => column.name === 'project_id')) { + throw new Error('hub_sessions.project_id was not migrated') + } + const sessionProjectForeignKey = ( + await executeJson(stateDir, 'PRAGMA foreign_key_list(hub_sessions);') + ).find((foreignKey) => foreignKey.from === 'project_id') + if ( + sessionProjectForeignKey?.table !== 'projects' || + sessionProjectForeignKey?.to !== 'id' || + sessionProjectForeignKey?.on_delete !== 'RESTRICT' + ) { + throw new Error( + `hub_sessions.project_id foreign key is invalid: ${JSON.stringify(sessionProjectForeignKey)}`, + ) + } + const projectRequestColumns = await executeJson( + stateDir, + 'PRAGMA table_info(project_creation_requests);', + ) + if ( + JSON.stringify(projectRequestColumns.map((column) => column.name)) !== + JSON.stringify([ + 'actor_user_id', + 'owner_scope', + 'owner_user_id', + 'owner_team_id', + 'idempotency_key', + 'project_id', + 'request_hash', + 'created_at', + ]) + ) { + throw new Error( + `Missing Project creation idempotency schema: ${JSON.stringify(projectRequestColumns)}`, + ) + } + const projectTriggers = await executeJson( + stateDir, + `SELECT name + FROM sqlite_master + WHERE type='trigger' AND name IN ( + 'handles_identity_immutable', + 'handles_release_monotonic', + 'handles_no_delete', + 'team_creation_requests_handle_immutable', + 'teams_legacy_creation_handle', + 'projects_tenant_immutable', + 'users_retire_projects', + 'teams_retire_projects', + 'hub_sessions_project_legacy_insert', + 'hub_sessions_project_required_update', + 'hub_sessions_project_tenant_insert', + 'hub_sessions_project_tenant_update', + 'hub_sessions_project_legacy_team_transfer' + ) + ORDER BY name;`, + ) + if (projectTriggers.length !== 13) { + throw new Error(`Missing handle/Project triggers: ${JSON.stringify(projectTriggers)}`) + } + await executeJson( + stateDir, + `INSERT INTO users (id,email,created_at,last_signin_at) + VALUES ('legacy-team-owner','legacy-team-owner@example.test',1,1); + INSERT INTO team_creation_requests + (user_id,idempotency_key,team_id,normalized_name,status,workos_organization_id,created_at,updated_at) + VALUES ( + 'legacy-team-owner', + 'create_without_handle', + 'team_0123456789abcdef0123456789abcdef', + 'Rolling Team', + 'pending', + 'org_rolling_team', + 2, + 2 + ); + INSERT INTO teams + (id,workos_organization_id,name,created_by_user_id,created_at,updated_at, + deletion_pending_until,archived_at) + SELECT + 'team_0123456789abcdef0123456789abcdef', + 'org_rolling_team', + 'Rolling Team', + 'legacy-team-owner', + 3, + 3, + NULL, + NULL + WHERE EXISTS ( + SELECT 1 FROM team_creation_requests + WHERE user_id='legacy-team-owner' + AND idempotency_key='create_without_handle' + AND status='pending' + ); + INSERT INTO team_memberships + (team_id,user_id,role,workos_membership_id,joined_at,updated_at) + VALUES ( + 'team_0123456789abcdef0123456789abcdef', + 'legacy-team-owner', + 'owner', + 'membership_rolling_team', + 3, + 3 + ); + UPDATE team_creation_requests + SET status='completed',updated_at=3 + WHERE user_id='legacy-team-owner' AND idempotency_key='create_without_handle';`, + ) + const legacyTeamHandle = await executeJson( + stateDir, + `SELECT request.requested_handle, handle.handle + FROM team_creation_requests request + LEFT JOIN handles handle + ON handle.team_id=request.team_id AND handle.released_at IS NULL + WHERE request.user_id='legacy-team-owner' + AND request.idempotency_key='create_without_handle';`, + ) + if ( + legacyTeamHandle.length !== 1 || + typeof legacyTeamHandle[0]?.requested_handle !== 'string' || + legacyTeamHandle[0]?.requested_handle !== legacyTeamHandle[0]?.handle + ) { + throw new Error( + `Legacy Team creation did not atomically adopt a handle: ${JSON.stringify(legacyTeamHandle)}`, + ) + } + await expectD1Failure( + stateDir, + `UPDATE team_creation_requests + SET requested_handle='different-rolling-handle' + WHERE user_id='legacy-team-owner' AND idempotency_key='create_without_handle';`, + 'team creation handle is immutable', + ) + await expectD1Failure( + stateDir, + `UPDATE team_creation_requests + SET requested_handle='different-handle' + WHERE user_id='durable-user' AND idempotency_key='create_durable_0001';`, + 'team creation handle is immutable', + ) + + await executeJson( + stateDir, + `INSERT INTO users (id,email,created_at,last_signin_at) + VALUES + ('handle-user-a','handle-a@example.test',1,1), + ('handle-user-b','handle-b@example.test',1,1); + INSERT INTO handles (handle,user_id,team_id,claimed_at,released_at) + VALUES + ('active-user-handle','handle-user-a',NULL,1,NULL), + ('retired-user-handle','handle-user-a',NULL,1,2), + ('active-team-handle',NULL,'team_durable',1,NULL);`, + ) + await expectD1Failure( + stateDir, + `INSERT INTO handles (handle,user_id,team_id,claimed_at,released_at) + VALUES ('second-user-handle','handle-user-a',NULL,2,NULL);`, + 'UNIQUE constraint failed: handles.user_id', + ) + await expectD1Failure( + stateDir, + `INSERT INTO handles (handle,user_id,team_id,claimed_at,released_at) + VALUES ('second-team-handle',NULL,'team_durable',2,NULL);`, + 'UNIQUE constraint failed: handles.team_id', + ) + await expectD1Failure( + stateDir, + `INSERT INTO handles (handle,user_id,team_id,claimed_at,released_at) + VALUES ('ACTIVE-TEAM-HANDLE','handle-user-b',NULL,2,NULL);`, + 'UNIQUE constraint failed: handles.handle', + ) + await expectD1Failure( + stateDir, + "UPDATE handles SET released_at=NULL WHERE handle='retired-user-handle';", + 'released handle is a permanent tombstone', + ) + await expectD1Failure( + stateDir, + "DELETE FROM handles WHERE handle='retired-user-handle';", + 'handle tombstones cannot be deleted', + ) + await executeJson( + stateDir, + `INSERT INTO users (id,email,created_at,last_signin_at) + VALUES ('retire-user','retire-user@example.test',1,1); + INSERT INTO teams + (id,workos_organization_id,name,created_by_user_id,created_at,updated_at) + VALUES ('team_retire','org_retire','Retire Team','durable-user',1,1); + INSERT INTO handles (handle,user_id,team_id,claimed_at,released_at) + VALUES + ('retire-user-handle','retire-user',NULL,1,NULL), + ('retire-team-handle',NULL,'team_retire',1,NULL); + INSERT INTO projects + (id,owner_user_id,owner_team_id,slug,name,created_by_user_id,created_at,updated_at) + VALUES + ('project_default_user_retire-user','retire-user',NULL,'sessions','Sessions','retire-user',1,1), + ('project_default_team_team_retire',NULL,'team_retire','sessions','Sessions','durable-user',1,1); + UPDATE users SET deleted_at=10 WHERE id='retire-user'; + UPDATE teams SET archived_at=11 WHERE id='team_retire';`, + ) + const retiredOwnerState = await executeJson( + stateDir, + `SELECT + (SELECT released_at FROM handles WHERE handle='retire-user-handle') AS user_handle, + (SELECT archived_at FROM projects WHERE id='project_default_user_retire-user') AS user_project, + (SELECT released_at FROM handles WHERE handle='retire-team-handle') AS team_handle, + (SELECT archived_at FROM projects WHERE id='project_default_team_team_retire') AS team_project;`, + ) + if ( + retiredOwnerState.length !== 1 || + retiredOwnerState[0]?.user_handle !== 10 || + retiredOwnerState[0]?.user_project !== 10 || + retiredOwnerState[0]?.team_handle !== 11 || + retiredOwnerState[0]?.team_project !== 11 + ) { + throw new Error( + `Owner retirement did not archive handles/Projects: ${JSON.stringify(retiredOwnerState)}`, + ) + } + await expectD1Failure( + stateDir, + `INSERT INTO project_creation_requests + (actor_user_id,owner_scope,owner_user_id,owner_team_id,idempotency_key,project_id, + request_hash,created_at) + VALUES ( + 'durable-user', + 'user:durable-user', + 'durable-user', + NULL, + 'project-create-user-0001', + 'project_user_durable-user_secondary', + '${'f'.repeat(64)}', + 2 + );`, + 'UNIQUE constraint failed', + ) + await executeJson( + stateDir, + `INSERT INTO users (id,email,created_at,last_signin_at) + VALUES ('0123456789abcdef','legacy-hub-user@example.test',1,1); + INSERT INTO hub_sessions + (sid,owner_user_id,root,record_count,visibility,created_at,updated_at) + VALUES ( + 'codex_project_legacy_personal', + '0123456789abcdef', + '${'1'.repeat(64)}', + 1, + 'unlisted', + 1, + 1 + );`, + ) + const legacyPersonalProject = await executeJson( + stateDir, + `SELECT + session.project_id, + project.owner_user_id, + project.owner_team_id, + handle.handle AS owner_handle + FROM hub_sessions session + JOIN projects project ON project.id=session.project_id + LEFT JOIN handles handle + ON handle.user_id=session.owner_user_id AND handle.released_at IS NULL + WHERE session.sid='codex_project_legacy_personal';`, + ) + if ( + legacyPersonalProject.length !== 1 || + legacyPersonalProject[0]?.project_id !== 'project_default_user_0123456789abcdef' || + legacyPersonalProject[0]?.owner_user_id !== '0123456789abcdef' || + legacyPersonalProject[0]?.owner_team_id !== null || + legacyPersonalProject[0]?.owner_handle !== 'user-0123456789abcdef' + ) { + throw new Error( + `Legacy Hub insert did not receive a Personal fallback Project: ${JSON.stringify(legacyPersonalProject)}`, + ) + } + await executeJson( + stateDir, + `UPDATE hub_sessions + SET + visibility='private', + team_id='team_0123456789abcdef0123456789abcdef', + updated_at=2 + WHERE sid='codex_project_legacy_personal';`, + ) + const legacyTransferredProject = await executeJson( + stateDir, + `SELECT session.project_id,project.owner_user_id,project.owner_team_id + FROM hub_sessions session + JOIN projects project ON project.id=session.project_id + WHERE session.sid='codex_project_legacy_personal';`, + ) + if ( + legacyTransferredProject.length !== 1 || + legacyTransferredProject[0]?.project_id !== + 'project_default_team_team_0123456789abcdef0123456789abcdef' || + legacyTransferredProject[0]?.owner_user_id !== null || + legacyTransferredProject[0]?.owner_team_id !== 'team_0123456789abcdef0123456789abcdef' + ) { + throw new Error( + `Legacy Personal-to-Team transfer did not switch Projects: ${JSON.stringify(legacyTransferredProject)}`, + ) + } + await executeJson( + stateDir, + `INSERT INTO hub_sessions + (sid,owner_user_id,root,record_count,visibility,team_id,created_at,updated_at) + VALUES ( + 'claude_project_legacy_team_insert', + '0123456789abcdef', + '${'5'.repeat(64)}', + 1, + 'private', + 'team_0123456789abcdef0123456789abcdef', + 3, + 3 + );`, + ) + const legacyTeamProject = await executeJson( + stateDir, + `SELECT project_id + FROM hub_sessions + WHERE sid='claude_project_legacy_team_insert';`, + ) + if ( + legacyTeamProject.length !== 1 || + legacyTeamProject[0]?.project_id !== + 'project_default_team_team_0123456789abcdef0123456789abcdef' + ) { + throw new Error( + `Legacy Team Hub insert did not receive a Team fallback Project: ${JSON.stringify(legacyTeamProject)}`, + ) + } + await expectD1Failure( + stateDir, + `INSERT INTO hub_sessions + (sid,owner_user_id,root,record_count,visibility,team_id,project_id,created_at,updated_at) + VALUES ( + 'codex_project_personal_mismatch', + 'durable-user', + '${'2'.repeat(64)}', + 1, + 'unlisted', + NULL, + 'project_default_team_team_durable', + 1, + 1 + );`, + 'hub session project tenant mismatch', + ) + await executeJson( + stateDir, + `INSERT INTO hub_sessions + (sid,owner_user_id,root,record_count,visibility,team_id,project_id,created_at,updated_at) + VALUES + ( + 'codex_project_personal_valid', + 'durable-user', + '${'3'.repeat(64)}', + 1, + 'unlisted', + NULL, + 'project_default_user_durable-user', + 1, + 1 + ), + ( + 'codex_project_team_valid', + 'durable-user', + '${'4'.repeat(64)}', + 1, + 'private', + 'team_durable', + 'project_default_team_team_durable', + 1, + 1 + );`, + ) + await expectD1Failure( + stateDir, + "UPDATE hub_sessions SET project_id=NULL WHERE sid='codex_project_personal_valid';", + 'hub session project is required', + ) + await expectD1Failure( + stateDir, + `UPDATE hub_sessions + SET project_id='project_default_team_team_durable' + WHERE sid='codex_project_personal_valid';`, + 'hub session project tenant mismatch', ) const discoveryIndexes = await executeJson( @@ -287,13 +1358,14 @@ try { `INSERT INTO users (id,email,created_at,last_signin_at) VALUES ('social-viewer','social-viewer@example.test',1,1); INSERT INTO hub_sessions - (sid,owner_user_id,root,record_count,visibility,created_at,updated_at) + (sid,owner_user_id,root,record_count,visibility,project_id,created_at,updated_at) VALUES ( 'codex_00000000-0000-4000-8000-000000000099', 'durable-user', '${'c'.repeat(64)}', 1, 'unlisted', + 'project_default_user_durable-user', 1, 1 ); @@ -337,13 +1409,14 @@ try { await executeJson( stateDir, `INSERT INTO hub_sessions - (sid,owner_user_id,root,record_count,visibility,created_at,updated_at) + (sid,owner_user_id,root,record_count,visibility,project_id,created_at,updated_at) VALUES ( 'claude_00000000-0000-4000-8000-000000000001', 'durable-user', '${'a'.repeat(64)}', 12, 'unlisted', + 'project_default_user_durable-user', 1, 1 ); @@ -377,19 +1450,36 @@ try { VALUES ('claude_00000000-0000-4000-8000-000000000001','1970-01-01',5); INSERT INTO handles (handle,user_id,claimed_at,released_at) VALUES - ('zeta-durable','durable-user',1,NULL), + ('zeta-durable','durable-user',1,2), ('alpha-durable','durable-user',2,NULL);`, ) + const hubProjectRows = await executeJson( + stateDir, + bindSql(HUB_PROJECTS_LIST_SQL, ['durable-user', 0, 0, 0, '', 101]), + ) + if ( + hubProjectRows.length !== 3 || + !hubProjectRows.every((row) => row.can_manage === 1) || + JSON.stringify(hubProjectRows.map((row) => [row.id, row.owner_handle])) !== + JSON.stringify([ + ['project_default_team_team_durable', 'active-team-handle'], + ['project_default_user_durable-user', 'alpha-durable'], + ['project_user_durable-user_secondary', 'alpha-durable'], + ]) + ) { + throw new Error(`Hub Projects SQL returned unexpected rows: ${JSON.stringify(hubProjectRows)}`) + } await executeJson( stateDir, `INSERT INTO hub_sessions - (sid,owner_user_id,root,record_count,visibility,created_at,updated_at) + (sid,owner_user_id,root,record_count,visibility,project_id,created_at,updated_at) VALUES ( 'codex_00000000-0000-4000-8000-000000000098', 'durable-user', '${'b'.repeat(64)}', 2, 'unlisted', + 'project_default_user_durable-user', 2, 2 ); @@ -608,8 +1698,108 @@ try { throw new Error(`Cross-Team quota trigger produced unexpected total: ${JSON.stringify(teamB)}`) } + // Project creation enforces its tenant quota in the final INSERT predicate. + // This exercises D1's exact COUNT/INSERT behavior at the boundary rather + // than relying on an application preflight that concurrent requests could + // both pass. + await executeJson( + stateDir, + `INSERT INTO users (id,email,created_at,last_signin_at) + VALUES ('project-quota-user','project-quota@example.test',1,1); + INSERT INTO handles (handle,user_id,claimed_at,released_at) + VALUES ('project-quota-user','project-quota-user',1,NULL); + WITH RECURSIVE sequence(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM sequence WHERE n < 100 + ) + INSERT INTO projects + (id,owner_user_id,owner_team_id,slug,name,description,github_url, + created_by_user_id,created_at,updated_at,archived_at) + SELECT + 'project_quota_' || printf('%03d',n), + 'project-quota-user', + NULL, + 'quota-' || printf('%03d',n), + 'Quota ' || n, + NULL, + NULL, + 'project-quota-user', + n, + n, + NULL + FROM sequence; + INSERT INTO projects + (id,owner_user_id,owner_team_id,slug,name,description,github_url, + created_by_user_id,created_at,updated_at,archived_at) + SELECT + 'project_quota_over', + 'project-quota-user', + NULL, + 'quota-over', + 'Quota over', + NULL, + NULL, + 'project-quota-user', + 101, + 101, + NULL + WHERE ( + SELECT COUNT(*) FROM projects active_project + WHERE active_project.owner_user_id IS 'project-quota-user' + AND active_project.owner_team_id IS NULL + AND active_project.archived_at IS NULL + ) < 100;`, + ) + const projectQuotaRows = await executeJson( + stateDir, + `SELECT COUNT(*) AS active_count, + SUM(CASE WHEN id='project_quota_over' THEN 1 ELSE 0 END) AS overflow_count + FROM projects + WHERE owner_user_id='project-quota-user' AND archived_at IS NULL;`, + ) + if (projectQuotaRows[0]?.active_count !== 100 || projectQuotaRows[0]?.overflow_count !== 0) { + throw new Error(`Project quota guard failed: ${JSON.stringify(projectQuotaRows)}`) + } + + const projectIntegrity = await executeJson( + stateDir, + `SELECT + SUM(CASE WHEN s.project_id IS NULL THEN 1 ELSE 0 END) AS null_projects, + SUM( + CASE + WHEN s.team_id IS NULL AND NOT ( + p.owner_user_id = s.owner_user_id AND p.owner_team_id IS NULL + ) THEN 1 + WHEN s.team_id IS NOT NULL AND NOT ( + p.owner_user_id IS NULL AND p.owner_team_id = s.team_id + ) THEN 1 + ELSE 0 + END + ) AS tenant_mismatches + FROM hub_sessions s + LEFT JOIN projects p ON p.id = s.project_id;`, + ) + if ( + projectIntegrity.length !== 1 || + projectIntegrity[0]?.null_projects !== 0 || + projectIntegrity[0]?.tenant_mismatches !== 0 + ) { + throw new Error(`Hub Project integrity is invalid: ${JSON.stringify(projectIntegrity)}`) + } + + const finalForeignKeyViolations = await executeJson(stateDir, 'PRAGMA foreign_key_check;') + if (finalForeignKeyViolations.length !== 0) { + throw new Error( + `Post-fixture foreign-key violations: ${JSON.stringify(finalForeignKeyViolations)}`, + ) + } + + await verifyProjectBackfill() + await verifyControlledHandleConflict() + console.log( - `D1 schema smoke passed: ${migrationNames.length} migrations applied; foreign keys and Team quota triggers valid.`, + `D1 schema smoke passed: ${migrationNames.length} migrations applied; foreign keys, handle/Project lifecycle, Project backfill, and Team quota triggers valid.`, ) } finally { await rm(stateDir, { force: true, recursive: true }) diff --git a/apps/backend/src/auth/require.ts b/apps/backend/src/auth/require.ts index b4d3691f..d40fb166 100644 --- a/apps/backend/src/auth/require.ts +++ b/apps/backend/src/auth/require.ts @@ -7,6 +7,19 @@ import { loadSession } from './session' export type RequireUserOpts = { allowPendingDeletion?: boolean } +export async function optionalUser( + req: Request, + env: { SESSIONS: KVNamespace; DB: D1Database }, +): Promise { + const bearer = req.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1] + const token = bearer ?? readCookie(req, COOKIE_NAME) + if (!token) return null + const sess = await loadSession(env.SESSIONS, token) + if (!sess) return null + const user = await getUserById(env.DB, sess.user_id) + return user && user.deletion_pending_until === null ? user : null +} + export async function requireUser( req: Request, env: { SESSIONS: KVNamespace; DB: D1Database }, diff --git a/apps/backend/src/discovery/sessions.ts b/apps/backend/src/discovery/sessions.ts index 8f2f13f5..92f19b9a 100644 --- a/apps/backend/src/discovery/sessions.ts +++ b/apps/backend/src/discovery/sessions.ts @@ -213,7 +213,9 @@ function isNonNegativeSafeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 } -function toDiscoveryItem(row: DiscoveryCandidateRow): DiscoverySessionItem { +function toDiscoveryItem(row: DiscoveryCandidateRow): DiscoverySessionItem & { + project: { id: string; slug: string; name: string } | null +} { const avatarVisible = row.avatar_visible === 1 const avatarUrl = !avatarVisible ? null @@ -246,6 +248,14 @@ function toDiscoveryItem(row: DiscoveryCandidateRow): DiscoverySessionItem { displayName: row.display_name ?? row.name, avatarUrl, }, + project: + row.project_id === null || row.project_slug === null || row.project_name === null + ? null + : { + id: row.project_id, + slug: row.project_slug, + name: row.project_name, + }, evidence: { records: row.record_count, messages: row.message_count, diff --git a/apps/backend/src/discovery/store.ts b/apps/backend/src/discovery/store.ts index 499368bd..5aec3be5 100644 --- a/apps/backend/src/discovery/store.ts +++ b/apps/backend/src/discovery/store.ts @@ -22,6 +22,9 @@ export type DiscoveryCandidateRow = { updated_at: number record_count: number owner_user_id: string + project_id: string | null + project_slug: string | null + project_name: string | null handle: string | null name: string | null display_name: string | null @@ -250,6 +253,9 @@ export async function listDiscoveryPage( d.updated_at, s.record_count, s.owner_user_id, + CASE WHEN s.team_id IS NULL THEN project.id ELSE NULL END AS project_id, + CASE WHEN s.team_id IS NULL THEN project.slug ELSE NULL END AS project_slug, + CASE WHEN s.team_id IS NULL THEN project.name ELSE NULL END AS project_name, CASE WHEN u.deleted_at IS NULL THEN h.handle ELSE NULL END AS handle, CASE WHEN u.deleted_at IS NULL THEN u.name ELSE NULL END AS name, CASE WHEN u.deleted_at IS NULL THEN u.display_name ELSE NULL END AS display_name, @@ -269,6 +275,7 @@ export async function listDiscoveryPage( END AS normalized_author FROM hub_session_discovery d JOIN hub_sessions s ON s.sid = d.sid + JOIN projects project ON project.id=s.project_id JOIN users u ON u.id = s.owner_user_id LEFT JOIN teams owning_team ON owning_team.id=s.team_id AND owning_team.archived_at IS NULL @@ -335,6 +342,9 @@ export async function listDiscoveryPage( d.updated_at, d.record_count, d.owner_user_id, + d.project_id, + d.project_slug, + d.project_name, d.handle, d.name, d.display_name, diff --git a/apps/backend/src/handles.ts b/apps/backend/src/handles.ts index 718afa8c..53edd5eb 100644 --- a/apps/backend/src/handles.ts +++ b/apps/backend/src/handles.ts @@ -1,20 +1,12 @@ -// Public profiles (/@handle pages) are cut from the launch scope: no -// client offers handle claiming, and the claim/check endpoints behind -// this gate return 404. Handles are the root of the whole profile -// surface — publish and visibility-PATCH already reject profile-listed -// without one, /@handle pages 404 without a row — so gating the claim -// path keeps everything downstream unreachable without touching it. -// That holds only while the handles table is empty: an existing row -// would keep its /@handle page, /api/me handle, and List-on-profile -// menu fully live. The production D1 launches with zero handle rows; -// if any get seeded before this gate is deployed, release them. -// If user feedback asks for public profiles, set PROFILES_ENABLED=1 on -// the Pages project and restore the client entry points — grep for -// PROFILES_ENABLED in the web app and SHOW_VISIBILITY_PICKER in the app, -// and restore the handle-release wording cut from the web app's -// DeleteAccountModal and the profile sections of /privacy and /terms. +import type { D1Database } from '@cloudflare/workers-types' + +import { ApiError } from './errors' + +// Handle-based owner/Project routes are part of the active product surface. +// Keep an emergency off switch for incident response, but default to enabled +// so an omitted Pages variable cannot make seeded owner URLs disappear. export function profilesEnabled(env: { PROFILES_ENABLED?: string }): boolean { - return env.PROFILES_ENABLED === '1' + return env.PROFILES_ENABLED !== '0' } // Two flavours of reserved name, merged into one set: @@ -102,3 +94,216 @@ export function validateHandle(raw: unknown): HandleValidation { if (RESERVED.has(h)) return { ok: false, reason: 'reserved' } return { ok: true, handle: h } } + +export async function activeTeamHandle(db: D1Database, teamId: string): Promise { + const row = await db + .prepare('SELECT handle FROM handles WHERE team_id=? AND released_at IS NULL LIMIT 1') + .bind(teamId) + .first<{ handle: string }>() + return row?.handle ?? null +} + +export async function activeUserHandle(db: D1Database, userId: string): Promise { + const row = await db + .prepare('SELECT handle FROM handles WHERE user_id=? AND released_at IS NULL LIMIT 1') + .bind(userId) + .first<{ handle: string }>() + return row?.handle ?? null +} + +/** + * Gives a live Project owner a stable route identity without taking over the + * owner-controlled rename surface. Automatic handles always include an opaque + * owner-id suffix, so common names do not become a first-come race. + */ +export async function ensureOwnerHandle( + db: D1Database, + args: + | { actorUserId: string; userId: string; teamId: null; label?: string; now: number } + | { actorUserId: string; userId: null; teamId: string; label?: string; now: number }, +): Promise { + const current = + args.teamId === null + ? await activeUserHandle(db, args.userId) + : await activeTeamHandle(db, args.teamId) + if (current) return current + + const owner = + args.teamId === null + ? await db + .prepare( + `SELECT COALESCE(display_name,name) AS label + FROM users + WHERE id=? AND id=? AND deleted_at IS NULL + AND deletion_pending_until IS NULL`, + ) + .bind(args.userId, args.actorUserId) + .first<{ label: string | null }>() + : await db + .prepare( + `SELECT t.name AS label + FROM teams t + JOIN team_memberships membership ON membership.team_id=t.id + JOIN users actor ON actor.id=membership.user_id + WHERE t.id=? AND membership.user_id=? + AND t.archived_at IS NULL AND t.deletion_pending_until IS NULL + AND actor.deleted_at IS NULL AND actor.deletion_pending_until IS NULL`, + ) + .bind(args.teamId, args.actorUserId) + .first<{ label: string }>() + if (!owner) throw new ApiError('NOT_FOUND') + + const ownerId = args.teamId ?? args.userId + for (let attempt = 0; attempt < 8; attempt += 1) { + const handle = automaticHandle( + args.label ?? owner.label ?? '', + ownerId, + args.teamId ? 'team' : 'user', + attempt, + ) + try { + const result = + args.teamId === null + ? await db + .prepare( + `INSERT INTO handles + (handle, user_id, team_id, claimed_at, released_at) + SELECT ?,id,NULL,?,NULL + FROM users + WHERE id=? AND id=? AND deleted_at IS NULL + AND deletion_pending_until IS NULL`, + ) + .bind(handle, args.now, args.userId, args.actorUserId) + .run() + : await db + .prepare( + `INSERT INTO handles + (handle, user_id, team_id, claimed_at, released_at) + SELECT ?,NULL,t.id,?,NULL + FROM teams t + JOIN team_memberships membership ON membership.team_id=t.id + JOIN users actor ON actor.id=membership.user_id + WHERE t.id=? AND membership.user_id=? + AND t.archived_at IS NULL AND t.deletion_pending_until IS NULL + AND actor.deleted_at IS NULL AND actor.deletion_pending_until IS NULL`, + ) + .bind(handle, args.now, args.teamId, args.actorUserId) + .run() + if ((result.meta.changes ?? 0) > 0) return handle + } catch (error) { + if ( + !(error instanceof Error) || + !/UNIQUE constraint failed: handles(?:\.handle|\.user_id|\.team_id)?/i.test(error.message) + ) { + throw error + } + const won = + args.teamId === null + ? await activeUserHandle(db, args.userId) + : await activeTeamHandle(db, args.teamId) + if (won) return won + continue + } + } + throw new ApiError('CONFLICT', 'could not allocate a unique owner handle') +} + +function automaticHandle( + label: string, + ownerId: string, + kind: 'user' | 'team', + attempt: number, +): string { + const suffixSeed = ownerId.toLowerCase().replace(/[^a-z0-9]/g, '') + const suffix = `${suffixSeed.slice(-10) || kind}${attempt === 0 ? '' : attempt.toString(36)}` + let base = label + .normalize('NFKD') + .toLowerCase() + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + if (!/^[a-z]/.test(base)) base = `${kind}-${base}` + if (base.length < 2) base = kind + const maxBase = Math.max(2, 31 - suffix.length) + base = base.slice(0, maxBase).replace(/-+$/g, '') || kind + return `${base}-${suffix}`.slice(0, 32) +} + +/** + * Chooses an automatic Team handle before any upstream Team resources are + * created. Handles are permanent tombstones, so released rows are occupied + * too. The final claim still happens in the same D1 batch as Team creation; + * this read is the fail-fast path, not the concurrency boundary. + */ +export async function chooseAvailableTeamHandle( + db: D1Database, + args: { label: string; teamId: string }, +): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + const handle = automaticHandle(args.label, args.teamId, 'team', attempt) + const occupied = await db + .prepare('/* handles:is-occupied */ SELECT 1 AS occupied FROM handles WHERE handle=? LIMIT 1') + .bind(handle) + .first<{ occupied: number }>() + if (!occupied) return handle + } + throw new ApiError('CONFLICT', 'could not allocate a unique Team handle') +} + +export async function assertHandleAvailable(db: D1Database, handle: string): Promise { + const occupied = await db + .prepare('/* handles:is-occupied */ SELECT 1 AS occupied FROM handles WHERE handle=? LIMIT 1') + .bind(handle) + .first<{ occupied: number }>() + if (occupied) throw new ApiError('CONFLICT', 'handle taken') +} + +export async function changeTeamHandle( + db: D1Database, + args: { teamId: string; actorUserId: string; handle: string; now: number }, +): Promise { + const current = await activeTeamHandle(db, args.teamId) + if (current === args.handle) return + + try { + const results = await db.batch([ + db + .prepare( + `/* handles:release-team */ + UPDATE handles SET released_at=? + WHERE team_id=? AND released_at IS NULL + AND EXISTS ( + SELECT 1 + FROM teams t + JOIN team_memberships m ON m.team_id=t.id + WHERE t.id=? AND t.archived_at IS NULL + AND t.deletion_pending_until IS NULL + AND m.user_id=? AND m.role='owner' + )`, + ) + .bind(args.now, args.teamId, args.teamId, args.actorUserId), + db + .prepare( + `/* handles:claim-team */ + INSERT INTO handles + (handle, user_id, team_id, claimed_at, released_at) + SELECT ?,NULL,t.id,?,NULL + FROM teams t + JOIN team_memberships m ON m.team_id=t.id + WHERE t.id=? AND t.archived_at IS NULL + AND t.deletion_pending_until IS NULL + AND m.user_id=? AND m.role='owner'`, + ) + .bind(args.handle, args.now, args.teamId, args.actorUserId), + ]) + if ((results[1]?.meta.changes ?? 0) === 0) throw new ApiError('FORBIDDEN') + } catch (error) { + if ( + error instanceof Error && + /UNIQUE constraint failed: handles(?:\.handle|\.team_id)?/i.test(error.message) + ) { + throw new ApiError('CONFLICT', 'handle taken') + } + throw error + } +} diff --git a/apps/backend/src/hub/auth.ts b/apps/backend/src/hub/auth.ts index ef38a607..4af2634b 100644 --- a/apps/backend/src/hub/auth.ts +++ b/apps/backend/src/hub/auth.ts @@ -52,6 +52,25 @@ export async function requireHubUser(req: Request, env: HubAuthEnv): Promise { + try { + return await requireHubUser(req, env) + } catch (error) { + if ( + error instanceof ApiError && + (error.code === 'UNAUTHENTICATED' || error.code === 'FORBIDDEN') + ) { + return null + } + throw error + } +} + async function ensureDevUser(db: D1Database): Promise { const existing = await getUserById(db, DEV_USER_ID) if (existing) return existing diff --git a/apps/backend/src/hub/head.ts b/apps/backend/src/hub/head.ts index e19df085..041d65dc 100644 --- a/apps/backend/src/hub/head.ts +++ b/apps/backend/src/hub/head.ts @@ -2,6 +2,8 @@ import type { D1Database, KVNamespace, R2Bucket } from '@cloudflare/workers-type import { sequenceRoot } from '@spool-lab/session-kit' import { ApiError } from '../errors' +import { activeProjectForTenant, resolveDefaultProject } from '../projects/store' +import type { ProjectTenant } from '../projects/types' import { requireHubUser } from './auth' import { getHubSession, presentOids, presentTeamOids, type HubSessionRow } from './store' import type { HeadBodyT } from './wire' @@ -32,6 +34,8 @@ export async function validateHead( aliasOids: string[] teamId: string | null teamRole: TeamRole | null + projectId: string + projectNeedsInsert: boolean }> { const existing = await getHubSession(db, sid) const requestedTeamId = body.teamId ?? null @@ -58,6 +62,48 @@ export async function validateHead( throw new ApiError('CONFLICT', 'a Team-owned Session cannot move to another Team') } + if ( + body.expectedProjectId !== undefined && + (existing?.project_id ?? null) !== body.expectedProjectId + ) { + throw new ApiError('CONFLICT', 'Session Project changed; review the current Project') + } + + const tenant: ProjectTenant = + teamId === null ? { userId, teamId: null } : { userId: null, teamId } + let projectId: string + let projectNeedsInsert = false + const preservesExistingProject = + existing !== null && + body.projectId === undefined && + existing.team_id === teamId && + typeof existing.project_id === 'string' + if (preservesExistingProject) { + projectId = existing.project_id + if (!(await activeProjectForTenant(db, projectId, tenant))) { + throw new ApiError('CONFLICT', 'Session Project is archived or unavailable') + } + } else if (typeof body.projectId === 'string') { + projectId = body.projectId + if (!(await activeProjectForTenant(db, projectId, tenant))) { + throw new ApiError('NOT_FOUND') + } + } else { + const fallback = await resolveDefaultProject(db, tenant) + projectId = fallback.projectId + projectNeedsInsert = fallback.needsInsert + } + const projectChanged = existing !== null && existing.project_id !== projectId + if ( + projectChanged && + existing !== null && + existing.team_id !== null && + teamRole !== 'owner' && + teamRole !== 'admin' + ) { + throw new ApiError('FORBIDDEN') + } + const root = await sequenceRoot(body.manifest) if (root !== body.root) { throw new ApiError('UNPROCESSABLE', 'manifest does not fold to root') @@ -77,6 +123,8 @@ export async function validateHead( aliasOids: [], teamId: null, teamRole: null, + projectId, + projectNeedsInsert, } } @@ -91,6 +139,8 @@ export async function validateHead( aliasOids: notInTeam.filter((oid) => personalPresent.has(oid)), teamId, teamRole, + projectId, + projectNeedsInsert, } } diff --git a/apps/backend/src/hub/managed-row.ts b/apps/backend/src/hub/managed-row.ts new file mode 100644 index 00000000..fb34c3d6 --- /dev/null +++ b/apps/backend/src/hub/managed-row.ts @@ -0,0 +1,91 @@ +import type { HubSessionRow } from './store' + +/** + * Columns appended to Session feed rows so rendering a page never performs + * Project/author/publication lookups per row. + */ +export type HydratedManagedSessionRow = HubSessionRow & { + team_name?: string | null + star_count?: number + managed_published: number + managed_published_at: number | null + managed_author_handle: string | null + managed_author_name: string | null + managed_author_display_name: string | null + managed_author_avatar_url: string | null + managed_author_custom_avatar_id: string | null + managed_author_avatar_visible: number + managed_project_slug: string + managed_project_name: string + managed_project_owner_user_id: string | null + managed_project_owner_team_id: string | null + managed_project_owner_handle: string + managed_project_owner_name: string + managed_project_owner_avatar_url: string | null + managed_project_owner_custom_avatar_id: string | null + managed_project_owner_avatar_visible: number +} + +export function managedSessionProjection(): string { + return ` + CASE WHEN managed_discovery.sid IS NULL THEN 0 ELSE 1 END AS managed_published, + managed_discovery.published_at AS managed_published_at, + managed_author_handle.handle AS managed_author_handle, + managed_author.name AS managed_author_name, + managed_author.display_name AS managed_author_display_name, + managed_author.avatar_url AS managed_author_avatar_url, + managed_author.custom_avatar_id AS managed_author_custom_avatar_id, + COALESCE(managed_author.avatar_visible,1) AS managed_author_avatar_visible, + managed_project.slug AS managed_project_slug, + managed_project.name AS managed_project_name, + managed_project.owner_user_id AS managed_project_owner_user_id, + managed_project.owner_team_id AS managed_project_owner_team_id, + managed_project_owner_handle.handle AS managed_project_owner_handle, + CASE + WHEN managed_project.owner_team_id IS NOT NULL THEN managed_project_team.name + ELSE COALESCE( + managed_project_user.display_name, + managed_project_user.name, + CASE + WHEN instr(managed_project_user.email,'@')>0 + THEN substr(managed_project_user.email,1,instr(managed_project_user.email,'@')-1) + ELSE managed_project_user.email + END, + managed_project_owner_handle.handle + ) + END AS managed_project_owner_name, + managed_project_user.avatar_url AS managed_project_owner_avatar_url, + managed_project_user.custom_avatar_id AS managed_project_owner_custom_avatar_id, + COALESCE(managed_project_user.avatar_visible,1) AS managed_project_owner_avatar_visible` +} + +export function managedSessionJoins(sessionAlias = 's', preserveEmpty = false): string { + const join = preserveEmpty ? 'LEFT JOIN' : 'JOIN' + return ` + ${join} projects managed_project ON managed_project.id=${sessionAlias}.project_id + ${join} users managed_author ON managed_author.id=${sessionAlias}.owner_user_id + LEFT JOIN hub_session_discovery managed_discovery ON managed_discovery.sid=${sessionAlias}.sid + LEFT JOIN handles managed_author_handle ON managed_author_handle.handle=( + SELECT MIN(candidate.handle) + FROM handles candidate + WHERE candidate.user_id=${sessionAlias}.owner_user_id + AND candidate.released_at IS NULL + ) + LEFT JOIN users managed_project_user + ON managed_project_user.id=managed_project.owner_user_id + LEFT JOIN teams managed_project_team + ON managed_project_team.id=managed_project.owner_team_id + ${join} handles managed_project_owner_handle ON managed_project_owner_handle.handle=( + SELECT MIN(candidate.handle) + FROM handles candidate + WHERE candidate.released_at IS NULL + AND candidate.user_id IS managed_project.owner_user_id + AND candidate.team_id IS managed_project.owner_team_id + )` +} + +export function isHydratedManagedSessionRow( + row: HubSessionRow | HydratedManagedSessionRow, +): row is HydratedManagedSessionRow { + return 'managed_project_slug' in row && 'managed_project_owner_handle' in row +} diff --git a/apps/backend/src/hub/management.ts b/apps/backend/src/hub/management.ts index 8fded226..aefd731b 100644 --- a/apps/backend/src/hub/management.ts +++ b/apps/backend/src/hub/management.ts @@ -8,7 +8,15 @@ import { import { base64urlFromBuffer, sha256 } from '../auth/pkce' import { isPublishedToDiscovery } from '../discovery/projection' import { ApiError } from '../errors' +import { getProjectById, projectOwner } from '../projects/store' +import type { ProjectRef } from '../projects/types' import type { TeamRole } from './head' +import { + isHydratedManagedSessionRow, + managedSessionJoins, + managedSessionProjection, + type HydratedManagedSessionRow, +} from './managed-row' import { getHubAuthor, type HubSessionRow } from './store' import { SID_RE } from './wire' @@ -23,6 +31,8 @@ export type ManagedHubSessionPageOptions = { export type ManagedHubSessionPage = { sessions: ManagedHubSession[] + /** Total Sessions in this scope, independent of the current page. */ + session_count: number next_cursor: string | null } @@ -49,10 +59,13 @@ export type ManagedHubSession = { star_count: number provider: string created_at: number + /** Discovery publication time. Null for Link-only and Team-only Sessions. */ + published_at: number | null updated_at: number visibility: 'public' | 'link-only' | 'team' team_id: string | null team_name: string | null + project: ProjectRef can_manage_visibility: boolean author: { handle: string | null @@ -74,18 +87,36 @@ export async function listOwnerHubSessions( const after = decodeCursor(options.cursor, fingerprint) const rows = await db .prepare( - 'SELECT s.*, t.name AS team_name, m.role AS team_role, ' + - '(SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count ' + - 'FROM users actor ' + - 'JOIN hub_sessions s ON s.owner_user_id=actor.id ' + - 'LEFT JOIN teams t ON t.id=s.team_id ' + - 'LEFT JOIN team_memberships m ON m.team_id=s.team_id AND m.user_id=actor.id ' + - 'WHERE actor.id=? AND actor.deleted_at IS NULL ' + - 'AND actor.deletion_pending_until IS NULL AND s.withdrawn_at IS NULL ' + - 'AND (s.team_id IS NULL OR (m.user_id IS NOT NULL AND t.archived_at IS NULL ' + - 'AND t.deletion_pending_until IS NULL)) ' + - 'AND (?=0 OR s.updated_at?)) ' + - 'ORDER BY s.updated_at DESC, s.sid ASC LIMIT ?', + `SELECT s.*, t.name AS team_name, m.role AS team_role, + ( + SELECT COUNT(*) + FROM hub_sessions counted + LEFT JOIN teams counted_team ON counted_team.id=counted.team_id + LEFT JOIN team_memberships counted_member + ON counted_member.team_id=counted.team_id AND counted_member.user_id=actor.id + WHERE counted.owner_user_id=actor.id AND counted.withdrawn_at IS NULL + AND ( + counted.team_id IS NULL + OR ( + counted_member.user_id IS NOT NULL + AND counted_team.archived_at IS NULL + AND counted_team.deletion_pending_until IS NULL + ) + ) + ) AS session_count, + (SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count, + ${managedSessionProjection()} + FROM users actor + JOIN hub_sessions s ON s.owner_user_id=actor.id + ${managedSessionJoins('s')} + LEFT JOIN teams t ON t.id=s.team_id + LEFT JOIN team_memberships m ON m.team_id=s.team_id AND m.user_id=actor.id + WHERE actor.id=? AND actor.deleted_at IS NULL + AND actor.deletion_pending_until IS NULL AND s.withdrawn_at IS NULL + AND (s.team_id IS NULL OR (m.user_id IS NOT NULL AND t.archived_at IS NULL + AND t.deletion_pending_until IS NULL)) + AND (?=0 OR s.updated_at?)) + ORDER BY s.updated_at DESC, s.sid ASC LIMIT ?`, ) .bind( userId, @@ -96,10 +127,11 @@ export async function listOwnerHubSessions( options.limit + 1, ) .all< - HubSessionRow & { + HydratedManagedSessionRow & { team_name: string | null team_role: string | null star_count: number + session_count: number } >() const hasMore = rows.results.length > options.limit @@ -115,6 +147,7 @@ export async function listOwnerHubSessions( ) return { sessions, + session_count: Math.max(0, Number(rows.results[0]?.session_count ?? rows.results.length)), next_cursor: hasMore ? encodeCursor(pageKey(pageRows.at(-1)!), fingerprint) : null, } } @@ -146,11 +179,18 @@ export async function listTeamHubSessions( AND actor.deletion_pending_until IS NULL ) SELECT s.*, current_team.name AS team_name, current_team.role AS team_role, - (SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count + ( + SELECT COUNT(*) + FROM hub_sessions counted + WHERE counted.team_id=current_team.id AND counted.withdrawn_at IS NULL + ) AS session_count, + (SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count, + ${managedSessionProjection()} FROM current_team LEFT JOIN hub_sessions s ON s.team_id=current_team.id AND s.withdrawn_at IS NULL AND (?=0 OR s.updated_at?)) + ${managedSessionJoins('s', true)} ORDER BY s.updated_at DESC, s.sid ASC LIMIT ?`, ) @@ -164,10 +204,11 @@ export async function listTeamHubSessions( options.limit + 1, ) .all< - { [K in keyof HubSessionRow]: HubSessionRow[K] | null } & { + { [K in keyof HydratedManagedSessionRow]: HydratedManagedSessionRow[K] | null } & { team_name: string team_role: TeamRole star_count: number + session_count: number } >() if (rows.results.length === 0) return null @@ -179,13 +220,14 @@ export async function listTeamHubSessions( pageRows.map((row) => serializeManagedSession( db, - row as HubSessionRow & { team_name: string }, + row as HydratedManagedSessionRow & { team_name: string }, row.team_role === 'owner' || row.team_role === 'admin', ), ), ) return { sessions, + session_count: Math.max(0, Number(rows.results[0]?.session_count ?? sessionRows.length)), next_cursor: hasMore ? encodeCursor(pageKey(pageRows.at(-1)! as HubSessionRow), fingerprint) : null, @@ -285,14 +327,70 @@ function isNonNegativeSafeInteger(value: unknown): value is number { export async function serializeManagedSession( db: D1Database, - row: HubSessionRow & { team_name?: string | null; star_count?: number }, + row: + | (HubSessionRow & { team_name?: string | null; star_count?: number }) + | HydratedManagedSessionRow, canManageVisibility = true, ): Promise { - const [author, published, starCount] = await Promise.all([ - getHubAuthor(db, row.owner_user_id), - row.visibility === 'unlisted' ? isPublishedToDiscovery(db, row.sid) : Promise.resolve(false), - row.star_count === undefined ? sessionStarCount(db, row.sid) : Promise.resolve(row.star_count), - ]) + const hydrated = isHydratedManagedSessionRow(row) + const fallback = hydrated + ? null + : await Promise.all([ + getHubAuthor(db, row.owner_user_id), + row.visibility === 'unlisted' + ? isPublishedToDiscovery(db, row.sid) + : Promise.resolve(false), + row.star_count === undefined + ? sessionStarCount(db, row.sid) + : Promise.resolve(row.star_count), + getProjectById(db, row.project_id, { includeArchived: true }), + ]) + const fallbackProject = fallback?.[3] ?? null + if (!hydrated && !fallbackProject) throw new ApiError('INTERNAL', 'Session Project missing') + const author = hydrated + ? { + handle: row.managed_author_handle, + displayName: row.managed_author_display_name ?? row.managed_author_name, + avatarUrl: visibleAvatarUrl({ + userId: row.owner_user_id, + avatarUrl: row.managed_author_avatar_url, + customAvatarId: row.managed_author_custom_avatar_id, + avatarVisible: row.managed_author_avatar_visible, + }), + } + : fallback![0] + const published = hydrated ? row.managed_published === 1 : fallback![1] + const starCount = hydrated ? (row.star_count ?? 0) : fallback![2] + const project: ProjectRef = hydrated + ? { + id: row.project_id, + slug: row.managed_project_slug, + name: row.managed_project_name, + owner: { + kind: row.managed_project_owner_team_id === null ? 'user' : 'team', + id: + row.managed_project_owner_team_id ?? + row.managed_project_owner_user_id ?? + row.owner_user_id, + handle: row.managed_project_owner_handle, + name: row.managed_project_owner_name, + avatar_url: + row.managed_project_owner_user_id === null + ? null + : visibleAvatarUrl({ + userId: row.managed_project_owner_user_id, + avatarUrl: row.managed_project_owner_avatar_url, + customAvatarId: row.managed_project_owner_custom_avatar_id, + avatarVisible: row.managed_project_owner_avatar_visible, + }), + }, + } + : { + id: fallbackProject!.id, + slug: fallbackProject!.slug, + name: fallbackProject!.name, + owner: await projectOwner(db, fallbackProject!), + } const parsedSummary = parseSummaryFrontMatter(row.note_md) return { sid: row.sid, @@ -307,11 +405,13 @@ export async function serializeManagedSession( star_count: Math.max(0, Number(starCount)), provider: row.sid.slice(0, row.sid.indexOf('_')), created_at: row.created_at, + published_at: hydrated ? row.managed_published_at : published ? row.created_at : null, updated_at: row.updated_at, visibility: row.visibility === 'private' && row.team_id ? 'team' : published ? 'public' : 'link-only', team_id: row.team_id ?? null, team_name: row.team_name ?? null, + project, can_manage_visibility: canManageVisibility, author: { handle: author.handle, @@ -321,6 +421,18 @@ export async function serializeManagedSession( } } +function visibleAvatarUrl(args: { + userId: string + avatarUrl: string | null + customAvatarId: string | null + avatarVisible: number +}): string | null { + if (args.avatarVisible !== 1) return null + return args.customAvatarId + ? `/api/avatars/${encodeURIComponent(args.userId)}?v=${encodeURIComponent(args.customAvatarId)}` + : args.avatarUrl +} + async function sessionStarCount(db: D1Database, sid: string): Promise { const row = await db .prepare('SELECT COUNT(*) AS star_count FROM hub_session_stars WHERE sid=?') diff --git a/apps/backend/src/hub/store.ts b/apps/backend/src/hub/store.ts index 356fdb9a..7ae6a0de 100644 --- a/apps/backend/src/hub/store.ts +++ b/apps/backend/src/hub/store.ts @@ -27,6 +27,9 @@ export type HubSessionRow = { /** Resource tenant. NULL means personal; a value means the Team owns the * Session and its object index even when the Team later publishes it. */ team_id: string | null + /** Required grouping inside the resource tenant. Migration 0014 backfills + * every legacy row and D1 triggers reject future NULL/mismatched values. */ + project_id: string withdrawn_at: number | null created_at: number updated_at: number @@ -60,6 +63,7 @@ export type HubSessionUpsert = { spoolFileOid: string | null costUsd: number | null totalTokens: number | null + projectId: string now: number } @@ -72,9 +76,9 @@ export function prepareHubSessionUpsert( // re-publish decision. return db .prepare( - 'INSERT INTO hub_sessions (sid, owner_user_id, root, record_count, sig, card_json, note_md, lineage_json, view_oid, spool_file_oid, cost_usd, total_tokens, visibility, withdrawn_at, created_at, updated_at) ' + - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'unlisted',NULL,?,?) " + - 'ON CONFLICT(sid) DO UPDATE SET root=excluded.root, record_count=excluded.record_count, sig=excluded.sig, card_json=excluded.card_json, note_md=excluded.note_md, lineage_json=excluded.lineage_json, view_oid=excluded.view_oid, spool_file_oid=excluded.spool_file_oid, cost_usd=excluded.cost_usd, total_tokens=excluded.total_tokens, withdrawn_at=NULL, updated_at=excluded.updated_at', + 'INSERT INTO hub_sessions (sid, owner_user_id, root, record_count, sig, card_json, note_md, lineage_json, view_oid, spool_file_oid, cost_usd, total_tokens, visibility, project_id, withdrawn_at, created_at, updated_at) ' + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'unlisted',?,NULL,?,?) " + + 'ON CONFLICT(sid) DO UPDATE SET root=excluded.root, record_count=excluded.record_count, sig=excluded.sig, card_json=excluded.card_json, note_md=excluded.note_md, lineage_json=excluded.lineage_json, view_oid=excluded.view_oid, spool_file_oid=excluded.spool_file_oid, cost_usd=excluded.cost_usd, total_tokens=excluded.total_tokens, project_id=excluded.project_id, withdrawn_at=NULL, updated_at=excluded.updated_at', ) .bind( row.sid, @@ -89,6 +93,7 @@ export function prepareHubSessionUpsert( row.spoolFileOid, row.costUsd, row.totalTokens, + row.projectId, row.now, row.now, ) @@ -98,6 +103,7 @@ export type AuthorizedHeadWrite = HubSessionUpsert & { actorUserId: string /** The tenant observed before doing any R2 or multi-statement preparation. */ expectedTeamId: string | null + expectedProjectId: string | null expectedVisibility: string expectedWithdrawnAt: number | null expectedRoot: string | null @@ -105,8 +111,10 @@ export type AuthorizedHeadWrite = HubSessionUpsert & { expectedPublished: boolean /** The durable tenant and storage visibility after this commit. */ targetTeamId: string | null + targetProjectId: string targetVisibility: 'unlisted' | 'private' changeAccess: boolean + changeProject: boolean clearWithdrawal: boolean requireTeamManager: boolean } @@ -130,9 +138,9 @@ export function prepareAuthorizedHeadInsert( INSERT INTO hub_sessions (sid, owner_user_id, root, record_count, sig, card_json, note_md, lineage_json, view_oid, spool_file_oid, cost_usd, total_tokens, - visibility, team_id, + visibility, team_id, project_id, withdrawn_at, created_at, updated_at) - SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,?,? + SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,?,? WHERE EXISTS ( SELECT 1 FROM users actor WHERE actor.id=? AND actor.deleted_at IS NULL @@ -147,7 +155,16 @@ export function prepareAuthorizedHeadInsert( AND m.user_id=? AND (?=0 OR m.role IN ('owner','admin')) AND actor_user.deleted_at IS NULL AND actor_user.deletion_pending_until IS NULL - ))`, + )) + AND EXISTS ( + SELECT 1 FROM projects project + WHERE project.id=? AND project.archived_at IS NULL + AND ( + (? IS NULL AND project.owner_user_id=? AND project.owner_team_id IS NULL) + OR + (? IS NOT NULL AND project.owner_team_id=? AND project.owner_user_id IS NULL) + ) + )`, ) .bind( row.sid, @@ -164,6 +181,7 @@ export function prepareAuthorizedHeadInsert( row.totalTokens, row.targetVisibility, row.targetTeamId, + row.targetProjectId, row.now, row.now, row.actorUserId, @@ -171,6 +189,11 @@ export function prepareAuthorizedHeadInsert( row.targetTeamId, row.actorUserId, manager, + row.targetProjectId, + row.targetTeamId, + row.actorUserId, + row.targetTeamId, + row.targetTeamId, ) } @@ -186,6 +209,7 @@ export function prepareAuthorizedHeadUpdate( ): D1PreparedStatement { const manager = row.requireTeamManager ? 1 : 0 const changeAccess = row.changeAccess ? 1 : 0 + const changeProject = row.changeProject ? 1 : 0 const clearWithdrawal = row.clearWithdrawal ? 1 : 0 const expectedPublished = row.expectedPublished ? 1 : 0 @@ -205,6 +229,7 @@ export function prepareAuthorizedHeadUpdate( total_tokens=?, visibility=CASE WHEN ?=1 THEN ? ELSE visibility END, team_id=CASE WHEN ?=1 THEN ? ELSE team_id END, + project_id=CASE WHEN ?=1 THEN ? ELSE project_id END, withdrawn_at=CASE WHEN ?=1 THEN NULL ELSE withdrawn_at END, updated_at=? WHERE sid=? @@ -212,6 +237,7 @@ export function prepareAuthorizedHeadUpdate( AND root=? AND updated_at=? AND team_id IS ? + AND project_id=? AND visibility=? AND withdrawn_at IS ? AND ( @@ -237,7 +263,16 @@ export function prepareAuthorizedHeadUpdate( AND m.user_id=? AND (?=0 OR m.role IN ('owner','admin')) AND actor_user.deleted_at IS NULL AND actor_user.deletion_pending_until IS NULL - ))`, + )) + AND EXISTS ( + SELECT 1 FROM projects project + WHERE project.id=? AND project.archived_at IS NULL + AND ( + (? IS NULL AND project.owner_user_id=? AND project.owner_team_id IS NULL) + OR + (? IS NOT NULL AND project.owner_team_id=? AND project.owner_user_id IS NULL) + ) + )`, ) .bind( row.root, @@ -254,6 +289,8 @@ export function prepareAuthorizedHeadUpdate( row.targetVisibility, changeAccess, row.targetTeamId, + changeProject, + row.targetProjectId, clearWithdrawal, row.now, row.sid, @@ -261,6 +298,7 @@ export function prepareAuthorizedHeadUpdate( row.expectedRoot, row.expectedUpdatedAt, row.expectedTeamId, + row.expectedProjectId, row.expectedVisibility, row.expectedWithdrawnAt, expectedPublished, @@ -270,6 +308,11 @@ export function prepareAuthorizedHeadUpdate( row.targetTeamId, row.actorUserId, manager, + row.targetProjectId, + row.targetTeamId, + row.actorUserId, + row.targetTeamId, + row.targetTeamId, ) } @@ -277,11 +320,13 @@ export type AuthorizedVisibilityUpdate = { sid: string actorUserId: string expectedTeamId: string | null + expectedProjectId: string expectedVisibility: string expectedPublished: boolean expectedRoot: string expectedUpdatedAt: number targetTeamId: string | null + targetProjectId: string targetVisibility: 'unlisted' | 'private' lineageJson: string | null requireTargetManager: boolean @@ -299,9 +344,10 @@ export function prepareAuthorizedVisibilityUpdate( .prepare( `/* hub:authorized-visibility-update */ UPDATE hub_sessions AS session - SET visibility=?, team_id=?, lineage_json=?, updated_at=? + SET visibility=?, team_id=?, project_id=?, lineage_json=?, updated_at=? WHERE session.sid=? AND session.team_id IS ? + AND session.project_id=? AND session.visibility=? AND session.root=? AND session.updated_at=? @@ -348,15 +394,26 @@ export function prepareAuthorizedVisibilityUpdate( AND target_actor.deleted_at IS NULL AND target_actor.deletion_pending_until IS NULL ) + ) + AND EXISTS ( + SELECT 1 FROM projects project + WHERE project.id=? AND project.archived_at IS NULL + AND ( + (? IS NULL AND project.owner_user_id=? AND project.owner_team_id IS NULL) + OR + (? IS NOT NULL AND project.owner_team_id=? AND project.owner_user_id IS NULL) + ) )`, ) .bind( change.targetVisibility, change.targetTeamId, + change.targetProjectId, change.lineageJson, change.now, change.sid, change.expectedTeamId, + change.expectedProjectId, change.expectedVisibility, change.expectedRoot, change.expectedUpdatedAt, @@ -372,6 +429,11 @@ export function prepareAuthorizedVisibilityUpdate( change.targetTeamId, change.actorUserId, requireTargetManager, + change.targetProjectId, + change.targetTeamId, + change.actorUserId, + change.targetTeamId, + change.targetTeamId, ) } diff --git a/apps/backend/src/hub/wire.ts b/apps/backend/src/hub/wire.ts index 763c458a..bc6d7c97 100644 --- a/apps/backend/src/hub/wire.ts +++ b/apps/backend/src/hub/wire.ts @@ -10,6 +10,7 @@ import { ApiError } from '../errors' export const OID_RE = /^[0-9a-f]{64}$/ export const SID_RE = /^(claude|codex|gemini|opencode|pi)_[0-9A-Za-z_-]{8,128}$/ export const TEAM_ID_RE = /^[0-9A-Za-z_-]{8,128}$/ +export const PROJECT_ID_RE = /^project_[0-9A-Za-z_-]{8,192}$/ export const MAX_MANIFEST = 100_000 export const MAX_SUMMARY_BYTES = 64 * 1024 @@ -52,6 +53,14 @@ export const HeadBody = z // Optional optimistic tenant precondition. Explicit null means the caller // observed a personal/new Session; omission keeps older clients working. expectedTeamId: z.string().regex(TEAM_ID_RE).nullable().optional(), + // Project is an independent grouping inside the durable tenant. Older + // clients omit it and are assigned to the tenant's deterministic default; + // explicit null requests that same default without ever persisting NULL. + projectId: z.string().regex(PROJECT_ID_RE).nullable().optional(), + // Optional optimistic Project precondition. A client moving an existing + // Session must send the Project it reviewed so a concurrent move fails + // closed instead of being overwritten by a later head commit. + expectedProjectId: z.string().regex(PROJECT_ID_RE).nullable().optional(), }) .superRefine((body, context) => { if (body.summaryMd === undefined && body.noteMd === undefined) { diff --git a/apps/backend/src/projects/limits.ts b/apps/backend/src/projects/limits.ts new file mode 100644 index 00000000..88d0fba2 --- /dev/null +++ b/apps/backend/src/projects/limits.ts @@ -0,0 +1,20 @@ +export const MAX_ACTIVE_PROJECTS_PER_TENANT = 100 +export const MAX_PROJECTS_PER_TENANT = 1_000 +export const MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR = 10_000 + +export const DEFAULT_PROJECT_LIST_LIMIT = 50 +export const MAX_PROJECT_LIST_LIMIT = 100 + +export const PROJECT_CREATE_RATE = { + bucket: 'project-create', + windowSec: 24 * 60 * 60, + max: 100, +} as const + +export const PROJECT_LIST_RATE = { + bucket: 'project-list', + windowSec: 60, + max: 120, +} as const + +export const MAX_PROJECT_DESCRIPTION_BYTES = 4 * 1024 diff --git a/apps/backend/src/projects/query-sql.ts b/apps/backend/src/projects/query-sql.ts new file mode 100644 index 00000000..99c8fc47 --- /dev/null +++ b/apps/backend/src/projects/query-sql.ts @@ -0,0 +1,66 @@ +/** + * Kept in an import-free module so the exact production query can also be + * executed by the Wrangler-backed schema smoke test. + */ +export const HUB_PROJECTS_LIST_SQL = `/* projects:list-hub-authorized */ +WITH actor AS ( + SELECT id + FROM users + WHERE id=? AND deleted_at IS NULL AND deletion_pending_until IS NULL +) +SELECT p.*, + (SELECT COUNT(*) FROM hub_sessions s + WHERE s.project_id=p.id AND s.withdrawn_at IS NULL) AS session_count, + owner_handle.handle AS owner_handle, + CASE + WHEN p.owner_team_id IS NOT NULL THEN owner_team.name + ELSE COALESCE( + owner_user.display_name, + owner_user.name, + CASE + WHEN instr(owner_user.email,'@')>0 + THEN substr(owner_user.email,1,instr(owner_user.email,'@')-1) + ELSE owner_user.email + END, + owner_handle.handle + ) + END AS owner_name, + owner_user.avatar_url AS owner_avatar_url, + owner_user.custom_avatar_id AS owner_custom_avatar_id, + COALESCE(owner_user.avatar_visible,1) AS owner_avatar_visible, + CASE + WHEN p.owner_user_id=actor.id THEN 1 + WHEN membership.role IN ('owner','admin') THEN 1 + ELSE 0 + END AS can_manage +FROM actor +JOIN projects p ON ( + p.owner_user_id=actor.id + OR EXISTS ( + SELECT 1 + FROM team_memberships current_membership + JOIN teams current_team ON current_team.id=current_membership.team_id + WHERE current_membership.user_id=actor.id + AND current_membership.team_id=p.owner_team_id + AND current_team.archived_at IS NULL + AND current_team.deletion_pending_until IS NULL + ) +) +LEFT JOIN team_memberships membership + ON membership.team_id=p.owner_team_id AND membership.user_id=actor.id +LEFT JOIN users owner_user ON owner_user.id=p.owner_user_id +LEFT JOIN teams owner_team ON owner_team.id=p.owner_team_id +JOIN handles owner_handle ON owner_handle.handle=( + SELECT MIN(candidate.handle) + FROM handles candidate + WHERE candidate.released_at IS NULL + AND candidate.user_id IS p.owner_user_id + AND candidate.team_id IS p.owner_team_id +) +WHERE p.archived_at IS NULL + AND (p.owner_team_id IS NULL + OR (owner_team.archived_at IS NULL + AND owner_team.deletion_pending_until IS NULL)) + AND (?=0 OR p.updated_at?)) +ORDER BY p.updated_at DESC, p.id ASC +LIMIT ?` diff --git a/apps/backend/src/projects/store.ts b/apps/backend/src/projects/store.ts new file mode 100644 index 00000000..51976eea --- /dev/null +++ b/apps/backend/src/projects/store.ts @@ -0,0 +1,1541 @@ +import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types' + +import { base64urlFromBuffer, sha256 } from '../auth/pkce' +import { ApiError } from '../errors' +import { ensureOwnerHandle } from '../handles' +import { + managedSessionJoins, + managedSessionProjection, + type HydratedManagedSessionRow, +} from '../hub/managed-row' +import { resolveDisplayName } from '../profile/display-name' +import { + DEFAULT_PROJECT_LIST_LIMIT, + MAX_ACTIVE_PROJECTS_PER_TENANT, + MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR, + MAX_PROJECT_LIST_LIMIT, + MAX_PROJECTS_PER_TENANT, +} from './limits' +import { HUB_PROJECTS_LIST_SQL } from './query-sql' +import type { ProjectOwner, ProjectResponse, ProjectRow, ProjectTenant } from './types' +import type { CreateProjectInput, UpdateProjectInput } from './validators' + +export type ProjectWithCount = ProjectRow & { session_count: number } + +export type HubProjectRow = ProjectWithCount & { + owner_handle: string + owner_name: string + owner_avatar_url: string | null + owner_custom_avatar_id: string | null + owner_avatar_visible: number + can_manage: number +} + +export type PublicProjectRow = ProjectRow & { + session_count: number + last_session_at: number + owner_handle: string + owner_email: string + owner_name: string | null + owner_display_name: string | null + owner_avatar_url: string | null + owner_custom_avatar_id: string | null + owner_avatar_visible: number +} + +export type PublicProjectCursor = { lastSessionAt: number; id: string } + +export type ProjectListPageOptions = { + after: { updatedAt: number; id: string } | null + fingerprint: string + limit: number +} + +export type ProjectListPage = { + rows: Row[] + nextCursor: string | null +} + +export type ProjectSessionPageOptions = { + after: { sortAt: number; sid: string } | null + fingerprint: string + limit: number +} + +export type ProjectSessionPage = { + rows: Row[] + nextCursor: string | null +} + +const DEFAULT_PROJECT_SESSION_LIMIT = 20 +const MAX_PROJECT_SESSION_LIMIT = 50 +const PROJECT_SESSION_CURSOR_VERSION = 1 +const PROJECT_LIST_CURSOR_VERSION = 1 + +export async function parseProjectListPageOptions( + request: Request, + scope: readonly string[], +): Promise { + const url = new URL(request.url) + if (url.searchParams.getAll('cursor').length > 1 || url.searchParams.getAll('limit').length > 1) { + throw new ApiError('BAD_REQUEST', 'cursor and limit may be provided at most once') + } + const limitValue = url.searchParams.get('limit') + let limit = DEFAULT_PROJECT_LIST_LIMIT + if (limitValue !== null) { + if (!/^\d+$/.test(limitValue)) { + throw new ApiError( + 'BAD_REQUEST', + `limit must be an integer from 1 to ${MAX_PROJECT_LIST_LIMIT}`, + ) + } + limit = Number(limitValue) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PROJECT_LIST_LIMIT) { + throw new ApiError( + 'BAD_REQUEST', + `limit must be an integer from 1 to ${MAX_PROJECT_LIST_LIMIT}`, + ) + } + } + const fingerprint = base64urlFromBuffer(await sha256(JSON.stringify(scope))).slice(0, 16) + const cursor = url.searchParams.get('cursor') + return { + after: cursor === null ? null : decodeProjectListCursor(cursor, fingerprint), + fingerprint, + limit, + } +} + +export function fullTenantProjectListOptions(): ProjectListPageOptions { + return { + after: null, + fingerprint: '', + limit: MAX_ACTIVE_PROJECTS_PER_TENANT, + } +} + +export async function parseProjectSessionPageOptions( + request: Request, + scope: readonly string[], +): Promise { + const url = new URL(request.url) + if (url.searchParams.getAll('cursor').length > 1 || url.searchParams.getAll('limit').length > 1) { + throw new ApiError('BAD_REQUEST', 'cursor and limit may be provided at most once') + } + const limitValue = url.searchParams.get('limit') + let limit = DEFAULT_PROJECT_SESSION_LIMIT + if (limitValue !== null) { + if (!/^\d+$/.test(limitValue)) { + throw new ApiError( + 'BAD_REQUEST', + `limit must be an integer from 1 to ${MAX_PROJECT_SESSION_LIMIT}`, + ) + } + limit = Number(limitValue) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PROJECT_SESSION_LIMIT) { + throw new ApiError( + 'BAD_REQUEST', + `limit must be an integer from 1 to ${MAX_PROJECT_SESSION_LIMIT}`, + ) + } + } + const fingerprint = base64urlFromBuffer(await sha256(JSON.stringify(scope))).slice(0, 16) + const cursor = url.searchParams.get('cursor') + return { + after: cursor === null ? null : decodeProjectSessionCursor(cursor, fingerprint), + fingerprint, + limit, + } +} + +export type ResolvedHandleOwner = + | { + kind: 'user' + id: string + handle: string + name: string + avatar_url: string | null + } + | { + kind: 'team' + id: string + handle: string + name: string + avatar_url: null + } + +export function defaultProjectId(tenant: ProjectTenant): string { + return tenant.teamId === null + ? `project_default_user_${tenant.userId}` + : `project_default_team_${tenant.teamId}` +} + +export function ensureProjectTenantHandle( + db: D1Database, + args: { actorUserId: string; tenant: ProjectTenant; label?: string; now: number }, +): Promise { + return args.tenant.teamId === null + ? ensureOwnerHandle(db, { + actorUserId: args.actorUserId, + userId: args.tenant.userId, + teamId: null, + ...(args.label === undefined ? {} : { label: args.label }), + now: args.now, + }) + : ensureOwnerHandle(db, { + actorUserId: args.actorUserId, + userId: null, + teamId: args.tenant.teamId, + ...(args.label === undefined ? {} : { label: args.label }), + now: args.now, + }) +} + +export async function activeProjectForTenant( + db: D1Database, + projectId: string, + tenant: ProjectTenant, +): Promise { + return db + .prepare( + `SELECT * FROM projects + WHERE id=? AND owner_user_id IS ? AND owner_team_id IS ? + AND archived_at IS NULL`, + ) + .bind(projectId, tenant.userId, tenant.teamId) + .first() +} + +export async function resolveDefaultProject( + db: D1Database, + tenant: ProjectTenant, +): Promise<{ projectId: string; needsInsert: boolean }> { + const id = defaultProjectId(tenant) + const row = await db + .prepare( + `SELECT id FROM projects + WHERE owner_user_id IS ? AND owner_team_id IS ? + AND archived_at IS NULL + AND (id=? OR slug='sessions') + ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END, created_at ASC, id ASC + LIMIT 1`, + ) + .bind(tenant.userId, tenant.teamId, id, id) + .first<{ id: string }>() + return row ? { projectId: row.id, needsInsert: false } : { projectId: id, needsInsert: true } +} + +export function prepareAuthorizedDefaultProjectInsert( + db: D1Database, + args: { actorUserId: string; tenant: ProjectTenant; now: number }, +): D1PreparedStatement { + const id = defaultProjectId(args.tenant) + return db + .prepare( + `/* projects:authorized-default */ + INSERT INTO projects + (id, owner_user_id, owner_team_id, slug, name, description, github_url, + created_by_user_id, created_at, updated_at, archived_at) + SELECT ?,?,?,'sessions','Sessions', + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + NULL,?,?,?,NULL + WHERE EXISTS ( + SELECT 1 FROM users actor + WHERE actor.id=? AND actor.deleted_at IS NULL + AND actor.deletion_pending_until IS NULL + ) + AND ( + (? IS NULL AND ?=?) + OR + (? IS NOT NULL AND EXISTS ( + SELECT 1 + FROM teams t + JOIN team_memberships m ON m.team_id=t.id + WHERE t.id=? AND t.archived_at IS NULL + AND t.deletion_pending_until IS NULL + AND m.user_id=? + )) + ) + AND EXISTS ( + SELECT 1 FROM handles owner_handle + WHERE owner_handle.released_at IS NULL + AND owner_handle.user_id IS ? AND owner_handle.team_id IS ? + ) + AND ( + SELECT COUNT(*) + FROM projects active_project + WHERE active_project.owner_user_id IS ? + AND active_project.owner_team_id IS ? + AND active_project.archived_at IS NULL + ) < ? + AND ( + SELECT COUNT(*) + FROM projects tenant_project + WHERE tenant_project.owner_user_id IS ? + AND tenant_project.owner_team_id IS ? + ) < ? + ON CONFLICT(id) DO NOTHING`, + ) + .bind( + id, + args.tenant.userId, + args.tenant.teamId, + args.actorUserId, + args.now, + args.now, + args.actorUserId, + args.tenant.teamId, + args.tenant.userId, + args.actorUserId, + args.tenant.teamId, + args.tenant.teamId, + args.actorUserId, + args.tenant.userId, + args.tenant.teamId, + args.tenant.userId, + args.tenant.teamId, + MAX_ACTIVE_PROJECTS_PER_TENANT, + args.tenant.userId, + args.tenant.teamId, + MAX_PROJECTS_PER_TENANT, + ) +} + +export async function getProjectById( + db: D1Database, + projectId: string, + options: { includeArchived?: boolean } = {}, +): Promise { + return db + .prepare( + `SELECT * FROM projects WHERE id=?${options.includeArchived ? '' : ' AND archived_at IS NULL'}`, + ) + .bind(projectId) + .first() +} + +export async function getPersonalProject( + db: D1Database, + projectId: string, + userId: string, +): Promise { + return db + .prepare( + `SELECT * FROM projects + WHERE id=? AND owner_user_id=? AND owner_team_id IS NULL + AND archived_at IS NULL`, + ) + .bind(projectId, userId) + .first() +} + +export async function getTeamProject( + db: D1Database, + projectId: string, + teamId: string, +): Promise { + return db + .prepare( + `SELECT * FROM projects + WHERE id=? AND owner_team_id=? AND owner_user_id IS NULL + AND archived_at IS NULL`, + ) + .bind(projectId, teamId) + .first() +} + +export async function getProjectBySlugForOwner( + db: D1Database, + owner: ResolvedHandleOwner, + slug: string, +): Promise { + const column = owner.kind === 'user' ? 'owner_user_id' : 'owner_team_id' + return db + .prepare(`SELECT * FROM projects WHERE ${column}=? AND slug=? AND archived_at IS NULL`) + .bind(owner.id, slug) + .first() +} + +export async function listPersonalProjects( + db: D1Database, + userId: string, + options: ProjectListPageOptions, +): Promise> { + const rows = await db + .prepare( + `SELECT p.*, + (SELECT COUNT(*) FROM hub_sessions s + WHERE s.project_id=p.id AND s.withdrawn_at IS NULL) AS session_count + FROM projects p + JOIN users owner ON owner.id=p.owner_user_id + WHERE p.owner_user_id=? AND p.owner_team_id IS NULL + AND p.archived_at IS NULL + AND owner.deleted_at IS NULL AND owner.deletion_pending_until IS NULL + AND ( + ?=0 OR p.updated_at?) + ) + ORDER BY p.updated_at DESC, p.id ASC + LIMIT ?`, + ) + .bind( + userId, + options.after === null ? 0 : 1, + options.after?.updatedAt ?? 0, + options.after?.updatedAt ?? 0, + options.after?.id ?? '', + options.limit + 1, + ) + .all() + return finishProjectListPage(rows.results, options) +} + +export async function listHubProjectsForUser( + db: D1Database, + userId: string, + options: ProjectListPageOptions, +): Promise> { + const rows = await db + .prepare(HUB_PROJECTS_LIST_SQL) + .bind( + userId, + options.after === null ? 0 : 1, + options.after?.updatedAt ?? 0, + options.after?.updatedAt ?? 0, + options.after?.id ?? '', + options.limit + 1, + ) + .all() + return finishProjectListPage(rows.results, options) +} + +export function serializeHubProject(row: HubProjectRow): ProjectResponse { + const ownerUserId = row.owner_user_id + const ownerTeamId = row.owner_team_id + return { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + github_url: row.github_url, + owner: { + kind: ownerTeamId === null ? 'user' : 'team', + id: ownerTeamId ?? ownerUserId!, + handle: row.owner_handle, + name: row.owner_name, + avatar_url: + ownerUserId === null + ? null + : visibleUserAvatarUrl({ + userId: ownerUserId, + avatarUrl: row.owner_avatar_url, + customAvatarId: row.owner_custom_avatar_id, + avatarVisible: row.owner_avatar_visible, + }), + }, + created_at: row.created_at, + updated_at: row.updated_at, + archived_at: row.archived_at, + session_count: Math.max(0, Number(row.session_count)), + can_manage: row.can_manage === 1, + } +} + +export async function listTeamProjects( + db: D1Database, + teamId: string, + actorUserId: string, + options: ProjectListPageOptions, +): Promise | null> { + const rows = await db + .prepare( + `/* projects:list-team-authorized */ + WITH current_team AS ( + SELECT t.id + FROM teams t + JOIN team_memberships m ON m.team_id=t.id + JOIN users actor ON actor.id=m.user_id + WHERE t.id=? AND m.user_id=? + AND t.archived_at IS NULL AND t.deletion_pending_until IS NULL + AND actor.deleted_at IS NULL AND actor.deletion_pending_until IS NULL + ) + SELECT p.*, + (SELECT COUNT(*) FROM hub_sessions s + WHERE s.project_id=p.id AND s.withdrawn_at IS NULL) AS session_count + FROM current_team + LEFT JOIN projects p + ON p.owner_team_id=current_team.id AND p.owner_user_id IS NULL + AND p.archived_at IS NULL + AND ( + ?=0 OR p.updated_at?) + ) + ORDER BY p.updated_at DESC, p.id ASC + LIMIT ?`, + ) + .bind( + teamId, + actorUserId, + options.after === null ? 0 : 1, + options.after?.updatedAt ?? 0, + options.after?.updatedAt ?? 0, + options.after?.id ?? '', + options.limit + 1, + ) + .all< + { [K in keyof ProjectRow]: ProjectRow[K] | null } & { + session_count: number | null + } + >() + if (rows.results.length === 0) return null + return finishProjectListPage( + rows.results.filter((row) => row.id !== null).map((row) => row as ProjectWithCount), + options, + ) +} + +export async function resolveHandleOwner( + db: D1Database, + handle: string, +): Promise { + const row = await db + .prepare( + `SELECT h.handle, h.user_id, h.team_id, + CASE + WHEN h.user_id IS NOT NULL THEN NULL + ELSE t.name + END AS owner_name, + u.email AS owner_email, + u.name AS owner_provider_name, + u.display_name AS owner_display_name, + u.avatar_url AS owner_avatar_url, + u.custom_avatar_id AS owner_custom_avatar_id, + u.avatar_visible AS owner_avatar_visible + FROM handles h + LEFT JOIN users u ON u.id=h.user_id + AND u.deleted_at IS NULL AND u.deletion_pending_until IS NULL + LEFT JOIN teams t ON t.id=h.team_id + AND t.archived_at IS NULL AND t.deletion_pending_until IS NULL + WHERE h.handle=? AND h.released_at IS NULL + AND ((h.user_id IS NOT NULL AND u.id IS NOT NULL) + OR (h.team_id IS NOT NULL AND t.id IS NOT NULL))`, + ) + .bind(handle) + .first<{ + handle: string + user_id: string | null + team_id: string | null + owner_name: string | null + owner_email: string | null + owner_provider_name: string | null + owner_display_name: string | null + owner_avatar_url: string | null + owner_custom_avatar_id: string | null + owner_avatar_visible: number | null + }>() + if (!row) return null + if (row.team_id !== null) { + return { + kind: 'team', + id: row.team_id, + handle: row.handle, + name: row.owner_name ?? row.handle, + avatar_url: null, + } + } + if (row.user_id === null || row.owner_email === null) return null + return { + kind: 'user', + id: row.user_id, + handle: row.handle, + name: resolveDisplayName({ + display_name: row.owner_display_name, + name: row.owner_provider_name, + email: row.owner_email, + }), + avatar_url: visibleUserAvatarUrl({ + userId: row.user_id, + avatarUrl: row.owner_avatar_url, + customAvatarId: row.owner_custom_avatar_id, + avatarVisible: row.owner_avatar_visible, + }), + } +} + +export async function projectOwner(db: D1Database, row: ProjectRow): Promise { + if (row.owner_team_id !== null) { + const owner = await db + .prepare( + `SELECT t.name, + (SELECT handle FROM handles + WHERE team_id=t.id AND released_at IS NULL LIMIT 1) AS handle + FROM teams t + WHERE t.id=? AND t.archived_at IS NULL AND t.deletion_pending_until IS NULL`, + ) + .bind(row.owner_team_id) + .first<{ name: string; handle: string | null }>() + if (!owner || owner.handle === null) + throw new ApiError('INTERNAL', 'Project owner handle missing') + return { + kind: 'team', + id: row.owner_team_id, + handle: owner.handle, + name: owner.name, + avatar_url: null, + } + } + + const owner = await db + .prepare( + `SELECT u.email, u.name, u.display_name, u.avatar_url, + u.custom_avatar_id, u.avatar_visible, + (SELECT handle FROM handles + WHERE user_id=u.id AND released_at IS NULL LIMIT 1) AS handle + FROM users u + WHERE u.id=? AND u.deleted_at IS NULL + AND u.deletion_pending_until IS NULL`, + ) + .bind(row.owner_user_id) + .first<{ + email: string + name: string | null + display_name: string | null + avatar_url: string | null + custom_avatar_id: string | null + avatar_visible: number | null + handle: string | null + }>() + if (!owner || owner.handle === null || row.owner_user_id === null) { + throw new ApiError('INTERNAL', 'Project owner handle missing') + } + return { + kind: 'user', + id: row.owner_user_id, + handle: owner.handle, + name: resolveDisplayName(owner), + avatar_url: visibleUserAvatarUrl({ + userId: row.owner_user_id, + avatarUrl: owner.avatar_url, + customAvatarId: owner.custom_avatar_id, + avatarVisible: owner.avatar_visible, + }), + } +} + +function visibleUserAvatarUrl(args: { + userId: string + avatarUrl: string | null + customAvatarId: string | null + avatarVisible: number | null +}): string | null { + if ((args.avatarVisible ?? 1) !== 1) return null + return args.customAvatarId + ? `/api/avatars/${encodeURIComponent(args.userId)}?v=${encodeURIComponent(args.customAvatarId)}` + : args.avatarUrl +} + +export async function serializeProject( + db: D1Database, + row: ProjectRow & { session_count?: number }, + options: { canManage?: boolean } = {}, +): Promise { + const sessionCount = + row.session_count === undefined + ? await db + .prepare( + `SELECT COUNT(*) AS count + FROM hub_sessions + WHERE project_id=? AND withdrawn_at IS NULL`, + ) + .bind(row.id) + .first<{ count: number }>() + : { count: row.session_count } + return serializeProjectWithOwner( + { + ...row, + session_count: Math.max(0, Number(sessionCount?.count ?? 0)), + }, + await projectOwner(db, row), + options, + ) +} + +export function serializeProjectWithOwner( + row: ProjectWithCount, + owner: ProjectOwner, + options: { canManage?: boolean } = {}, +): ProjectResponse { + return { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + github_url: row.github_url, + owner, + created_at: row.created_at, + updated_at: row.updated_at, + archived_at: row.archived_at, + session_count: Math.max(0, Number(row.session_count)), + can_manage: options.canManage ?? false, + } +} + +export function serializePublicProject(row: PublicProjectRow): ProjectResponse { + if (row.owner_user_id === null) { + throw new ApiError('INTERNAL', 'Public Project owner missing') + } + return serializeProjectWithOwner( + row, + { + kind: 'user', + id: row.owner_user_id, + handle: row.owner_handle, + name: resolveDisplayName({ + email: row.owner_email, + name: row.owner_name, + display_name: row.owner_display_name, + }), + avatar_url: visibleUserAvatarUrl({ + userId: row.owner_user_id, + avatarUrl: row.owner_avatar_url, + customAvatarId: row.owner_custom_avatar_id, + avatarVisible: row.owner_avatar_visible, + }), + }, + { canManage: false }, + ) +} + +export async function createProject( + db: D1Database, + args: { + id: string + actorUserId: string + tenant: ProjectTenant + input: CreateProjectInput + now: number + }, +): Promise { + const result = await prepareAuthorizedProjectInsert(db, args).run() + if ((result.meta.changes ?? 0) === 0) { + if ((await countActiveProjectsForTenant(db, args.tenant)) >= MAX_ACTIVE_PROJECTS_PER_TENANT) { + throw new ApiError( + 'CONFLICT', + `active Project limit reached (${MAX_ACTIVE_PROJECTS_PER_TENANT})`, + ) + } + if ((await countProjectsForTenant(db, args.tenant)) >= MAX_PROJECTS_PER_TENANT) { + throw new ApiError('CONFLICT', `Project history limit reached (${MAX_PROJECTS_PER_TENANT})`) + } + throw new ApiError('NOT_FOUND') + } + const created = await getProjectById(db, args.id) + if (!created) throw new ApiError('INTERNAL', 'Project create failed') + return created +} + +export async function createProjectIdempotently( + db: D1Database, + args: { + actorUserId: string + tenant: ProjectTenant + input: CreateProjectInput + idempotencyKey: string + now: number + }, +): Promise<{ project: ProjectRow; replayed: boolean }> { + const tenantKey = + args.tenant.teamId === null ? `user:${args.tenant.userId}` : `team:${args.tenant.teamId}` + const requestHash = await projectCreateHash(JSON.stringify({ input: args.input, tenantKey })) + const projectId = `project_${( + await projectCreateHash(JSON.stringify([args.actorUserId, tenantKey, args.idempotencyKey])) + ).slice(0, 32)}` + const priorRequest = await getProjectCreationRequest( + db, + args.actorUserId, + tenantKey, + args.idempotencyKey, + ) + if (priorRequest) { + if (priorRequest.request_hash !== requestHash) { + throw new ApiError('CONFLICT', 'Idempotency-Key was already used for another Project') + } + const priorProject = await getProjectById(db, priorRequest.project_id, { + includeArchived: true, + }) + if (!priorProject) throw new ApiError('INTERNAL', 'Project create receipt is incomplete') + return { project: priorProject, replayed: true } + } + + const existing = await getProjectById(db, projectId, { includeArchived: true }) + if (existing) { + if ( + existing.owner_user_id !== args.tenant.userId || + existing.owner_team_id !== args.tenant.teamId || + existing.slug !== args.input.slug || + existing.name !== args.input.name || + existing.description !== args.input.description || + existing.github_url !== args.input.github_url + ) { + throw new ApiError('CONFLICT', 'Idempotency-Key was already used for another Project') + } + return { project: existing, replayed: true } + } + + try { + const results = await db.batch([ + prepareAuthorizedProjectInsert(db, { + id: projectId, + actorUserId: args.actorUserId, + tenant: args.tenant, + input: args.input, + now: args.now, + receiptActorUserId: args.actorUserId, + }), + db + .prepare( + `/* projects:record-idempotent-create */ + INSERT INTO project_creation_requests + (actor_user_id, owner_scope, owner_user_id, owner_team_id, + idempotency_key, project_id, request_hash, created_at) + SELECT ?,?,?,?,?,?,?,? + WHERE EXISTS ( + SELECT 1 FROM projects project + WHERE project.id=? AND project.owner_user_id IS ? + AND project.owner_team_id IS ? + ) + AND ( + SELECT COUNT(*) + FROM project_creation_requests actor_receipt + WHERE actor_receipt.actor_user_id=? + ) < ?`, + ) + .bind( + args.actorUserId, + tenantKey, + args.tenant.userId, + args.tenant.teamId, + args.idempotencyKey, + projectId, + requestHash, + args.now, + projectId, + args.tenant.userId, + args.tenant.teamId, + args.actorUserId, + MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR, + ), + ]) + if ((results[0]?.meta.changes ?? 0) === 0 || (results[1]?.meta.changes ?? 0) === 0) { + if ((await countActiveProjectsForTenant(db, args.tenant)) >= MAX_ACTIVE_PROJECTS_PER_TENANT) { + throw new ApiError( + 'CONFLICT', + `active Project limit reached (${MAX_ACTIVE_PROJECTS_PER_TENANT})`, + ) + } + if ((await countProjectsForTenant(db, args.tenant)) >= MAX_PROJECTS_PER_TENANT) { + throw new ApiError('CONFLICT', `Project history limit reached (${MAX_PROJECTS_PER_TENANT})`) + } + if ( + (await countProjectCreationReceiptsForActor(db, args.actorUserId)) >= + MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR + ) { + throw new ApiError( + 'CONFLICT', + `Project creation receipt limit reached (${MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR})`, + ) + } + throw new ApiError('NOT_FOUND') + } + } catch (error) { + // D1 batches are atomic. A concurrent identical request can win both the + // Project and receipt constraints; recover only after reading the durable + // receipt, never from a half-written Project. + const won = await getProjectCreationRequest( + db, + args.actorUserId, + tenantKey, + args.idempotencyKey, + ) + if (won?.request_hash === requestHash) { + const wonProject = await getProjectById(db, won.project_id, { + includeArchived: true, + }) + if (wonProject) return { project: wonProject, replayed: true } + } + if (won) { + throw new ApiError('CONFLICT', 'Idempotency-Key was already used for another Project') + } + if (isProjectConstraintError(error)) { + throw new ApiError('CONFLICT', 'Project slug is already in use') + } + throw error + } + + const created = await getProjectById(db, projectId) + if (!created) throw new ApiError('INTERNAL', 'Project create failed') + return { project: created, replayed: false } +} + +type ProjectCreationRequest = { project_id: string; request_hash: string } + +async function getProjectCreationRequest( + db: D1Database, + actorUserId: string, + ownerScope: string, + idempotencyKey: string, +): Promise { + return db + .prepare( + `SELECT project_id, request_hash + FROM project_creation_requests + WHERE actor_user_id=? AND owner_scope=? AND idempotency_key=?`, + ) + .bind(actorUserId, ownerScope, idempotencyKey) + .first() +} + +async function projectCreateHash(value: string): Promise { + const digest = new Uint8Array(await sha256(value)) + return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function prepareAuthorizedProjectInsert( + db: D1Database, + args: { + id: string + actorUserId: string + tenant: ProjectTenant + input: CreateProjectInput + now: number + receiptActorUserId?: string + }, +): D1PreparedStatement { + const teamId = args.tenant.teamId + const ownerUserId = args.tenant.userId + return db + .prepare( + `/* projects:authorized-create */ + INSERT INTO projects + (id, owner_user_id, owner_team_id, slug, name, description, github_url, + created_by_user_id, created_at, updated_at, archived_at) + SELECT ?,?,?,?,?,?,?,?,?,?,NULL + WHERE EXISTS ( + SELECT 1 FROM users actor + WHERE actor.id=? AND actor.deleted_at IS NULL + AND actor.deletion_pending_until IS NULL + ) + AND ( + (? IS NULL AND ?=?) + OR + (? IS NOT NULL AND EXISTS ( + SELECT 1 + FROM teams t + JOIN team_memberships m ON m.team_id=t.id + WHERE t.id=? AND t.archived_at IS NULL + AND t.deletion_pending_until IS NULL + AND m.user_id=? AND m.role IN ('owner','admin') + )) + ) + AND EXISTS ( + SELECT 1 FROM handles owner_handle + WHERE owner_handle.released_at IS NULL + AND owner_handle.user_id IS ? AND owner_handle.team_id IS ? + ) + AND ( + SELECT COUNT(*) + FROM projects active_project + WHERE active_project.owner_user_id IS ? + AND active_project.owner_team_id IS ? + AND active_project.archived_at IS NULL + ) < ? + AND ( + SELECT COUNT(*) + FROM projects tenant_project + WHERE tenant_project.owner_user_id IS ? + AND tenant_project.owner_team_id IS ? + ) < ? + AND ( + ? IS NULL OR ( + SELECT COUNT(*) + FROM project_creation_requests actor_receipt + WHERE actor_receipt.actor_user_id=? + ) < ? + )`, + ) + .bind( + args.id, + ownerUserId, + teamId, + args.input.slug, + args.input.name, + args.input.description, + args.input.github_url, + args.actorUserId, + args.now, + args.now, + args.actorUserId, + teamId, + ownerUserId, + args.actorUserId, + teamId, + teamId, + args.actorUserId, + ownerUserId, + teamId, + ownerUserId, + teamId, + MAX_ACTIVE_PROJECTS_PER_TENANT, + ownerUserId, + teamId, + MAX_PROJECTS_PER_TENANT, + args.receiptActorUserId ?? null, + args.receiptActorUserId ?? '', + MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR, + ) +} + +export async function countActiveProjectsForTenant( + db: D1Database, + tenant: ProjectTenant, +): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS count + FROM projects + WHERE owner_user_id IS ? AND owner_team_id IS ? + AND archived_at IS NULL`, + ) + .bind(tenant.userId, tenant.teamId) + .first<{ count: number }>() + return Math.max(0, Number(row?.count ?? 0)) +} + +export async function countProjectsForTenant( + db: D1Database, + tenant: ProjectTenant, +): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS count + FROM projects + WHERE owner_user_id IS ? AND owner_team_id IS ?`, + ) + .bind(tenant.userId, tenant.teamId) + .first<{ count: number }>() + return Math.max(0, Number(row?.count ?? 0)) +} + +export async function countProjectCreationReceiptsForActor( + db: D1Database, + actorUserId: string, +): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS count + FROM project_creation_requests + WHERE actor_user_id=?`, + ) + .bind(actorUserId) + .first<{ count: number }>() + return Math.max(0, Number(row?.count ?? 0)) +} + +export async function updateProject( + db: D1Database, + args: { + projectId: string + actorUserId: string + tenant: ProjectTenant + input: UpdateProjectInput + now: number + }, +): Promise { + const input = args.input + const result = await db + .prepare( + `/* projects:authorized-update */ + UPDATE projects + SET name=CASE WHEN ?=1 THEN ? ELSE name END, + slug=CASE WHEN ?=1 THEN ? ELSE slug END, + description=CASE WHEN ?=1 THEN ? ELSE description END, + github_url=CASE WHEN ?=1 THEN ? ELSE github_url END, + archived_at=CASE WHEN ?=1 THEN CASE WHEN ?=1 THEN ? ELSE NULL END + ELSE archived_at END, + updated_at=? + WHERE id=? + AND owner_user_id IS ? AND owner_team_id IS ? + AND (?=0 OR updated_at=?) + AND ( + ?=0 + OR NOT EXISTS ( + SELECT 1 FROM hub_sessions linked_session + WHERE linked_session.project_id=projects.id + AND linked_session.withdrawn_at IS NULL + ) + ) + AND ( + ?=0 OR NOT (projects.id=? OR projects.slug='sessions') + ) + AND EXISTS ( + SELECT 1 FROM users actor + WHERE actor.id=? AND actor.deleted_at IS NULL + AND actor.deletion_pending_until IS NULL + ) + AND ( + (? IS NULL AND owner_user_id=?) + OR + (? IS NOT NULL AND EXISTS ( + SELECT 1 + FROM teams t + JOIN team_memberships m ON m.team_id=t.id + WHERE t.id=? AND t.archived_at IS NULL + AND t.deletion_pending_until IS NULL + AND m.user_id=? AND m.role IN ('owner','admin') + )) + )`, + ) + .bind( + input.name === undefined ? 0 : 1, + input.name ?? '', + input.slug === undefined ? 0 : 1, + input.slug ?? '', + input.description === undefined ? 0 : 1, + input.description ?? null, + input.github_url === undefined ? 0 : 1, + input.github_url ?? null, + input.archived === undefined ? 0 : 1, + input.archived ? 1 : 0, + args.now, + args.now, + args.projectId, + args.tenant.userId, + args.tenant.teamId, + input.expected_updated_at === undefined ? 0 : 1, + input.expected_updated_at ?? 0, + input.archived === true ? 1 : 0, + input.archived === true ? 1 : 0, + defaultProjectId(args.tenant), + args.actorUserId, + args.tenant.teamId, + args.actorUserId, + args.tenant.teamId, + args.tenant.teamId, + args.actorUserId, + ) + .run() + if ((result.meta.changes ?? 0) === 0) { + if (input.archived === true) { + const fallbackProject = await db + .prepare( + `SELECT 1 FROM projects + WHERE id=? AND owner_user_id IS ? AND owner_team_id IS ? + AND archived_at IS NULL + AND (id=? OR slug='sessions') + LIMIT 1`, + ) + .bind(args.projectId, args.tenant.userId, args.tenant.teamId, defaultProjectId(args.tenant)) + .first() + if (fallbackProject) { + throw new ApiError('CONFLICT', 'Default Project cannot be archived') + } + const linkedSession = await db + .prepare('SELECT 1 FROM hub_sessions WHERE project_id=? AND withdrawn_at IS NULL LIMIT 1') + .bind(args.projectId) + .first() + if (linkedSession) { + throw new ApiError('CONFLICT', 'Move its Sessions before archiving') + } + } + throw new ApiError('CONFLICT', 'Project changed') + } + const updated = await getProjectById(db, args.projectId, { includeArchived: true }) + if (!updated) throw new ApiError('NOT_FOUND') + return updated +} + +export async function listProjectSessions( + db: D1Database, + projectId: string, + scope: { kind: 'personal'; userId: string } | { kind: 'team'; teamId: string; userId: string }, + options: ProjectSessionPageOptions, +): Promise< + ProjectSessionPage< + HydratedManagedSessionRow & { + team_name: string | null + star_count: number + project_sort_at: number + } + > +> { + const teamId = scope.kind === 'team' ? scope.teamId : null + const rows = await db + .prepare( + `/* projects:list-sessions-authorized */ + SELECT s.*, s.updated_at AS project_sort_at, t.name AS team_name, + (SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count, + ${managedSessionProjection()} + FROM projects p + JOIN hub_sessions s ON s.project_id=p.id + ${managedSessionJoins('s')} + LEFT JOIN teams t ON t.id=s.team_id + WHERE p.id=? AND p.archived_at IS NULL + AND s.withdrawn_at IS NULL + AND p.owner_user_id IS ? AND p.owner_team_id IS ? + AND ( + (? IS NULL AND p.owner_user_id=?) + OR + (? IS NOT NULL AND EXISTS ( + SELECT 1 FROM teams gated_team + JOIN team_memberships member ON member.team_id=gated_team.id + WHERE gated_team.id=? AND gated_team.archived_at IS NULL + AND gated_team.deletion_pending_until IS NULL + AND member.user_id=? + )) + ) + AND ( + ?=0 OR s.updated_at?) + ) + ORDER BY s.updated_at DESC, s.sid ASC + LIMIT ?`, + ) + .bind( + projectId, + scope.kind === 'personal' ? scope.userId : null, + teamId, + teamId, + scope.userId, + teamId, + teamId, + scope.userId, + options.after === null ? 0 : 1, + options.after?.sortAt ?? 0, + options.after?.sortAt ?? 0, + options.after?.sid ?? '', + options.limit + 1, + ) + .all< + HydratedManagedSessionRow & { + team_name: string | null + star_count: number + project_sort_at: number + } + >() + return finishProjectSessionPage(rows.results, options) +} + +export async function listPublicProjectSessions( + db: D1Database, + projectId: string, + ownerUserId: string, + options: ProjectSessionPageOptions, +): Promise< + ProjectSessionPage< + HydratedManagedSessionRow & { + team_name: null + star_count: number + project_sort_at: number + } + > +> { + const rows = await db + .prepare( + `/* projects:list-public-sessions */ + SELECT s.*, d.published_at AS project_sort_at, NULL AS team_name, + (SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count, + ${managedSessionProjection()} + FROM projects p + JOIN users owner ON owner.id=p.owner_user_id + JOIN hub_sessions s ON s.project_id=p.id + ${managedSessionJoins('s')} + JOIN hub_session_discovery d ON d.sid=s.sid + WHERE p.id=? AND p.owner_user_id=? AND p.owner_team_id IS NULL + AND p.archived_at IS NULL AND owner.deleted_at IS NULL + AND s.team_id IS NULL AND s.visibility='unlisted' + AND s.withdrawn_at IS NULL + AND ( + ?=0 OR d.published_at?) + ) + ORDER BY d.published_at DESC, s.sid ASC + LIMIT ?`, + ) + .bind( + projectId, + ownerUserId, + options.after === null ? 0 : 1, + options.after?.sortAt ?? 0, + options.after?.sortAt ?? 0, + options.after?.sid ?? '', + options.limit + 1, + ) + .all< + HydratedManagedSessionRow & { + team_name: null + star_count: number + project_sort_at: number + } + >() + return finishProjectSessionPage(rows.results, options) +} + +export async function listPublicSessionsForUser( + db: D1Database, + ownerUserId: string, + options: ProjectSessionPageOptions, +): Promise< + ProjectSessionPage< + HydratedManagedSessionRow & { + team_name: null + star_count: number + project_sort_at: number + } + > +> { + const rows = await db + .prepare( + `/* projects:list-public-owner-sessions */ + SELECT s.*, d.published_at AS project_sort_at, NULL AS team_name, + (SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count, + ${managedSessionProjection()} + FROM hub_sessions s + ${managedSessionJoins('s')} + JOIN projects p ON p.id=s.project_id + JOIN hub_session_discovery d ON d.sid=s.sid + JOIN users owner ON owner.id=s.owner_user_id + WHERE s.owner_user_id=? AND s.team_id IS NULL + AND s.visibility='unlisted' AND s.withdrawn_at IS NULL + AND p.owner_user_id=s.owner_user_id AND p.owner_team_id IS NULL + AND p.archived_at IS NULL + AND owner.deleted_at IS NULL AND owner.deletion_pending_until IS NULL + AND ( + ?=0 OR d.published_at?) + ) + ORDER BY d.published_at DESC, s.sid ASC + LIMIT ?`, + ) + .bind( + ownerUserId, + options.after === null ? 0 : 1, + options.after?.sortAt ?? 0, + options.after?.sortAt ?? 0, + options.after?.sid ?? '', + options.limit + 1, + ) + .all< + HydratedManagedSessionRow & { + team_name: null + star_count: number + project_sort_at: number + } + >() + return finishProjectSessionPage(rows.results, options) +} + +export async function listPublicProjects( + db: D1Database, + args: { after: PublicProjectCursor | null; limit: number }, +): Promise { + const rows = await db + .prepare( + `/* projects:list-public */ + WITH public_projects AS ( + SELECT p.*, + COUNT(*) AS session_count, + MAX(d.published_at) AS last_session_at, + h.handle AS owner_handle, + u.email AS owner_email, + u.name AS owner_name, + u.display_name AS owner_display_name, + u.avatar_url AS owner_avatar_url, + u.custom_avatar_id AS owner_custom_avatar_id, + COALESCE(u.avatar_visible,1) AS owner_avatar_visible + FROM projects p + JOIN users u ON u.id=p.owner_user_id AND u.deleted_at IS NULL + JOIN handles h ON h.user_id=p.owner_user_id AND h.released_at IS NULL + JOIN hub_sessions s ON s.project_id=p.id + AND s.owner_user_id=p.owner_user_id + AND s.team_id IS NULL + AND s.visibility='unlisted' + AND s.withdrawn_at IS NULL + JOIN hub_session_discovery d ON d.sid=s.sid + WHERE p.owner_user_id IS NOT NULL AND p.owner_team_id IS NULL + AND p.archived_at IS NULL + GROUP BY p.id + ) + SELECT * FROM public_projects + WHERE (?=0 OR last_session_at?)) + ORDER BY last_session_at DESC, id ASC + LIMIT ?`, + ) + .bind( + args.after === null ? 0 : 1, + args.after?.lastSessionAt ?? 0, + args.after?.lastSessionAt ?? 0, + args.after?.id ?? '', + args.limit + 1, + ) + .all() + return rows.results +} + +export async function listPublicProjectsForUser( + db: D1Database, + userId: string, +): Promise { + const rows = await db + .prepare( + `SELECT p.*, COUNT(d.sid) AS session_count + FROM projects p + JOIN hub_sessions s ON s.project_id=p.id + JOIN hub_session_discovery d ON d.sid=s.sid + JOIN users owner ON owner.id=p.owner_user_id + WHERE p.owner_user_id=? AND p.owner_team_id IS NULL + AND p.archived_at IS NULL AND owner.deleted_at IS NULL + AND s.team_id IS NULL AND s.visibility='unlisted' + AND s.withdrawn_at IS NULL + GROUP BY p.id + ORDER BY MAX(d.published_at) DESC, p.id ASC + LIMIT ?`, + ) + .bind(userId, MAX_ACTIVE_PROJECTS_PER_TENANT + 1) + .all() + return rows.results +} + +export function isProjectConstraintError(error: unknown): boolean { + return ( + error instanceof Error && + /(UNIQUE constraint failed: projects|project tenant mismatch|project must be active)/i.test( + error.message, + ) + ) +} + +function finishProjectListPage( + rows: Row[], + options: ProjectListPageOptions, +): ProjectListPage { + const hasMore = rows.length > options.limit + const page = hasMore ? rows.slice(0, options.limit) : rows + const last = page.at(-1) + return { + rows: page, + nextCursor: + hasMore && last + ? encodeProjectListCursor({ updatedAt: last.updated_at, id: last.id }, options.fingerprint) + : null, + } +} + +function encodeProjectListCursor( + key: { updatedAt: number; id: string }, + fingerprint: string, +): string { + const bytes = new TextEncoder().encode( + JSON.stringify({ + v: PROJECT_LIST_CURSOR_VERSION, + f: fingerprint, + u: key.updatedAt, + i: key.id, + }), + ) + const buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return base64urlFromBuffer(buffer) +} + +function decodeProjectListCursor( + value: string, + fingerprint: string, +): { updatedAt: number; id: string } { + if (value.length > 512 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new ApiError('BAD_REQUEST', 'malformed cursor') + } + try { + const standard = value.replaceAll('-', '+').replaceAll('_', '/') + const binary = atob(standard.padEnd(Math.ceil(standard.length / 4) * 4, '=')) + const parsed = JSON.parse( + new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))), + ) as Record + if ( + parsed['v'] !== PROJECT_LIST_CURSOR_VERSION || + parsed['f'] !== fingerprint || + typeof parsed['u'] !== 'number' || + !Number.isSafeInteger(parsed['u']) || + parsed['u'] < 0 || + typeof parsed['i'] !== 'string' || + parsed['i'].length < 1 || + parsed['i'].length > 192 + ) { + throw new Error('invalid') + } + return { updatedAt: parsed['u'], id: parsed['i'] } + } catch { + throw new ApiError('BAD_REQUEST', 'malformed or mismatched cursor') + } +} + +function finishProjectSessionPage( + rows: Row[], + options: ProjectSessionPageOptions, +): ProjectSessionPage { + const hasMore = rows.length > options.limit + const page = hasMore ? rows.slice(0, options.limit) : rows + const last = page.at(-1) + return { + rows: page, + nextCursor: + hasMore && last + ? encodeProjectSessionCursor( + { sortAt: last.project_sort_at, sid: last.sid }, + options.fingerprint, + ) + : null, + } +} + +function encodeProjectSessionCursor( + key: { sortAt: number; sid: string }, + fingerprint: string, +): string { + const bytes = new TextEncoder().encode( + JSON.stringify({ + v: PROJECT_SESSION_CURSOR_VERSION, + f: fingerprint, + s: key.sortAt, + i: key.sid, + }), + ) + const buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return base64urlFromBuffer(buffer) +} + +function decodeProjectSessionCursor( + value: string, + fingerprint: string, +): { sortAt: number; sid: string } { + if (value.length > 512 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new ApiError('BAD_REQUEST', 'malformed cursor') + } + try { + const standard = value.replaceAll('-', '+').replaceAll('_', '/') + const binary = atob(standard.padEnd(Math.ceil(standard.length / 4) * 4, '=')) + const parsed = JSON.parse( + new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))), + ) as Record + if ( + parsed['v'] !== PROJECT_SESSION_CURSOR_VERSION || + parsed['f'] !== fingerprint || + typeof parsed['s'] !== 'number' || + !Number.isSafeInteger(parsed['s']) || + parsed['s'] < 0 || + typeof parsed['i'] !== 'string' || + parsed['i'].length < 1 || + parsed['i'].length > 192 + ) { + throw new Error('invalid') + } + return { sortAt: parsed['s'], sid: parsed['i'] } + } catch { + throw new ApiError('BAD_REQUEST', 'malformed or mismatched cursor') + } +} diff --git a/apps/backend/src/projects/types.ts b/apps/backend/src/projects/types.ts new file mode 100644 index 00000000..f05b482a --- /dev/null +++ b/apps/backend/src/projects/types.ts @@ -0,0 +1,52 @@ +export type ProjectOwnerKind = 'user' | 'team' + +export type ProjectRow = { + id: string + owner_user_id: string | null + owner_team_id: string | null + slug: string + name: string + description: string | null + github_url: string | null + created_by_user_id: string + created_at: number + updated_at: number + archived_at: number | null +} + +export type ProjectOwner = { + kind: ProjectOwnerKind + id: string + handle: string + name: string + avatar_url: string | null +} + +export type ProjectResponse = { + id: string + slug: string + name: string + description: string | null + github_url: string | null + owner: ProjectOwner + created_at: number + updated_at: number + archived_at: number | null + session_count: number + /** Whether the current actor may change or archive this Project. */ + can_manage: boolean +} + +export type ProjectRef = Pick + +export type ProjectTenant = { userId: string; teamId: null } | { userId: null; teamId: string } + +export function projectTenant( + row: Pick, +): ProjectTenant { + if (row.owner_team_id !== null) return { userId: null, teamId: row.owner_team_id } + if (row.owner_user_id !== null) return { userId: row.owner_user_id, teamId: null } + // The D1 CHECK makes this unreachable. Throwing here keeps malformed fakes + // and rolling schemas from silently becoming a personal Project. + throw new TypeError('Project has no owner tenant') +} diff --git a/apps/backend/src/projects/validators.ts b/apps/backend/src/projects/validators.ts new file mode 100644 index 00000000..a00b22e2 --- /dev/null +++ b/apps/backend/src/projects/validators.ts @@ -0,0 +1,166 @@ +import { z } from 'zod' + +import { ApiError } from '../errors' +import { PROJECT_ID_RE, TEAM_ID_RE } from '../hub/wire' +import { MAX_PROJECT_DESCRIPTION_BYTES } from './limits' + +const MAX_NAME_CHARS = 80 +const MAX_GITHUB_URL_CHARS = 512 +const MAX_IDEMPOTENCY_KEY_CHARS = 200 + +export const PROJECT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/ + +const ProjectName = z + .string() + .trim() + .min(1) + .refine((value) => Array.from(value).length <= MAX_NAME_CHARS, { + message: `must be at most ${MAX_NAME_CHARS} characters`, + }) + +const ProjectSlug = z.string().trim().toLowerCase().regex(PROJECT_SLUG_RE) + +const ProjectDescription = z + .string() + .trim() + .refine((value) => new TextEncoder().encode(value).byteLength <= MAX_PROJECT_DESCRIPTION_BYTES, { + message: `must be at most ${MAX_PROJECT_DESCRIPTION_BYTES} UTF-8 bytes`, + }) + .nullable() + +const GithubUrl = z + .string() + .trim() + .max(MAX_GITHUB_URL_CHARS) + .url() + .refine((value) => { + const url = new URL(value) + return url.protocol === 'https:' && url.hostname.toLowerCase() === 'github.com' + }, 'must be an https://github.com URL') + .nullable() + +export const CreateProjectBody = z + .object({ + name: ProjectName, + slug: ProjectSlug.optional(), + description: ProjectDescription.optional().default(null), + github_url: GithubUrl.optional().default(null), + }) + .strict() + +export type CreateProjectInput = z.infer & { slug: string } + +export const UpdateProjectBody = z + .object({ + name: ProjectName.optional(), + slug: ProjectSlug.optional(), + description: ProjectDescription.optional(), + github_url: GithubUrl.optional(), + // Project identity retirement is terminal. The API never exposes a + // non-functional `archived: false` recovery option. + archived: z.literal(true).optional(), + expected_updated_at: z.number().int().nonnegative().optional(), + }) + .strict() + .refine( + (value) => + value.name !== undefined || + value.slug !== undefined || + value.description !== undefined || + value.github_url !== undefined || + value.archived !== undefined, + { message: 'at least one Project field is required' }, + ) + +export type UpdateProjectInput = z.infer + +export async function parseCreateProjectBody(request: Request): Promise { + const parsed = CreateProjectBody.safeParse(await readJson(request)) + if (!parsed.success) { + throw new ApiError('UNPROCESSABLE', 'invalid Project', { issues: parsed.error.issues }) + } + return { + ...parsed.data, + slug: parsed.data.slug ?? slugFromName(parsed.data.name), + } +} + +export async function parseUpdateProjectBody(request: Request): Promise { + const parsed = UpdateProjectBody.safeParse(await readJson(request)) + if (!parsed.success) { + throw new ApiError('UNPROCESSABLE', 'invalid Project update', { + issues: parsed.error.issues, + }) + } + return parsed.data +} + +export function requireProjectId(value: unknown): string { + const id = typeof value === 'string' ? value : '' + if (!PROJECT_ID_RE.test(id)) throw new ApiError('NOT_FOUND') + return id +} + +export function requireProjectSlug(value: unknown): string { + const slug = typeof value === 'string' ? value.trim().toLowerCase() : '' + if (!PROJECT_SLUG_RE.test(slug)) throw new ApiError('NOT_FOUND') + return slug +} + +export function requireOwnerHandle(value: unknown): string { + const handle = typeof value === 'string' ? value.trim().toLowerCase() : '' + if (!/^[a-z][a-z0-9_-]{2,31}$/.test(handle)) throw new ApiError('NOT_FOUND') + return handle +} + +export function requireProjectTeamId(value: unknown): string { + const id = typeof value === 'string' ? value : '' + if (!TEAM_ID_RE.test(id)) throw new ApiError('NOT_FOUND') + return id +} + +export function requireProjectIdempotencyKey(request: Request, bodyValue?: string): string { + const headerValue = request.headers.get('Idempotency-Key')?.trim() + const normalizedBodyValue = bodyValue?.trim() + if ( + headerValue !== undefined && + normalizedBodyValue !== undefined && + headerValue !== normalizedBodyValue + ) { + throw new ApiError('BAD_REQUEST', 'Idempotency-Key header and body must match') + } + const value = headerValue ?? normalizedBodyValue ?? '' + if ( + value.length < 8 || + value.length > MAX_IDEMPOTENCY_KEY_CHARS || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new ApiError('BAD_REQUEST', 'Idempotency-Key must be 8-200 printable characters') + } + return value +} + +export function slugFromName(name: string): string { + const normalized = name + .normalize('NFKD') + .toLowerCase() + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, '') + if (normalized && PROJECT_SLUG_RE.test(normalized)) return normalized + return 'project' +} + +export function newProjectId(): string { + return `project_${crypto.randomUUID().replaceAll('-', '')}` +} + +async function readJson(request: Request): Promise { + try { + return await request.json() + } catch { + throw new ApiError('BAD_REQUEST', 'invalid json') + } +} diff --git a/apps/backend/src/teams/store.ts b/apps/backend/src/teams/store.ts index 046c870b..9a40d0f3 100644 --- a/apps/backend/src/teams/store.ts +++ b/apps/backend/src/teams/store.ts @@ -244,6 +244,7 @@ export type TeamCreationRequestRow = { idempotency_key: string team_id: string normalized_name: string + requested_handle: string | null status: 'pending' | 'completed' | 'failed' workos_organization_id: string | null created_at: number @@ -270,6 +271,7 @@ export async function beginTeamCreationRequest( idempotencyKey: string teamId: string name: string + requestedHandle: string now: number }, ): Promise<{ created: boolean; request: TeamCreationRequestRow }> { @@ -277,17 +279,51 @@ export async function beginTeamCreationRequest( .prepare( `/* teams:begin-creation-request */ INSERT OR IGNORE INTO team_creation_requests - (user_id, idempotency_key, team_id, normalized_name, status, + (user_id, idempotency_key, team_id, normalized_name, requested_handle, status, workos_organization_id, created_at, updated_at) - VALUES (?,?,?,?,'pending',NULL,?,?)`, + VALUES (?,?,?,?,?,'pending',NULL,?,?)`, + ) + .bind( + args.userId, + args.idempotencyKey, + args.teamId, + args.name, + args.requestedHandle, + args.now, + args.now, ) - .bind(args.userId, args.idempotencyKey, args.teamId, args.name, args.now, args.now) .run() const request = await getTeamCreationRequest(db, args.userId, args.idempotencyKey) if (!request) throw new ApiError('INTERNAL', 'team creation request could not be loaded') return { created: result.meta.changes > 0, request } } +/** + * Older pending/completed receipts predate handle intent. A retry may adopt + * one handle exactly once; after that the migration trigger and this predicate + * make the intent immutable. + */ +export async function adoptTeamCreationHandle( + db: D1Database, + userId: string, + idempotencyKey: string, + requestedHandle: string, + now: number, +): Promise { + await db + .prepare( + `/* teams:adopt-creation-handle */ + UPDATE team_creation_requests + SET requested_handle=?, updated_at=? + WHERE user_id=? AND idempotency_key=? AND requested_handle IS NULL`, + ) + .bind(requestedHandle, now, userId, idempotencyKey) + .run() + const request = await getTeamCreationRequest(db, userId, idempotencyKey) + if (!request) throw new ApiError('INTERNAL', 'team creation request could not be loaded') + return request +} + export async function recordTeamCreationOrganization( db: D1Database, userId: string, @@ -340,11 +376,13 @@ export async function failTeamCreationRequest( `/* teams:fail-creation-request */ UPDATE team_creation_requests SET status='failed', updated_at=? WHERE user_id=? AND idempotency_key=? AND status='pending' - AND workos_organization_id IS NOT NULL - AND EXISTS ( - SELECT 1 FROM workos_cleanup_outbox cleanup - WHERE cleanup.operation='organization.delete' - AND cleanup.resource_id=team_creation_requests.workos_organization_id + AND ( + workos_organization_id IS NULL OR + EXISTS ( + SELECT 1 FROM workos_cleanup_outbox cleanup + WHERE cleanup.operation='organization.delete' + AND cleanup.resource_id=team_creation_requests.workos_organization_id + ) ) AND NOT EXISTS ( SELECT 1 FROM teams t JOIN team_memberships m ON m.team_id=t.id @@ -362,6 +400,7 @@ export async function completeTeamCreationRequest( userId: string, idempotencyKey: string, teamId: string, + requestedHandle: string, now: number, ): Promise { await db @@ -369,12 +408,17 @@ export async function completeTeamCreationRequest( `/* teams:complete-creation-request-recovery */ UPDATE team_creation_requests SET status='completed', updated_at=? WHERE user_id=? AND idempotency_key=? AND team_id=? AND status='pending' + AND requested_handle=? AND EXISTS ( SELECT 1 FROM teams t JOIN team_memberships m ON m.team_id=t.id WHERE t.id=? AND m.user_id=? AND m.role='owner' + AND EXISTS ( + SELECT 1 FROM handles h + WHERE h.team_id=t.id AND h.handle=? AND h.released_at IS NULL + ) )`, ) - .bind(now, userId, idempotencyKey, teamId, teamId, userId) + .bind(now, userId, idempotencyKey, teamId, requestedHandle, teamId, userId, requestedHandle) .run() } @@ -403,6 +447,7 @@ export async function createLocalTeam( workosMembershipUpdatedAt?: number | null idempotencyKey: string ownerUserId: string + requestedHandle: string now: number }, ): Promise { @@ -418,6 +463,7 @@ export async function createLocalTeam( AND EXISTS ( SELECT 1 FROM team_creation_requests WHERE user_id=? AND idempotency_key=? AND team_id=? AND status='pending' + AND requested_handle=? AND workos_organization_id=? ) AND EXISTS ( SELECT 1 FROM users owner @@ -437,6 +483,8 @@ export async function createLocalTeam( args.ownerUserId, args.idempotencyKey, args.id, + args.requestedHandle, + args.workosOrganizationId, args.ownerUserId, ), db @@ -453,15 +501,43 @@ export async function createLocalTeam( args.id, args.workosOrganizationId, ), + db + .prepare( + `/* teams:create-handle */ + INSERT INTO handles (handle, user_id, team_id, claimed_at, released_at) + SELECT ?,NULL,t.id,?,NULL + FROM teams t + JOIN team_memberships membership ON membership.team_id=t.id + JOIN team_creation_requests request ON request.team_id=t.id + WHERE t.id=? AND t.workos_organization_id=? + AND membership.user_id=? AND membership.role='owner' + AND request.user_id=? AND request.idempotency_key=? + AND request.status='pending' AND request.requested_handle=?`, + ) + .bind( + args.requestedHandle, + args.now, + args.id, + args.workosOrganizationId, + args.ownerUserId, + args.ownerUserId, + args.idempotencyKey, + args.requestedHandle, + ), db .prepare( `/* teams:complete-creation-request */ UPDATE team_creation_requests SET status='completed', updated_at=? WHERE user_id=? AND idempotency_key=? AND team_id=? AND status='pending' + AND requested_handle=? AND EXISTS ( SELECT 1 FROM teams t JOIN team_memberships m ON m.team_id=t.id WHERE t.id=? AND t.workos_organization_id=? AND m.user_id=? AND m.role='owner' + AND EXISTS ( + SELECT 1 FROM handles h + WHERE h.team_id=t.id AND h.handle=? AND h.released_at IS NULL + ) )`, ) .bind( @@ -469,25 +545,35 @@ export async function createLocalTeam( args.ownerUserId, args.idempotencyKey, args.id, + args.requestedHandle, args.id, args.workosOrganizationId, args.ownerUserId, + args.requestedHandle, ), ]) return ( (results[0]?.meta.changes ?? 0) > 0 && (results[1]?.meta.changes ?? 0) > 0 && - (results[2]?.meta.changes ?? 0) > 0 + (results[2]?.meta.changes ?? 0) > 0 && + (results[3]?.meta.changes ?? 0) > 0 ) } -type TeamListRow = TeamRow & { role: TeamRole; member_count: number; owner_count: number } +type TeamListRow = TeamRow & { + role: TeamRole + member_count: number + owner_count: number + handle: string | null +} export async function listTeamsForUser(db: D1Database, userId: string): Promise { const result = await db .prepare( `/* teams:list */ SELECT t.*, m.role, + (SELECT handle FROM handles h + WHERE h.team_id=t.id AND h.released_at IS NULL LIMIT 1) AS handle, (SELECT COUNT(*) FROM team_memberships members WHERE members.team_id=t.id) AS member_count, (SELECT COUNT(*) FROM team_memberships owners WHERE owners.team_id=t.id AND owners.role='owner') AS owner_count @@ -556,6 +642,8 @@ export async function getTeamForMember( .prepare( `/* teams:get-for-member */ SELECT t.*, m.role, + (SELECT handle FROM handles h + WHERE h.team_id=t.id AND h.released_at IS NULL LIMIT 1) AS handle, (SELECT COUNT(*) FROM team_memberships members WHERE members.team_id=t.id) AS member_count, (SELECT COUNT(*) FROM team_memberships owners WHERE owners.team_id=t.id AND owners.role='owner') AS owner_count @@ -1633,6 +1721,7 @@ function teamResponse(row: TeamListRow): TeamResponse { return { id: row.id, name: row.name, + handle: row.handle ?? null, role: row.role, // The final owner cannot leave without first transferring ownership. Keep // the server-computed capability contract aligned with that invariant so diff --git a/apps/backend/src/teams/types.ts b/apps/backend/src/teams/types.ts index 593354b7..70fba4a8 100644 --- a/apps/backend/src/teams/types.ts +++ b/apps/backend/src/teams/types.ts @@ -2,6 +2,7 @@ export type TeamRole = 'owner' | 'admin' | 'member' export const TEAM_PERMISSIONS = [ 'team:update', + 'team:identity', 'team:archive', 'members:invite', 'members:manage', @@ -79,6 +80,7 @@ export type TeamInvitationRow = { export type TeamResponse = { id: string name: string + handle: string | null role: TeamRole permissions: TeamPermission[] member_count: number diff --git a/apps/backend/src/teams/validators.ts b/apps/backend/src/teams/validators.ts index 52846f4e..001c3acd 100644 --- a/apps/backend/src/teams/validators.ts +++ b/apps/backend/src/teams/validators.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { ApiError } from '../errors' +import { validateHandle } from '../handles' const TeamName = z .string() @@ -9,9 +10,22 @@ const TeamName = z .transform((value) => value.trim()) .pipe(z.string().min(1).max(80)) const InvitableRole = z.enum(['admin', 'member']) +const TeamHandle = z.string().transform((value, context) => { + const parsed = validateHandle(value) + if (!parsed.ok) { + context.addIssue({ code: 'custom', message: parsed.reason }) + return z.NEVER + } + return parsed.handle +}) -const CreateTeamBody = z.object({ name: TeamName }).strict() -const UpdateTeamBody = z.object({ name: TeamName }).strict() +const CreateTeamBody = z.object({ name: TeamName, handle: TeamHandle.optional() }).strict() +const UpdateTeamBody = z + .object({ name: TeamName.optional(), handle: TeamHandle.optional() }) + .strict() + .refine((value) => (value.name === undefined) !== (value.handle === undefined), { + message: 'provide exactly one of name or handle', + }) const InviteBody = z.object({ email: z.email().trim().max(254), role: InvitableRole }).strict() const UpdateMemberBody = z.object({ role: z.enum(['owner', 'admin', 'member']) }).strict() diff --git a/apps/backend/tests/_helpers/fakes.ts b/apps/backend/tests/_helpers/fakes.ts index 598ab5e6..264fc2f9 100644 --- a/apps/backend/tests/_helpers/fakes.ts +++ b/apps/backend/tests/_helpers/fakes.ts @@ -69,7 +69,8 @@ type UserIdentityRow = { type HandleRow = { handle: string - user_id: string + user_id: string | null + team_id?: string | null claimed_at: number released_at: number | null } @@ -116,6 +117,9 @@ type HubSessionRow = { // Added in migration 0007. Optional keeps pre-Team fixtures readable while // still letting deletion tests distinguish personal and Team-owned rows. team_id?: string | null + // Added in migration 0014. Optional keeps older fixture literals source + // compatible; all new Hub commits below resolve and persist a Project. + project_id?: string withdrawn_at: number | null created_at: number updated_at: number @@ -150,6 +154,31 @@ type TeamMembershipRow = { workos_membership_id?: string | null } +type ProjectRow = { + id: string + owner_user_id: string | null + owner_team_id: string | null + slug: string + name: string + description: string | null + github_url: string | null + created_by_user_id: string + created_at: number + updated_at: number + archived_at: number | null +} + +type ProjectCreationRequestRow = { + actor_user_id: string + owner_scope: string + owner_user_id: string | null + owner_team_id: string | null + idempotency_key: string + project_id: string + request_hash: string + created_at: number +} + type ApiTokenRow = { id: string user_id: string @@ -246,6 +275,8 @@ export type FakeDbState = { hub_team_objects: HubTeamObjectRow[] teams: TeamRow[] team_memberships: TeamMembershipRow[] + projects: ProjectRow[] + project_creation_requests: ProjectCreationRequestRow[] workos_cleanup_outbox: WorkosCleanupOutboxRow[] api_tokens: ApiTokenRow[] hub_session_discovery: HubSessionDiscoveryRow[] @@ -269,6 +300,8 @@ export function emptyState(): FakeDbState { hub_team_objects: [], teams: [], team_memberships: [], + projects: [], + project_creation_requests: [], workos_cleanup_outbox: [], api_tokens: [], hub_session_discovery: [], @@ -302,6 +335,80 @@ export function makeDb(state: FakeDbState = emptyState()): { ) } + function activeProjectFor( + projectId: string, + ownerUserId: string | null, + ownerTeamId: string | null, + ): ProjectRow | null { + return ( + state.projects.find( + (row) => + row.id === projectId && + row.owner_user_id === ownerUserId && + row.owner_team_id === ownerTeamId && + row.archived_at === null, + ) ?? null + ) + } + + function hydratedManagedSessionFields( + session: HubSessionRow, + ): Record { + const project = state.projects.find((row) => row.id === session.project_id) + if (!project) return {} + + const author = state.users.find((row) => row.id === session.owner_user_id) ?? null + const authorHandle = + state.handles + .filter((row) => row.user_id === session.owner_user_id && row.released_at === null) + .sort((left, right) => left.handle.localeCompare(right.handle))[0]?.handle ?? null + const ownerHandle = + state.handles + .filter( + (row) => + row.user_id === project.owner_user_id && + (row.team_id ?? null) === project.owner_team_id && + row.released_at === null, + ) + .sort((left, right) => left.handle.localeCompare(right.handle))[0]?.handle ?? null + if (ownerHandle === null) return {} + + const ownerUser = + project.owner_user_id === null + ? null + : (state.users.find((row) => row.id === project.owner_user_id) ?? null) + const ownerTeam = + project.owner_team_id === null + ? null + : (state.teams.find((row) => row.id === project.owner_team_id) ?? null) + + const discovery = state.hub_session_discovery.find((row) => row.sid === session.sid) ?? null + return { + managed_published: discovery === null ? 0 : 1, + managed_published_at: discovery?.published_at ?? null, + managed_author_handle: authorHandle, + managed_author_name: author?.name ?? null, + managed_author_display_name: author?.display_name ?? null, + managed_author_avatar_url: author?.avatar_url ?? null, + managed_author_custom_avatar_id: author?.custom_avatar_id ?? null, + managed_author_avatar_visible: author?.avatar_visible ?? 1, + managed_project_slug: project.slug, + managed_project_name: project.name, + managed_project_owner_user_id: project.owner_user_id, + managed_project_owner_team_id: project.owner_team_id, + managed_project_owner_handle: ownerHandle, + managed_project_owner_name: + ownerTeam?.name ?? + ownerUser?.display_name ?? + ownerUser?.name ?? + ownerUser?.email.split('@')[0] ?? + ownerHandle, + managed_project_owner_avatar_url: ownerUser?.avatar_url ?? null, + managed_project_owner_custom_avatar_id: ownerUser?.custom_avatar_id ?? null, + managed_project_owner_avatar_visible: ownerUser?.avatar_visible ?? 1, + } + } + function isLivePublicSession(sid: string): boolean { const session = state.hub_sessions.find( (row) => row.sid === sid && row.visibility === 'unlisted' && row.withdrawn_at === null, @@ -462,6 +569,344 @@ export function makeDb(state: FakeDbState = emptyState()): { star_count: state.hub_session_stars.filter((row) => row.sid === sid).length, } as T } + if ( + /^SELECT COUNT\(\*\) AS count\s+FROM hub_sessions\s+WHERE project_id=\? AND withdrawn_at IS NULL$/i.test( + sql, + ) + ) { + const [projectId] = params as [string] + return { + count: state.hub_sessions.filter( + (row) => row.project_id === projectId && row.withdrawn_at === null, + ).length, + } as T + } + if ( + sql.includes('SELECT COUNT(*) AS count') && + sql.includes('FROM hub_sessions s') && + sql.includes('JOIN hub_session_discovery d ON d.sid=s.sid') && + sql.includes('s.project_id=?') && + sql.includes('s.owner_user_id=?') + ) { + const [projectId, ownerUserId] = params as [string, string] + return { + count: state.hub_sessions.filter( + (row) => + row.project_id === projectId && + row.owner_user_id === ownerUserId && + (row.team_id ?? null) === null && + row.visibility === 'unlisted' && + row.withdrawn_at === null && + state.hub_session_discovery.some((projection) => projection.sid === row.sid), + ).length, + } as T + } + if (sql.includes('SELECT id FROM projects') && sql.includes("(id=? OR slug='sessions')")) { + const [ownerUserId, ownerTeamId, preferredId] = params as [ + string | null, + string | null, + string, + string, + ] + const candidates = state.projects + .filter( + (row) => + row.owner_user_id === ownerUserId && + row.owner_team_id === ownerTeamId && + row.archived_at === null && + (row.id === preferredId || row.slug === 'sessions'), + ) + .sort((left, right) => { + const preferred = Number(right.id === preferredId) - Number(left.id === preferredId) + return ( + preferred || left.created_at - right.created_at || left.id.localeCompare(right.id) + ) + }) + return candidates[0] ? ({ id: candidates[0].id } as T) : null + } + if ( + sql.includes('SELECT * FROM projects') && + sql.includes('owner_user_id IS ?') && + sql.includes('owner_team_id IS ?') + ) { + const [projectId, ownerUserId, ownerTeamId] = params as [ + string, + string | null, + string | null, + ] + return (activeProjectFor(projectId, ownerUserId, ownerTeamId) as T) ?? null + } + if (/^SELECT \* FROM projects WHERE id=\?(?: AND archived_at IS NULL)?$/i.test(sql)) { + const [projectId] = params as [string] + const row = state.projects.find( + (candidate) => + candidate.id === projectId && + (!sql.includes('archived_at IS NULL') || candidate.archived_at === null), + ) + return (row as T) ?? null + } + if ( + /^SELECT \* FROM projects\s+WHERE id=\? AND owner_user_id=\? AND owner_team_id IS NULL\s+AND archived_at IS NULL$/i.test( + sql, + ) + ) { + const [projectId, userId] = params as [string, string] + return ( + (state.projects.find( + (row) => + row.id === projectId && + row.owner_user_id === userId && + row.owner_team_id === null && + row.archived_at === null, + ) as T) ?? null + ) + } + if ( + /^SELECT \* FROM projects\s+WHERE id=\? AND owner_team_id=\? AND owner_user_id IS NULL\s+AND archived_at IS NULL$/i.test( + sql, + ) + ) { + const [projectId, teamId] = params as [string, string] + return ( + (state.projects.find( + (row) => + row.id === projectId && + row.owner_team_id === teamId && + row.owner_user_id === null && + row.archived_at === null, + ) as T) ?? null + ) + } + if ( + /^SELECT \* FROM projects WHERE (owner_user_id|owner_team_id)=\? AND slug=\? AND archived_at IS NULL$/i.test( + sql, + ) + ) { + const [, ownerColumn] = + sql.match(/^SELECT \* FROM projects WHERE (owner_user_id|owner_team_id)=\?/i) ?? [] + const [ownerId, slug] = params as [string, string] + return ( + (state.projects.find( + (row) => + row[ownerColumn as 'owner_user_id' | 'owner_team_id'] === ownerId && + row.slug === slug && + row.archived_at === null, + ) as T) ?? null + ) + } + if ( + /^SELECT project_id, request_hash\s+FROM project_creation_requests\s+WHERE actor_user_id=\? AND owner_scope=\? AND idempotency_key=\?$/i.test( + sql, + ) + ) { + const [actorUserId, ownerScope, idempotencyKey] = params as [string, string, string] + const row = state.project_creation_requests.find( + (candidate) => + candidate.actor_user_id === actorUserId && + candidate.owner_scope === ownerScope && + candidate.idempotency_key === idempotencyKey, + ) + return row ? ({ project_id: row.project_id, request_hash: row.request_hash } as T) : null + } + if (sql.includes('SELECT h.handle, h.user_id, h.team_id')) { + const [handle] = params as [string] + const claim = state.handles.find( + (candidate) => candidate.handle === handle && candidate.released_at === null, + ) + if (!claim) return null + if (claim.user_id !== null) { + const user = state.users.find( + (candidate) => + candidate.id === claim.user_id && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + if (!user) return null + return { + handle, + user_id: user.id, + team_id: null, + owner_name: null, + owner_email: user.email, + owner_provider_name: user.name, + owner_display_name: user.display_name ?? null, + owner_avatar_url: user.avatar_url, + owner_custom_avatar_id: user.custom_avatar_id ?? null, + owner_avatar_visible: user.avatar_visible ?? 1, + } as T + } + const team = state.teams.find( + (candidate) => + candidate.id === (claim.team_id ?? null) && + candidate.archived_at === null && + (candidate.deletion_pending_until ?? null) === null, + ) + return team + ? ({ + handle, + user_id: null, + team_id: team.id, + owner_name: team.name, + owner_email: null, + owner_provider_name: null, + owner_display_name: null, + owner_avatar_url: null, + owner_custom_avatar_id: null, + owner_avatar_visible: null, + } as T) + : null + } + if ( + /^SELECT 1 FROM hub_sessions WHERE project_id=\? AND withdrawn_at IS NULL LIMIT 1$/i.test( + sql, + ) + ) { + const [projectId] = params as [string] + return state.hub_sessions.some( + (row) => row.project_id === projectId && row.withdrawn_at === null, + ) + ? ({ '1': 1 } as T) + : null + } + if ( + /^SELECT COUNT\(\*\) AS count\s+FROM projects\s+WHERE owner_user_id IS \? AND owner_team_id IS \?\s+AND archived_at IS NULL$/i.test( + sql, + ) + ) { + const [ownerUserId, ownerTeamId] = params as [string | null, string | null] + return { + count: state.projects.filter( + (project) => + project.owner_user_id === ownerUserId && + project.owner_team_id === ownerTeamId && + project.archived_at === null, + ).length, + } as T + } + if ( + /^SELECT COUNT\(\*\) AS count\s+FROM projects\s+WHERE owner_user_id IS \? AND owner_team_id IS \?$/i.test( + sql, + ) + ) { + const [ownerUserId, ownerTeamId] = params as [string | null, string | null] + return { + count: state.projects.filter( + (project) => + project.owner_user_id === ownerUserId && project.owner_team_id === ownerTeamId, + ).length, + } as T + } + if ( + /^SELECT COUNT\(\*\) AS count\s+FROM project_creation_requests\s+WHERE actor_user_id=\?$/i.test( + sql, + ) + ) { + const [actorUserId] = params as [string] + return { + count: state.project_creation_requests.filter( + (receipt) => receipt.actor_user_id === actorUserId, + ).length, + } as T + } + if ( + /^SELECT 1 FROM projects\s+WHERE id=\? AND owner_user_id IS \? AND owner_team_id IS \?\s+AND archived_at IS NULL\s+AND \(id=\? OR slug='sessions'\)\s+LIMIT 1$/i.test( + sql, + ) + ) { + const [projectId, ownerUserId, ownerTeamId, defaultId] = params as [ + string, + string | null, + string | null, + string, + ] + return state.projects.some( + (project) => + project.id === projectId && + project.owner_user_id === ownerUserId && + project.owner_team_id === ownerTeamId && + project.archived_at === null && + (project.id === defaultId || project.slug === 'sessions'), + ) + ? ({ '1': 1 } as T) + : null + } + if ( + /SELECT m\.role FROM team_memberships m\s+JOIN teams t ON t\.id=m\.team_id\s+WHERE m\.team_id=\? AND m\.user_id=\?/i.test( + sql, + ) + ) { + const [teamId, userId] = params as [string, string] + const role = activeTeamRoleFor(teamId, userId) + return role === null ? null : ({ role } as T) + } + if (/^SELECT role FROM team_memberships WHERE team_id=\? AND user_id=\?$/i.test(sql)) { + const [teamId, userId] = params as [string, string] + const role = activeTeamRoleFor(teamId, userId) + return role === null ? null : ({ role } as T) + } + if ( + sql.includes('/* teams:get */ SELECT * FROM teams WHERE id=? AND archived_at IS NULL') + ) { + const [teamId] = params as [string] + const team = state.teams.find((team) => team.id === teamId && team.archived_at === null) + return team + ? ({ ...team, deletion_pending_until: team.deletion_pending_until ?? null } as T) + : null + } + if ( + sql.includes( + '/* teams:get-membership */ SELECT * FROM team_memberships WHERE team_id=? AND user_id=?', + ) + ) { + const [teamId, userId] = params as [string, string] + return ( + (state.team_memberships.find( + (membership) => membership.team_id === teamId && membership.user_id === userId, + ) as T) ?? null + ) + } + if (sql.includes('SELECT t.name') && sql.includes('FROM teams t')) { + const [teamId] = params as [string] + const team = state.teams.find( + (candidate) => + candidate.id === teamId && + candidate.archived_at === null && + (candidate.deletion_pending_until ?? null) === null, + ) + if (!team) return null + const handle = + state.handles.find( + (candidate) => + (candidate.team_id ?? null) === teamId && candidate.released_at === null, + )?.handle ?? null + return { name: team.name, handle } as T + } + if ( + sql.includes('SELECT u.email, u.name, u.display_name, u.avatar_url') && + sql.includes('FROM users u') + ) { + const [userId] = params as [string] + const user = state.users.find( + (candidate) => + candidate.id === userId && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + if (!user) return null + const handle = + state.handles.find( + (candidate) => candidate.user_id === userId && candidate.released_at === null, + )?.handle ?? null + return { + email: user.email, + name: user.name, + display_name: user.display_name ?? null, + avatar_url: user.avatar_url, + custom_avatar_id: user.custom_avatar_id ?? null, + avatar_visible: user.avatar_visible ?? 1, + handle, + } as T + } if (/^SELECT \* FROM hub_sessions WHERE sid=\?$/i.test(sql)) { const [sid] = params as [string] return (state.hub_sessions.find((row) => row.sid === sid) as T) ?? null @@ -580,6 +1025,38 @@ export function makeDb(state: FakeDbState = emptyState()): { avatar_visible: user.avatar_visible ?? 1, } as T } + if (sql.includes('SELECT COALESCE(display_name,name) AS label')) { + const [userId, actorUserId] = params as [string, string] + const user = state.users.find( + (candidate) => + candidate.id === userId && + candidate.id === actorUserId && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + return user + ? ({ + label: user.display_name ?? user.name ?? null, + } as T) + : null + } + if (sql.includes('SELECT t.name AS label') && sql.includes('JOIN team_memberships')) { + const [teamId, actorUserId] = params as [string, string] + const team = state.teams.find( + (candidate) => + candidate.id === teamId && + candidate.archived_at === null && + (candidate.deletion_pending_until ?? null) === null, + ) + const role = activeTeamRoleFor(teamId, actorUserId) + const actor = state.users.find( + (candidate) => + candidate.id === actorUserId && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + return team && actor && role !== null ? ({ label: team.name } as T) : null + } if ( /^SELECT u\.\* FROM users u JOIN user_identities i ON i\.user_id = u\.id WHERE i\.provider = \? AND i\.provider_sub = \?/i.test( sql, @@ -617,6 +1094,21 @@ export function makeDb(state: FakeDbState = emptyState()): { if (!u) return null return { custom_avatar_id: u.custom_avatar_id ?? null } as T } + if (/^SELECT 1 FROM handles WHERE handle=\?$/i.test(sql)) { + const [handle] = params as [string] + return state.handles.some((row) => row.handle === handle) ? ({ '1': 1 } as T) : null + } + if (/^SELECT user_id, team_id, released_at FROM handles WHERE handle=\?$/i.test(sql)) { + const [handle] = params as [string] + const row = state.handles.find((candidate) => candidate.handle === handle) + return row + ? ({ + user_id: row.user_id, + team_id: row.team_id ?? null, + released_at: row.released_at, + } as T) + : null + } if (/^SELECT 1 FROM handles WHERE handle=\? AND released_at IS NULL/i.test(sql)) { const [h] = params const row = state.handles.find((x) => x.handle === h && x.released_at === null) @@ -632,6 +1124,13 @@ export function makeDb(state: FakeDbState = emptyState()): { const row = state.handles.find((x) => x.user_id === uid && x.released_at === null) return row ? ({ handle: row.handle } as T) : null } + if (/^SELECT handle FROM handles WHERE team_id=\? AND released_at IS NULL/i.test(sql)) { + const [teamId] = params + const row = state.handles.find( + (candidate) => (candidate.team_id ?? null) === teamId && candidate.released_at === null, + ) + return row ? ({ handle: row.handle } as T) : null + } if ( /^SELECT version FROM published_shares WHERE id=\? AND user_id=\? AND revoked_at IS NULL/i.test( sql, @@ -1387,93 +1886,308 @@ export function makeDb(state: FakeDbState = emptyState()): { meta: { changes: before - state.hub_session_guidance.length }, } } - if (sql.includes('/* hub:authorized-head-insert */')) { + if (sql.includes('/* projects:authorized-create */')) { const [ - sid, + projectId, ownerUserId, - root, - recordCount, - sig, - cardJson, - summaryMd, - lineageJson, - viewOid, - spoolFileOid, - costUsd, - totalTokens, - targetVisibility, - targetTeamId, + ownerTeamId, + slug, + name, + description, + githubUrl, + createdByUserId, createdAt, updatedAt, actorUserId, - , - , - , - requireTeamManager, ] = params as [ string, - string, - string, - number, - string | null, - string | null, string | null, string | null, string, - string | null, - number | null, - number | null, string, string | null, + string | null, + string, number, number, string, + ] + const actor = state.users.find( + (row) => + row.id === actorUserId && + row.deleted_at === null && + row.deletion_pending_until === null, + ) + const authorized = + actor !== undefined && + (ownerTeamId === null + ? ownerUserId === actorUserId + : ['owner', 'admin'].includes(activeTeamRoleFor(ownerTeamId, actorUserId) ?? '')) + const hasHandle = state.handles.some( + (row) => + row.released_at === null && + row.user_id === ownerUserId && + (row.team_id ?? null) === ownerTeamId, + ) + if (!authorized || !hasHandle) return { success: true, meta: { changes: 0 } } + if ( + state.projects.filter( + (row) => + row.owner_user_id === ownerUserId && + row.owner_team_id === ownerTeamId && + row.archived_at === null, + ).length >= 100 + ) { + return { success: true, meta: { changes: 0 } } + } + if ( + state.projects.filter( + (row) => row.owner_user_id === ownerUserId && row.owner_team_id === ownerTeamId, + ).length >= 1_000 + ) { + return { success: true, meta: { changes: 0 } } + } + const receiptRequired = params.at(-3) !== null + const receiptActorUserId = params.at(-2) as string + const receiptLimit = params.at(-1) as number + if ( + receiptRequired && + state.project_creation_requests.filter( + (receipt) => receipt.actor_user_id === receiptActorUserId, + ).length >= receiptLimit + ) { + return { success: true, meta: { changes: 0 } } + } + if ( + state.projects.some( + (row) => + row.id === projectId || + (row.owner_user_id === ownerUserId && + row.owner_team_id === ownerTeamId && + row.slug === slug && + row.archived_at === null), + ) + ) { + throw new Error('UNIQUE constraint failed: projects.owner, projects.slug') + } + state.projects.push({ + id: projectId, + owner_user_id: ownerUserId, + owner_team_id: ownerTeamId, + slug, + name, + description, + github_url: githubUrl, + created_by_user_id: createdByUserId, + created_at: createdAt, + updated_at: updatedAt, + archived_at: null, + }) + return { success: true, meta: { changes: 1 } } + } + if (sql.includes('/* projects:record-idempotent-create */')) { + const [ + actorUserId, + ownerScope, + ownerUserId, + ownerTeamId, + idempotencyKey, + projectId, + requestHash, + createdAt, + ] = params as [ + string, + string, string | null, string | null, string, + string, + string, number, ] - const actor = state.users.find((row) => row.id === actorUserId) - if (!actor || actor.deleted_at !== null || actor.deletion_pending_until !== null) { + const project = activeProjectFor(projectId, ownerUserId, ownerTeamId) + if (!project) return { success: true, meta: { changes: 0 } } + const receiptActorUserId = params[11] as string + const receiptLimit = params[12] as number + if ( + state.project_creation_requests.filter( + (receipt) => receipt.actor_user_id === receiptActorUserId, + ).length >= receiptLimit + ) { return { success: true, meta: { changes: 0 } } } - if (targetTeamId !== null) { - const role = activeTeamRoleFor(targetTeamId, actorUserId) - if ( - role === null || - (requireTeamManager === 1 && role !== 'owner' && role !== 'admin') || - actor.deleted_at !== null || - actor.deletion_pending_until !== null - ) { - return { success: true, meta: { changes: 0 } } - } + if ( + state.project_creation_requests.some( + (row) => + (row.actor_user_id === actorUserId && + row.owner_scope === ownerScope && + row.idempotency_key === idempotencyKey) || + row.project_id === projectId, + ) + ) { + throw new Error( + 'UNIQUE constraint failed: project_creation_requests.actor_user_id, project_creation_requests.owner_scope, project_creation_requests.idempotency_key', + ) } - if (state.hub_sessions.some((row) => row.sid === sid)) { - throw new Error('UNIQUE constraint failed: hub_sessions.sid') + state.project_creation_requests.push({ + actor_user_id: actorUserId, + owner_scope: ownerScope, + owner_user_id: ownerUserId, + owner_team_id: ownerTeamId, + idempotency_key: idempotencyKey, + project_id: projectId, + request_hash: requestHash, + created_at: createdAt, + }) + return { success: true, meta: { changes: 1 } } + } + if (sql.includes('/* projects:authorized-update */')) { + const [ + hasName, + name, + hasSlug, + slug, + hasDescription, + description, + hasGithubUrl, + githubUrl, + hasArchived, + archived, + archivedAt, + updatedAt, + projectId, + ownerUserId, + ownerTeamId, + hasExpectedUpdatedAt, + expectedUpdatedAt, + archiveRequested, + fallbackArchiveRequested, + defaultProjectId, + actorUserId, + ] = params as [ + number, + string, + number, + string, + number, + string | null, + number, + string | null, + number, + number, + number, + number, + string, + string | null, + string | null, + number, + number, + number, + number, + string, + string, + ] + const project = activeProjectFor(projectId, ownerUserId, ownerTeamId) + const actor = state.users.find( + (row) => + row.id === actorUserId && + row.deleted_at === null && + row.deletion_pending_until === null, + ) + const authorized = + actor !== undefined && + (ownerTeamId === null + ? ownerUserId === actorUserId + : ['owner', 'admin'].includes(activeTeamRoleFor(ownerTeamId, actorUserId) ?? '')) + if ( + !project || + !authorized || + (hasExpectedUpdatedAt === 1 && project.updated_at !== expectedUpdatedAt) || + (archiveRequested === 1 && + state.hub_sessions.some( + (row) => row.project_id === projectId && row.withdrawn_at === null, + )) || + (fallbackArchiveRequested === 1 && + (project.id === defaultProjectId || project.slug === 'sessions')) + ) { + return { success: true, meta: { changes: 0 } } } - state.hub_sessions.push({ - sid, + const nextSlug = hasSlug === 1 ? slug : project.slug + if ( + state.projects.some( + (row) => + row.id !== projectId && + row.owner_user_id === ownerUserId && + row.owner_team_id === ownerTeamId && + row.slug === nextSlug && + row.archived_at === null, + ) + ) { + throw new Error('UNIQUE constraint failed: projects.owner, projects.slug') + } + if (hasName === 1) project.name = name + if (hasSlug === 1) project.slug = slug + if (hasDescription === 1) project.description = description + if (hasGithubUrl === 1) project.github_url = githubUrl + if (hasArchived === 1) project.archived_at = archived === 1 ? archivedAt : null + project.updated_at = updatedAt + return { success: true, meta: { changes: 1 } } + } + if (sql.includes('/* projects:authorized-default */')) { + const [projectId, ownerUserId, ownerTeamId, actorUserId, createdAt, updatedAt] = + params as [string, string | null, string | null, string, number, number] + if (state.projects.some((row) => row.id === projectId)) { + return { success: true, meta: { changes: 0 } } + } + const actor = state.users.find( + (row) => + row.id === actorUserId && + row.deleted_at === null && + row.deletion_pending_until === null, + ) + const authorized = + actor !== undefined && + (ownerTeamId === null + ? ownerUserId === actorUserId + : activeTeamRoleFor(ownerTeamId, actorUserId) !== null) + if (!authorized) return { success: true, meta: { changes: 0 } } + if ( + state.projects.filter( + (row) => + row.owner_user_id === ownerUserId && + row.owner_team_id === ownerTeamId && + row.archived_at === null, + ).length >= 100 + ) { + return { success: true, meta: { changes: 0 } } + } + if ( + state.projects.filter( + (row) => row.owner_user_id === ownerUserId && row.owner_team_id === ownerTeamId, + ).length >= 1_000 + ) { + return { success: true, meta: { changes: 0 } } + } + state.projects.push({ + id: projectId, owner_user_id: ownerUserId, - root, - record_count: recordCount, - sig, - card_json: cardJson, - note_md: summaryMd, - lineage_json: lineageJson, - view_oid: viewOid, - spool_file_oid: spoolFileOid, - cost_usd: costUsd, - total_tokens: totalTokens, - visibility: targetVisibility, - team_id: targetTeamId, - withdrawn_at: null, + owner_team_id: ownerTeamId, + slug: 'sessions', + name: 'Sessions', + description: + 'Sessions from older clients or work without a specific Project are collected here so every Session keeps a stable home.', + github_url: null, + created_by_user_id: actorUserId, created_at: createdAt, updated_at: updatedAt, + archived_at: null, }) return { success: true, meta: { changes: 1 } } } - if (sql.includes('/* hub:authorized-head-update */')) { + if (sql.includes('/* hub:authorized-head-insert */')) { const [ + sid, + ownerUserId, root, recordCount, sig, @@ -1484,27 +2198,19 @@ export function makeDb(state: FakeDbState = emptyState()): { spoolFileOid, costUsd, totalTokens, - changeAccess, targetVisibility, - , targetTeamId, - clearWithdrawal, + targetProjectId, + createdAt, updatedAt, - sid, actorUserId, - expectedRoot, - expectedUpdatedAt, - expectedTeamId, - expectedVisibility, - expectedWithdrawnAt, - expectedPublished, - , - , , , , requireTeamManager, ] = params as [ + string, + string, string, number, string | null, @@ -1515,19 +2221,9 @@ export function makeDb(state: FakeDbState = emptyState()): { string | null, number | null, number | null, - number, - string, - number, - string | null, - number, - number, - string, string, string | null, - number | null, - string | null, string, - number | null, number, number, string, @@ -1551,6 +2247,92 @@ export function makeDb(state: FakeDbState = emptyState()): { return { success: true, meta: { changes: 0 } } } } + if ( + !activeProjectFor( + targetProjectId, + targetTeamId === null ? actorUserId : null, + targetTeamId, + ) + ) { + return { success: true, meta: { changes: 0 } } + } + if (state.hub_sessions.some((row) => row.sid === sid)) { + throw new Error('UNIQUE constraint failed: hub_sessions.sid') + } + state.hub_sessions.push({ + sid, + owner_user_id: ownerUserId, + root, + record_count: recordCount, + sig, + card_json: cardJson, + note_md: summaryMd, + lineage_json: lineageJson, + view_oid: viewOid, + spool_file_oid: spoolFileOid, + cost_usd: costUsd, + total_tokens: totalTokens, + visibility: targetVisibility, + team_id: targetTeamId, + project_id: targetProjectId, + withdrawn_at: null, + created_at: createdAt, + updated_at: updatedAt, + }) + return { success: true, meta: { changes: 1 } } + } + if (sql.includes('/* hub:authorized-head-update */')) { + const root = params[0] as string + const recordCount = params[1] as number + const sig = params[2] as string | null + const cardJson = params[3] as string | null + const summaryMd = params[4] as string | null + const lineageJson = params[5] as string | null + const viewOid = params[6] as string + const spoolFileOid = params[7] as string | null + const costUsd = params[8] as number | null + const totalTokens = params[9] as number | null + const changeAccess = params[10] as number + const targetVisibility = params[11] as string + const targetTeamId = params[13] as string | null + const changeProject = params[14] as number + const targetProjectId = params[15] as string + const clearWithdrawal = params[16] as number + const updatedAt = params[17] as number + const sid = params[18] as string + const actorUserId = params[19] as string + const expectedRoot = params[20] as string | null + const expectedUpdatedAt = params[21] as number | null + const expectedTeamId = params[22] as string | null + const expectedProjectId = params[23] as string | null + const expectedVisibility = params[24] as string + const expectedWithdrawnAt = params[25] as number | null + const expectedPublished = params[26] as number + const requireTeamManager = params[32] as number + const actor = state.users.find((row) => row.id === actorUserId) + if (!actor || actor.deleted_at !== null || actor.deletion_pending_until !== null) { + return { success: true, meta: { changes: 0 } } + } + if (targetTeamId !== null) { + const role = activeTeamRoleFor(targetTeamId, actorUserId) + if ( + role === null || + (requireTeamManager === 1 && role !== 'owner' && role !== 'admin') || + actor.deleted_at !== null || + actor.deletion_pending_until !== null + ) { + return { success: true, meta: { changes: 0 } } + } + } + if ( + !activeProjectFor( + targetProjectId, + targetTeamId === null ? actorUserId : null, + targetTeamId, + ) + ) { + return { success: true, meta: { changes: 0 } } + } const existing = state.hub_sessions.find((row) => row.sid === sid) if (!existing) return { success: true, meta: { changes: 0 } } const isPublished = state.hub_session_discovery.some((row) => row.sid === sid) @@ -1559,6 +2341,7 @@ export function makeDb(state: FakeDbState = emptyState()): { existing.root !== expectedRoot || existing.updated_at !== expectedUpdatedAt || (existing.team_id ?? null) !== expectedTeamId || + (existing.project_id ?? null) !== expectedProjectId || existing.visibility !== expectedVisibility || existing.withdrawn_at !== expectedWithdrawnAt || isPublished !== (expectedPublished === 1) @@ -1579,56 +2362,26 @@ export function makeDb(state: FakeDbState = emptyState()): { existing.visibility = targetVisibility existing.team_id = targetTeamId } + if (changeProject === 1) existing.project_id = targetProjectId if (clearWithdrawal === 1) existing.withdrawn_at = null existing.updated_at = updatedAt return { success: true, meta: { changes: 1 } } } if (sql.includes('/* hub:authorized-visibility-update */')) { - const [ - targetVisibility, - targetTeamId, - lineageJson, - updatedAt, - sid, - expectedTeamId, - expectedVisibility, - expectedRoot, - expectedUpdatedAt, - actorUserId, - expectedPublished, - , - , - , - , - , - , - , - , - , - requireTargetManager, - ] = params as [ - string, - string | null, - string | null, - number, - string, - string | null, - string, - string, - number, - string, - number, - number, - string | null, - string, - string | null, - string | null, - string, - string | null, - string | null, - string, - number, - ] + const targetVisibility = params[0] as string + const targetTeamId = params[1] as string | null + const targetProjectId = params[2] as string + const lineageJson = params[3] as string | null + const updatedAt = params[4] as number + const sid = params[5] as string + const expectedTeamId = params[6] as string | null + const expectedProjectId = params[7] as string + const expectedVisibility = params[8] as string + const expectedRoot = params[9] as string + const expectedUpdatedAt = params[10] as number + const actorUserId = params[11] as string + const expectedPublished = params[12] as number + const requireTargetManager = params[22] as number const session = state.hub_sessions.find((row) => row.sid === sid) const isPublished = state.hub_session_discovery.some((row) => row.sid === sid) const actor = state.users.find((row) => row.id === actorUserId) @@ -1638,6 +2391,7 @@ export function makeDb(state: FakeDbState = emptyState()): { actor.deleted_at !== null || actor.deletion_pending_until !== null || (session.team_id ?? null) !== expectedTeamId || + session.project_id !== expectedProjectId || session.visibility !== expectedVisibility || session.root !== expectedRoot || session.updated_at !== expectedUpdatedAt || @@ -1671,8 +2425,18 @@ export function makeDb(state: FakeDbState = emptyState()): { return { success: true, meta: { changes: 0 } } } } + if ( + !activeProjectFor( + targetProjectId, + targetTeamId === null ? actorUserId : null, + targetTeamId, + ) + ) { + return { success: true, meta: { changes: 0 } } + } session.visibility = targetVisibility session.team_id = targetTeamId + session.project_id = targetProjectId session.lineage_json = lineageJson session.updated_at = updatedAt return { success: true, meta: { changes: 1 } } @@ -2218,13 +2982,97 @@ export function makeDb(state: FakeDbState = emptyState()): { state.audit.push({ user_id, ip_hash, ua_hash, action, target_id, details_json, ts }) return { success: true, meta: { changes: 1 } } } - if (/^INSERT INTO handles \(handle, user_id, claimed_at\) VALUES/i.test(sql)) { + if ( + /INSERT INTO handles\s+\(handle, user_id, team_id, claimed_at, released_at\)\s+SELECT \?,id,NULL,\?,NULL\s+FROM users/i.test( + sql, + ) + ) { + const [handle, claimedAt, userId, actorUserId] = params as [ + string, + number, + string, + string, + ] + const user = state.users.find( + (candidate) => + candidate.id === userId && + candidate.id === actorUserId && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + if (!user) return { success: true, meta: { changes: 0 } } + if ( + state.handles.some( + (candidate) => + candidate.handle === handle || + (candidate.user_id === userId && candidate.released_at === null), + ) + ) { + throw new Error('UNIQUE constraint failed: handles.handle') + } + state.handles.push({ + handle, + user_id: userId, + team_id: null, + claimed_at: claimedAt, + released_at: null, + }) + return { success: true, meta: { changes: 1 } } + } + if ( + /INSERT INTO handles\s+\(handle, user_id, team_id, claimed_at, released_at\)\s+SELECT \?,NULL,t\.id,\?,NULL\s+FROM teams t/i.test( + sql, + ) + ) { + const [handle, claimedAt, teamId, actorUserId] = params as [ + string, + number, + string, + string, + ] + const actor = state.users.find( + (candidate) => + candidate.id === actorUserId && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + const role = activeTeamRoleFor(teamId, actorUserId) + if (!actor || role === null) { + return { success: true, meta: { changes: 0 } } + } + if ( + state.handles.some( + (candidate) => + candidate.handle === handle || + ((candidate.team_id ?? null) === teamId && candidate.released_at === null), + ) + ) { + throw new Error('UNIQUE constraint failed: handles.handle') + } + state.handles.push({ + handle, + user_id: null, + team_id: teamId, + claimed_at: claimedAt, + released_at: null, + }) + return { success: true, meta: { changes: 1 } } + } + if ( + /^INSERT INTO handles \(handle, user_id, (?:team_id, )?claimed_at\) VALUES/i.test(sql) + ) { const [handle, user_id, claimed_at] = params as [string, string, number] // Mirror the real D1 PK constraint so race-condition coverage works. if (state.handles.some((h) => h.handle === handle)) { throw new Error(`UNIQUE constraint failed: handles.handle`) } - state.handles.push({ handle, user_id, claimed_at, released_at: null }) + state.handles.push({ + handle, + user_id, + team_id: null, + claimed_at, + released_at: null, + }) return { success: true, meta: { changes: 1 } } } if (/^INSERT OR REPLACE INTO deletion_queue/i.test(sql)) { @@ -2467,6 +3315,25 @@ export function makeDb(state: FakeDbState = emptyState()): { ) return { success: true, meta: { changes: before - state.hub_sessions.length } } } + if (sql.includes('/* account-deletion:delete-personal-project-receipts */')) { + const [ownerUserId] = params as [string] + const before = state.project_creation_requests.length + state.project_creation_requests = state.project_creation_requests.filter( + (row) => row.owner_user_id !== ownerUserId || row.owner_team_id !== null, + ) + return { + success: true, + meta: { changes: before - state.project_creation_requests.length }, + } + } + if (sql.includes('/* account-deletion:delete-personal-projects */')) { + const [ownerUserId] = params as [string] + const before = state.projects.length + state.projects = state.projects.filter( + (row) => row.owner_user_id !== ownerUserId || row.owner_team_id !== null, + ) + return { success: true, meta: { changes: before - state.projects.length } } + } if (/^DELETE FROM hub_session_engagement_daily WHERE sid=\?$/i.test(sql)) { const [sid] = params as [string] const before = state.hub_session_engagement_daily.length @@ -2490,6 +3357,378 @@ export function makeDb(state: FakeDbState = emptyState()): { throw new Error(`unmocked run() SQL: ${sql}`) }, async all(): Promise<{ results: T[] }> { + if (sql.includes('/* teams:list */')) { + const [userId, limit] = params as [string, number] + const items = state.team_memberships + .filter((membership) => membership.user_id === userId) + .flatMap((membership) => { + const team = state.teams.find( + (candidate) => + candidate.id === membership.team_id && + candidate.archived_at === null && + (candidate.deletion_pending_until ?? null) === null, + ) + if (!team) return [] + return [ + { + ...team, + role: membership.role, + handle: + state.handles.find( + (candidate) => + (candidate.team_id ?? null) === team.id && candidate.released_at === null, + )?.handle ?? null, + member_count: state.team_memberships.filter( + (candidate) => candidate.team_id === team.id, + ).length, + owner_count: state.team_memberships.filter( + (candidate) => candidate.team_id === team.id && candidate.role === 'owner', + ).length, + }, + ] + }) + .slice(0, limit) + return { results: items as T[] } + } + if (sql.includes('/* projects:list-hub-authorized */')) { + const [userId, hasAfter, beforeUpdatedAt, equalUpdatedAt, afterId, limit] = params as [ + string, + number, + number, + number, + string, + number, + ] + const actor = state.users.find( + (user) => + user.id === userId && + user.deleted_at === null && + user.deletion_pending_until === null, + ) + if (!actor) return { results: [] } + const rows = state.projects + .flatMap((project) => { + const role = + project.owner_team_id === null + ? null + : activeTeamRoleFor(project.owner_team_id, userId) + if ( + project.archived_at !== null || + (project.owner_user_id !== userId && role === null) + ) { + return [] + } + const handle = state.handles.find( + (candidate) => + candidate.released_at === null && + candidate.user_id === project.owner_user_id && + (candidate.team_id ?? null) === project.owner_team_id, + ) + if (!handle) return [] + const ownerUser = + project.owner_user_id === null + ? null + : (state.users.find((user) => user.id === project.owner_user_id) ?? null) + const ownerTeam = + project.owner_team_id === null + ? null + : (state.teams.find((team) => team.id === project.owner_team_id) ?? null) + return [ + { + ...project, + session_count: state.hub_sessions.filter( + (session) => session.project_id === project.id && session.withdrawn_at === null, + ).length, + owner_handle: handle.handle, + owner_name: + ownerTeam?.name ?? + ownerUser?.display_name ?? + ownerUser?.name ?? + ownerUser?.email.split('@')[0] ?? + handle.handle, + owner_avatar_url: ownerUser?.avatar_url ?? null, + owner_custom_avatar_id: ownerUser?.custom_avatar_id ?? null, + owner_avatar_visible: ownerUser?.avatar_visible ?? 1, + can_manage: + project.owner_user_id === userId || role === 'owner' || role === 'admin' + ? 1 + : 0, + }, + ] + }) + .sort( + (left, right) => + right.updated_at - left.updated_at || left.id.localeCompare(right.id), + ) + .filter( + (project) => + hasAfter === 0 || + project.updated_at < beforeUpdatedAt || + (project.updated_at === equalUpdatedAt && project.id > afterId), + ) + .slice(0, limit) + return { results: rows as T[] } + } + if ( + sql.includes('FROM projects p') && + sql.includes('JOIN users owner ON owner.id=p.owner_user_id') && + sql.includes('WHERE p.owner_user_id=? AND p.owner_team_id IS NULL') && + !sql.includes('JOIN hub_sessions') + ) { + const [userId, hasAfter, beforeUpdatedAt, equalUpdatedAt, afterId, limit] = params as [ + string, + number, + number, + number, + string, + number, + ] + const user = state.users.find( + (candidate) => + candidate.id === userId && + candidate.deleted_at === null && + candidate.deletion_pending_until === null, + ) + const items = user + ? state.projects + .filter( + (project) => + project.owner_user_id === userId && + project.owner_team_id === null && + project.archived_at === null, + ) + .map((project) => ({ + ...project, + session_count: state.hub_sessions.filter( + (session) => session.project_id === project.id && session.withdrawn_at === null, + ).length, + })) + .sort( + (left, right) => + right.updated_at - left.updated_at || left.id.localeCompare(right.id), + ) + .filter( + (project) => + hasAfter === 0 || + project.updated_at < beforeUpdatedAt || + (project.updated_at === equalUpdatedAt && project.id > afterId), + ) + .slice(0, limit) + : [] + return { results: items as T[] } + } + if (sql.includes('/* projects:list-team-authorized */')) { + const [teamId, userId, hasAfter, beforeUpdatedAt, equalUpdatedAt, afterId, limit] = + params as [string, string, number, number, number, string, number] + const role = activeTeamRoleFor(teamId, userId) + if (role === null) return { results: [] } + const projects = state.projects + .filter( + (project) => + project.owner_user_id === null && + project.owner_team_id === teamId && + project.archived_at === null, + ) + .map((project) => ({ + ...project, + session_count: state.hub_sessions.filter( + (session) => session.project_id === project.id && session.withdrawn_at === null, + ).length, + })) + .sort( + (left, right) => + right.updated_at - left.updated_at || left.id.localeCompare(right.id), + ) + .filter( + (project) => + hasAfter === 0 || + project.updated_at < beforeUpdatedAt || + (project.updated_at === equalUpdatedAt && project.id > afterId), + ) + .slice(0, limit) + return { + results: (projects.length > 0 ? projects : [{ id: null }]) as T[], + } + } + if (sql.includes('/* projects:list-public */')) { + const [hasAfter, beforePublishedAt, equalPublishedAt, afterId, limit] = params as [ + number, + number, + number, + string, + number, + ] + const items = state.projects + .flatMap((project) => { + if ( + project.owner_user_id === null || + project.owner_team_id !== null || + project.archived_at !== null + ) { + return [] + } + const user = state.users.find( + (candidate) => + candidate.id === project.owner_user_id && candidate.deleted_at === null, + ) + const handle = state.handles.find( + (candidate) => + candidate.user_id === project.owner_user_id && candidate.released_at === null, + ) + if (!user || !handle) return [] + const published = state.hub_sessions.flatMap((session) => { + const projection = state.hub_session_discovery.find( + (candidate) => candidate.sid === session.sid, + ) + return session.project_id === project.id && + session.owner_user_id === project.owner_user_id && + (session.team_id ?? null) === null && + session.visibility === 'unlisted' && + session.withdrawn_at === null && + projection + ? [projection] + : [] + }) + if (published.length === 0) return [] + return [ + { + ...project, + session_count: published.length, + last_session_at: Math.max(...published.map((row) => row.published_at)), + owner_handle: handle.handle, + owner_email: user.email, + owner_name: user.name, + owner_display_name: user.display_name ?? null, + owner_avatar_url: user.avatar_url, + owner_custom_avatar_id: user.custom_avatar_id ?? null, + owner_avatar_visible: user.avatar_visible ?? 1, + }, + ] + }) + .filter( + (project) => + hasAfter === 0 || + project.last_session_at < beforePublishedAt || + (project.last_session_at === equalPublishedAt && project.id > afterId), + ) + .sort( + (left, right) => + right.last_session_at - left.last_session_at || left.id.localeCompare(right.id), + ) + .slice(0, limit) + return { results: items as T[] } + } + if ( + sql.includes('SELECT p.*, COUNT(d.sid) AS session_count') && + sql.includes('JOIN hub_session_discovery d') + ) { + const [userId, limit] = params as [string, number] + const projects = state.projects.flatMap((project) => { + if ( + project.owner_user_id !== userId || + project.owner_team_id !== null || + project.archived_at !== null + ) { + return [] + } + const projections = state.hub_sessions.flatMap((session) => { + const projection = state.hub_session_discovery.find( + (candidate) => candidate.sid === session.sid, + ) + return session.project_id === project.id && + (session.team_id ?? null) === null && + session.visibility === 'unlisted' && + session.withdrawn_at === null && + projection + ? [projection] + : [] + }) + return projections.length === 0 + ? [] + : [ + { + ...project, + session_count: projections.length, + last_session_at: Math.max(...projections.map((row) => row.published_at)), + }, + ] + }) + projects.sort( + (left, right) => + right.last_session_at - left.last_session_at || left.id.localeCompare(right.id), + ) + return { results: projects.slice(0, limit) as T[] } + } + if ( + sql.includes('/* projects:list-sessions-authorized */') || + sql.includes('/* projects:list-public-sessions */') || + sql.includes('/* projects:list-public-owner-sessions */') + ) { + const publicProject = sql.includes('/* projects:list-public-sessions */') + const publicOwner = sql.includes('/* projects:list-public-owner-sessions */') + const [firstParam, secondParam] = params as [string, string | null] + const projectId = publicOwner ? null : firstParam + const ownerUserId = publicOwner ? firstParam : publicProject ? secondParam : null + const teamId = !publicProject && !publicOwner ? (params[2] as string | null) : null + const actorUserId = !publicProject && !publicOwner ? (params[4] as string) : null + const cursorOffset = publicOwner ? 1 : publicProject ? 2 : 8 + const hasAfter = Number(params[cursorOffset] ?? 0) + const afterSortAt = Number(params[cursorOffset + 1] ?? 0) + const equalSortAt = Number(params[cursorOffset + 2] ?? 0) + const afterSid = String(params[cursorOffset + 3] ?? '') + const limit = Number(params[cursorOffset + 4] ?? Number.MAX_SAFE_INTEGER) + const rows = state.hub_sessions + .filter((session) => { + if (projectId !== null && session.project_id !== projectId) return false + if (session.withdrawn_at !== null) return false + if (publicProject || publicOwner) { + return ( + session.owner_user_id === ownerUserId && + (session.team_id ?? null) === null && + session.visibility === 'unlisted' && + state.hub_session_discovery.some((projection) => projection.sid === session.sid) + ) + } + if (teamId === null) { + return (session.team_id ?? null) === null && session.owner_user_id === actorUserId + } + return ( + session.team_id === teamId && + actorUserId !== null && + activeTeamRoleFor(teamId, actorUserId) !== null + ) + }) + .map((session) => { + const publishedAt = + state.hub_session_discovery.find((projection) => projection.sid === session.sid) + ?.published_at ?? 0 + return { + ...session, + ...hydratedManagedSessionFields(session), + team_name: + session.team_id == null + ? null + : (state.teams.find((team) => team.id === session.team_id)?.name ?? null), + star_count: state.hub_session_stars.filter((star) => star.sid === session.sid) + .length, + published_at: publishedAt, + project_sort_at: publicProject || publicOwner ? publishedAt : session.updated_at, + } + }) + .filter( + (session) => + hasAfter === 0 || + session.project_sort_at < afterSortAt || + (session.project_sort_at === equalSortAt && session.sid > afterSid), + ) + .sort((left, right) => + publicProject || publicOwner + ? right.published_at - left.published_at || left.sid.localeCompare(right.sid) + : right.updated_at - left.updated_at || left.sid.localeCompare(right.sid), + ) + .slice(0, limit) + return { results: rows as T[] } + } if (sql.includes('/* teams:local-workos-memberships */')) { const [userId, after, limit] = params as [string, string, number] const items = state.team_memberships @@ -2668,12 +3907,24 @@ export function makeDb(state: FakeDbState = emptyState()): { ) return sourceTeam ? discovery.lineage_source_sid : null })() + const publicProject = + session.team_id == null && session.project_id + ? state.projects.find( + (project) => + project.id === session.project_id && + project.owner_user_id === session.owner_user_id && + project.owner_team_id === null, + ) + : null return [ { ...discovery, lineage_source_sid: lineageSourceSid, record_count: session.record_count, owner_user_id: session.owner_user_id, + project_id: publicProject?.id ?? null, + project_slug: publicProject?.slug ?? null, + project_name: publicProject?.name ?? null, handle, name: identityLive ? user.name : null, display_name: identityLive ? (user.display_name ?? null) : null, diff --git a/apps/backend/tests/discovery.test.ts b/apps/backend/tests/discovery.test.ts index 4741cc29..2a8d85d1 100644 --- a/apps/backend/tests/discovery.test.ts +++ b/apps/backend/tests/discovery.test.ts @@ -497,12 +497,14 @@ describe('GET /api/discovery/v1/sessions', () => { items: Array<{ sid: string author: { handle: string | null; displayName: string | null; avatarUrl: string | null } + project: { id: string; slug: string; name: string } | null }> } expect(body.items).toEqual([ expect.objectContaining({ sid: CLAUDE_SID, author: { handle: null, displayName: null, avatarUrl: null }, + project: null, }), ]) diff --git a/apps/backend/tests/e2e-roundtrip.test.ts b/apps/backend/tests/e2e-roundtrip.test.ts index 2865e01a..b1561b64 100644 --- a/apps/backend/tests/e2e-roundtrip.test.ts +++ b/apps/backend/tests/e2e-roundtrip.test.ts @@ -22,6 +22,10 @@ import { import { describe, expect, it } from 'vite-plus/test' import { onRequestPost as batchPost } from '../functions/api/hub/v1/objects/batch' +import { + onRequestGet as projectsGet, + onRequestPost as projectsPost, +} from '../functions/api/hub/v1/projects' import { onRequestPost as headPost } from '../functions/api/hub/v1/sessions/[sid]/head' import { onRequestGet as metaGet } from '../functions/api/hub/v1/sessions/[sid]/index' import { onRequestPost as pushPost } from '../functions/api/hub/v1/sessions/[sid]/push' @@ -61,6 +65,10 @@ function makeFetchAdapter(env: Env): typeof fetch { if (url.pathname === '/api/hub/v1/objects/batch') { return invoke(batchPost, request, env) } + if (url.pathname === '/api/hub/v1/projects') { + if (request.method === 'GET') return invoke(projectsGet, request, env) + if (request.method === 'POST') return invoke(projectsPost, request, env) + } if (sidMatch) { const params = { sid: decodeURIComponent(sidMatch[1] as string) } const action = sidMatch[2] @@ -153,7 +161,7 @@ describe('full-stack round trip: CLI ↔ hub handlers ↔ reader derivation', () const shareErrors: string[] = [] const shareExit = await handleShareCommand( undefined, - { agentSummary: false, yes: true }, + { agentSummary: false, yes: true, createProject: 'Round-trip fixture' }, { fetch: fetchAdapter, homeDir: authorHome, @@ -166,6 +174,11 @@ describe('full-stack round trip: CLI ↔ hub handlers ↔ reader derivation', () sessionUuid: SESSION_UUID, filePath, cwd: authorWs, + projectIdentity: { + kind: 'path', + key: authorWs, + displayName: 'Round-trip fixture', + }, }), }, ) @@ -289,7 +302,7 @@ describe('full-stack round trip: CLI ↔ hub handlers ↔ reader derivation', () const codexSid = `codex_${SESSION_UUID}` const exit = await handleShareCommand( undefined, - { agentSummary: false, yes: true }, + { agentSummary: false, yes: true, createProject: 'Round-trip fixture' }, { fetch: fetchAdapter, homeDir: authorHome, @@ -304,6 +317,11 @@ describe('full-stack round trip: CLI ↔ hub handlers ↔ reader derivation', () sessionUuid: SESSION_UUID, filePath, cwd: authorWs, + projectIdentity: { + kind: 'path', + key: authorWs, + displayName: 'Round-trip fixture', + }, }), }, ) @@ -362,7 +380,7 @@ describe('full-stack round trip: CLI ↔ hub handlers ↔ reader derivation', () const exit = await handleShareCommand( `${SESSION_UUID}@2`, - { agentSummary: false, yes: true }, + { agentSummary: false, yes: true, createProject: 'Round-trip fixture' }, { fetch: fetchAdapter, homeDir: authorHome, @@ -377,6 +395,11 @@ describe('full-stack round trip: CLI ↔ hub handlers ↔ reader derivation', () sessionUuid: SESSION_UUID, filePath, cwd: authorWs, + projectIdentity: { + kind: 'path', + key: authorWs, + displayName: 'Round-trip fixture', + }, }), }, ) diff --git a/apps/backend/tests/hub-management.test.ts b/apps/backend/tests/hub-management.test.ts index e2b83936..efd178eb 100644 --- a/apps/backend/tests/hub-management.test.ts +++ b/apps/backend/tests/hub-management.test.ts @@ -24,6 +24,7 @@ const TEAM_SESSION: HubSessionRow = { total_tokens: null, visibility: 'private', team_id: 'team_1', + project_id: 'project_11111111', withdrawn_at: null, created_at: 1, updated_at: 2, @@ -40,6 +41,24 @@ function mockDatabase(feedRows: unknown[]) { return { all: vi.fn(async () => ({ results: feedRows })), first: vi.fn(async () => { + if (sql.includes('SELECT * FROM projects')) { + return { + id: TEAM_SESSION.project_id, + owner_user_id: null, + owner_team_id: 'team_1', + slug: 'paperboy', + name: 'Paperboy', + description: 'Paperboy product work.', + github_url: null, + created_by_user_id: 'author_1', + created_at: 1, + updated_at: 2, + archived_at: null, + } + } + if (sql.includes('SELECT t.name')) { + return { name: 'Paperboy', handle: 'paperboy' } + } if (sql.includes('SELECT name, avatar_url')) { return { name: 'Author', @@ -73,6 +92,8 @@ describe('Team Session management feed', () => { expect(sql).toContain('t.archived_at IS NULL') expect(sql).toContain('t.deletion_pending_until IS NULL') expect(sql).toContain('ON s.team_id=current_team.id AND s.withdrawn_at IS NULL') + expect(sql).toContain('counted.team_id=current_team.id') + expect(sql).toContain('counted.withdrawn_at IS NULL') expect(sql).toContain( '(SELECT COUNT(*) FROM hub_session_stars star WHERE star.sid=s.sid) AS star_count', ) @@ -86,6 +107,7 @@ describe('Team Session management feed', () => { await expect(listTeamHubSessions(authorized.db, 'team_1', 'user_1')).resolves.toEqual({ sessions: [], + session_count: 0, next_cursor: null, }) }) @@ -159,11 +181,63 @@ describe('Team Session management feed', () => { }) }) + it('hydrates author and Project metadata in the feed query without per-row lookups', async () => { + const authorized = mockDatabase([ + { + ...TEAM_SESSION, + team_name: 'Paperboy', + team_role: 'owner', + star_count: 3, + managed_published: 0, + managed_published_at: null, + managed_author_handle: 'author', + managed_author_name: 'Author', + managed_author_display_name: 'Author', + managed_author_avatar_url: null, + managed_author_custom_avatar_id: null, + managed_author_avatar_visible: 1, + managed_project_slug: 'paperboy', + managed_project_name: 'Paperboy', + managed_project_owner_user_id: null, + managed_project_owner_team_id: 'team_1', + managed_project_owner_handle: 'paperboy', + managed_project_owner_name: 'Paperboy', + managed_project_owner_avatar_url: null, + managed_project_owner_custom_avatar_id: null, + managed_project_owner_avatar_visible: 1, + }, + ]) + + const page = await listTeamHubSessions(authorized.db, 'team_1', 'user_1') + + expect(authorized.sqlLog).toHaveLength(1) + expect(authorized.sqlLog[0]).toContain('JOIN projects managed_project') + expect(authorized.sqlLog[0]).toContain('managed_project_owner_handle.handle') + expect(page?.sessions[0]).toMatchObject({ + author: { handle: 'author', display_name: 'Author' }, + project: { + id: TEAM_SESSION.project_id, + slug: 'paperboy', + name: 'Paperboy', + owner: { + kind: 'team', + id: 'team_1', + handle: 'paperboy', + name: 'Paperboy', + avatar_url: null, + }, + }, + }) + expect(page?.sessions[0]?.project).not.toHaveProperty('session_count') + expect(page?.sessions[0]?.project).not.toHaveProperty('can_manage') + }) + it('hides Sessions from archived or deletion-pending Teams in the owner feed', async () => { const owner = mockDatabase([]) await expect(listOwnerHubSessions(owner.db, 'user_1')).resolves.toEqual({ sessions: [], + session_count: 0, next_cursor: null, }) @@ -198,7 +272,7 @@ describe('Team Session management feed', () => { cursor: firstPage.next_cursor, limit: 1, }), - ).resolves.toEqual({ sessions: [], next_cursor: null }) + ).resolves.toEqual({ sessions: [], session_count: 0, next_cursor: null }) expect(next.bindLog[0]).toEqual(['user_1', 1, 20, 20, firstSid, 2]) }) @@ -249,7 +323,22 @@ describe('Team Session management feed', () => { cursor: page?.next_cursor ?? null, limit: 1, }), - ).resolves.toEqual({ sessions: [], next_cursor: null }) + ).resolves.toEqual({ sessions: [], session_count: 0, next_cursor: null }) + }) + + it('returns the total Team Session count independently of the current page', async () => { + const feed = mockDatabase([ + { + ...TEAM_SESSION, + team_name: 'Paperboy', + team_role: 'member', + session_count: 2_726, + }, + ]) + + await expect( + listTeamHubSessions(feed.db, 'team_1', 'user_1', { cursor: null, limit: 1 }), + ).resolves.toMatchObject({ session_count: 2_726 }) }) it('validates page size and duplicate query parameters', () => { diff --git a/apps/backend/tests/hub.test.ts b/apps/backend/tests/hub.test.ts index 4c8244df..19fa752e 100644 --- a/apps/backend/tests/hub.test.ts +++ b/apps/backend/tests/hub.test.ts @@ -1,5 +1,6 @@ import type { KVNamespace } from '@cloudflare/workers-types' import { + backupSessionRecord, canonicalizeRecord, composeSessionDiff, costForUsage, @@ -30,6 +31,7 @@ import type { SessionRecord } from '../src/auth/session' import { sha256Hex } from '../src/hub/auth' import { MAX_SESSION_GUIDANCE_BYTES, validateSessionGuidanceForHead } from '../src/hub/guidance' import { TEAM_QUOTA_BYTES } from '../src/hub/wire' +import { defaultProjectId } from '../src/projects/store' import { invoke } from './_helpers/ctx' import { emptyState, makeDb, makeKv, makeR2, type FakeDbState } from './_helpers/fakes' @@ -41,7 +43,22 @@ const USER_C_TOKEN = 'c'.repeat(40) const DEV_TOKEN = 'local-hub-dev-token' const TEAM_ID = `team_${'d'.repeat(32)}` -type Fixture = Awaited> +type Fixture = { + records: CanonicalRecord[] + view: CanonicalRecord + viewValue: SessionViewV1 + head: { + root: string + count: number + manifest: string[] + sig: null + cardJson: string + summaryMd: string + lineageJson: string | null + viewOid: string + } + entries: CanonicalRecord[] +} type TestEnv = ReturnType function envFor(options: { devToken?: string } = {}) { @@ -144,7 +161,7 @@ function syntheticRecords(): unknown[] { async function makeFixture(rawRecords: readonly unknown[] = syntheticRecords()) { const records = await Promise.all( rawRecords.map((record) => - canonicalizeRecord(JSON.stringify(record), { + backupSessionRecord(JSON.stringify(record), { workspaceRoot: '/workspace', homeDir: '/home/tester', }), @@ -167,6 +184,56 @@ async function makeFixture(rawRecords: readonly unknown[] = syntheticRecords()) return { records, view, viewValue, head, entries: [...records, view] } } +async function makeLegacyCanonicalFixture( + rawRecords: readonly unknown[] = syntheticRecords(), +): Promise { + const portable = await Promise.all( + rawRecords.map((record) => + canonicalizeRecord(JSON.stringify(record), { + workspaceRoot: '/workspace', + homeDir: '/home/tester', + }), + ), + ) + const records = await Promise.all( + portable.map(async (record) => { + const data = serializeLegacyCanonical(JSON.parse(record.data)) + return { data, oid: await sha256Hex(data) } + }), + ) + const viewValue = deriveView(records, { provider: 'claude' }) + const viewData = serializeLegacyCanonical(viewValue) + const view = { data: viewData, oid: await sha256Hex(viewData) } + const manifest = records.map((record) => record.oid) + const root = await sequenceRoot(manifest) + const head = { + root, + count: manifest.length, + manifest, + sig: null, + cardJson: JSON.stringify({ workspace: '$SPOOL_WS', branch: 'main' }), + summaryMd: '## Outcome\n\nA synthetic shared session.', + lineageJson: null, + viewOid: view.oid, + } + return { records, view, viewValue, head, entries: [...records, view] } +} + +function serializeLegacyCanonical(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'number') { + return JSON.stringify(value) + } + if (typeof value === 'string') return JSON.stringify(value) + if (Array.isArray(value)) { + return `[${value.map((item) => serializeLegacyCanonical(item)).join(',')}]` + } + const record = value as Record + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${serializeLegacyCanonical(record[key])}`) + .join(',')}}` +} + async function replaceFixtureView(fixture: Fixture, viewValue: unknown): Promise { const view = await canonicalizeRecord(JSON.stringify(viewValue)) fixture.view = view @@ -257,13 +324,54 @@ async function resumeGrant( async function changeVisibility( env: TestEnv, - body: { visibility: 'public' | 'link-only' | 'team'; team_id?: string | null }, + body: { + visibility: 'public' | 'link-only' | 'team' + team_id?: string | null + project_id?: string | null + expected_project_id?: string | null + }, token = USER_A_TOKEN, sid = SID, ) { + const current = env.state.hub_sessions.find((session) => session.sid === sid) + if (!current) throw new Error(`Missing test Session ${sid}`) + let requestBody = body + if (current.team_id == null && body.team_id) { + const projectId = defaultProjectId({ userId: null, teamId: body.team_id }) + if (!env.state.handles.some((handle) => handle.team_id === body.team_id)) { + env.state.handles.push({ + handle: 'launch-team', + user_id: null, + team_id: body.team_id, + claimed_at: Date.now(), + released_at: null, + }) + } + if (!env.state.projects.some((project) => project.id === projectId)) { + const now = Date.now() + env.state.projects.push({ + id: projectId, + owner_user_id: null, + owner_team_id: body.team_id, + slug: 'sessions', + name: 'Sessions', + description: null, + github_url: null, + created_by_user_id: current.owner_user_id, + created_at: now, + updated_at: now, + archived_at: null, + }) + } + requestBody = { + ...body, + project_id: body.project_id ?? projectId, + expected_project_id: body.expected_project_id ?? current.project_id ?? null, + } + } return invoke( visibilityPatch, - jsonPatch(`${BASE_URL}/api/me/sessions/${sid}`, body, token), + jsonPatch(`${BASE_URL}/api/me/sessions/${sid}`, requestBody, token), env, { sid }, ) @@ -844,6 +952,41 @@ describe('hub head and withdrawal', () => { }) }) + it('upgrades a legacy canonical head to byte-preserving records without changing its identity', async () => { + const env = envFor() + await seedUsers(env) + const legacy = await makeLegacyCanonicalFixture() + expect((await uploadAndCommit(env, legacy)).status).toBe(200) + env.state.hub_session_stars.push({ + sid: SID, + user_id: 'user-b', + created_at: Date.now(), + }) + const before = { ...env.state.hub_sessions[0]! } + const publishedAt = env.state.hub_session_discovery[0]?.published_at + + const bytePreserving = await makeFixture() + expect(bytePreserving.head.root).not.toBe(legacy.head.root) + const pushed = await push(env, bytePreserving) + expect(pushed.status).toBe(200) + expect((await upload(env, USER_A_TOKEN, bytePreserving.entries)).status).toBe(200) + const upgraded = await commit(env, bytePreserving) + + expect(upgraded.status).toBe(200) + expect(env.state.hub_sessions[0]).toMatchObject({ + sid: SID, + owner_user_id: before.owner_user_id, + project_id: before.project_id, + visibility: before.visibility, + lineage_json: before.lineage_json, + root: bytePreserving.head.root, + }) + expect(env.state.hub_session_stars).toEqual([ + expect.objectContaining({ sid: SID, user_id: 'user-b' }), + ]) + expect(env.state.hub_session_discovery[0]?.published_at).toBe(publishedAt) + }) + it('commits a portable-provider head as Link-only without a Discovery projection', async () => { const env = envFor() await seedUsers(env) @@ -1644,6 +1787,34 @@ describe('Team-only Hub isolation', () => { return env } + it('requires an explicit target Project and source Project expectation for ownership transfer', async () => { + const env = await teamEnv() + const fixture = await makeFixture() + expect((await uploadAndCommit(env, fixture)).status).toBe(200) + const sourceProjectId = env.state.hub_sessions[0]?.project_id + + const missingSelection = await invoke( + visibilityPatch, + jsonPatch( + `${BASE_URL}/api/me/sessions/${SID}`, + { visibility: 'team', team_id: TEAM_ID }, + USER_A_TOKEN, + ), + env, + { sid: SID }, + ) + + expect(missingSelection.status).toBe(422) + await expect(missingSelection.json()).resolves.toMatchObject({ + detail: 'Moving a Session to a Team requires project_id and expected_project_id', + }) + expect(env.state.hub_sessions[0]).toMatchObject({ + team_id: null, + project_id: sourceProjectId, + }) + expect(env.state.hub_team_objects).toHaveLength(0) + }) + it('returns a committed head when post-commit audit delivery fails', async () => { const env = await teamEnv() const fixture = await makeFixture() @@ -1716,6 +1887,10 @@ describe('Team-only Hub isolation', () => { await expect(memberMeta.json()).resolves.toMatchObject({ visibility: 'team', team: { id: TEAM_ID, name: 'Launch Team' }, + project: { + slug: 'sessions', + owner: { kind: 'team', id: TEAM_ID }, + }, }) const memberView = await invoke( @@ -1750,6 +1925,119 @@ describe('Team-only Hub isolation', () => { expect(removedMember.status).toBe(404) }) + it('keeps Team Project metadata member-only on Public and Link-only reads', async () => { + const env = await teamEnv() + const fixture = await makeFixture() + expect((await upload(env, USER_A_TOKEN, fixture.entries)).status).toBe(200) + + const published = await invoke( + headPost, + jsonPost( + `${sessionUrl(SID)}/head`, + { ...fixture.head, visibility: 'public', teamId: TEAM_ID }, + USER_A_TOKEN, + ), + env, + { sid: SID }, + ) + expect(published.status).toBe(200) + + const publicMeta = await invoke(metaGet, readRequest(sessionUrl(SID)), env, { sid: SID }) + expect(publicMeta.status).toBe(200) + expect(publicMeta.headers.get('cache-control')).toBe('private, no-store') + expect(publicMeta.headers.get('vary')).toBe('Cookie, Authorization') + await expect(publicMeta.json()).resolves.toMatchObject({ + visibility: 'public', + team: { id: TEAM_ID, name: 'Launch Team' }, + project: null, + }) + + const outsiderMeta = await invoke( + metaGet, + authenticatedReadRequest(sessionUrl(SID), USER_C_TOKEN), + env, + { sid: SID }, + ) + expect(outsiderMeta.status).toBe(200) + expect(outsiderMeta.headers.get('cache-control')).toBe('private, no-store') + await expect(outsiderMeta.json()).resolves.toMatchObject({ + visibility: 'public', + project: null, + }) + + const memberMeta = await invoke( + metaGet, + authenticatedReadRequest(sessionUrl(SID), USER_B_TOKEN), + env, + { sid: SID }, + ) + expect(memberMeta.status).toBe(200) + await expect(memberMeta.json()).resolves.toMatchObject({ + visibility: 'public', + project: { + slug: 'sessions', + owner: { kind: 'team', id: TEAM_ID }, + }, + }) + + const apiToken = 'team-project-cli-api-token' + env.state.api_tokens.push({ + id: 'team-project-cli-token-id', + user_id: 'user-a', + token_hash: await sha256Hex(apiToken), + label: 'cli', + created_at: Date.now(), + last_used_at: null, + }) + const cliMeta = await invoke( + metaGet, + new Request(sessionUrl(SID), { + headers: { authorization: `Bearer ${apiToken}` }, + }), + env, + { sid: SID }, + ) + expect(cliMeta.status).toBe(200) + await expect(cliMeta.json()).resolves.toMatchObject({ + visibility: 'public', + project: { + slug: 'sessions', + owner: { kind: 'team', id: TEAM_ID }, + }, + }) + + const madeLinkOnly = await changeVisibility( + env, + { visibility: 'link-only', team_id: TEAM_ID }, + USER_A_TOKEN, + ) + expect(madeLinkOnly.status).toBe(200) + + const linkOnlyMeta = await invoke(metaGet, readRequest(sessionUrl(SID)), env, { sid: SID }) + expect(linkOnlyMeta.status).toBe(200) + expect(linkOnlyMeta.headers.get('cache-control')).toBe('private, no-store') + await expect(linkOnlyMeta.json()).resolves.toMatchObject({ + visibility: 'link-only', + team: { id: TEAM_ID, name: 'Launch Team' }, + project: null, + }) + + const linkOnlyMemberMeta = await invoke( + metaGet, + authenticatedReadRequest(sessionUrl(SID), USER_B_TOKEN), + env, + { sid: SID }, + ) + expect(linkOnlyMemberMeta.status).toBe(200) + await expect(linkOnlyMemberMeta.json()).resolves.toMatchObject({ + visibility: 'link-only', + project: { + slug: 'sessions', + owner: { kind: 'team', id: TEAM_ID }, + }, + }) + }) + it('rejects stale personal/new tenant expectations in both push and head', async () => { const env = await teamEnv() const fixture = await makeFixture() diff --git a/apps/backend/tests/me.test.ts b/apps/backend/tests/me.test.ts index af68bef5..2113ff77 100644 --- a/apps/backend/tests/me.test.ts +++ b/apps/backend/tests/me.test.ts @@ -60,9 +60,8 @@ function envFor(state?: FakeDbState) { DB: db, SESSIONS: makeKv(), RATE: makeKv(), - // Handle claim/check are gated off at launch (profiles cut from - // scope). Behavior tests run with the gate open; the gated-off - // 404s have their own cases below. + // Owner routes are enabled by default. Behavior tests keep the explicit + // value for readability; emergency-off 404s have their own cases below. PROFILES_ENABLED: '1', state: s, } @@ -137,8 +136,8 @@ describe('requireUser', () => { }) describe('GET /api/handles/check', () => { - it('404 when PROFILES_ENABLED is not set (launch default)', async () => { - const env = { ...envFor(), PROFILES_ENABLED: '' } + it('404 when the emergency PROFILES_ENABLED off switch is set', async () => { + const env = { ...envFor(), PROFILES_ENABLED: '0' } const req = new Request('https://x/api/handles/check?h=alice') const res = await invoke(checkHandleGet, req, env) expect(res.status).toBe(404) @@ -184,8 +183,8 @@ describe('GET /api/handles/check', () => { }) describe('POST /api/handles/claim', () => { - it('404 when PROFILES_ENABLED is not set (launch default), even signed in', async () => { - const env = { ...envFor(), PROFILES_ENABLED: '' } + it('404 when the emergency PROFILES_ENABLED off switch is set, even signed in', async () => { + const env = { ...envFor(), PROFILES_ENABLED: '0' } seedUser(env.state) await seedSession(env.SESSIONS, TOKEN, 'user-1') const req = authedReq('https://x/api/handles/claim', { diff --git a/apps/backend/tests/projects-api.test.ts b/apps/backend/tests/projects-api.test.ts new file mode 100644 index 00000000..4614d302 --- /dev/null +++ b/apps/backend/tests/projects-api.test.ts @@ -0,0 +1,1217 @@ +import type { KVNamespace } from '@cloudflare/workers-types' +import { describe, expect, it } from 'vite-plus/test' + +import { + onRequestGet as hubProjectsGet, + onRequestPost as hubProjectsPost, +} from '../functions/api/hub/v1/projects' +import { onRequestPatch as personalProjectPatch } from '../functions/api/me/projects/[projectId]' +import { + onRequestGet as personalProjectsGet, + onRequestPost as personalProjectsPost, +} from '../functions/api/me/projects/index' +import { onRequestGet as ownerProjectGet } from '../functions/api/owners/[handle]/projects/[slug]' +import { onRequestGet as ownerProjectsGet } from '../functions/api/owners/[handle]/projects/index' +import { onRequestGet as publicProjectsGet } from '../functions/api/projects' +import { onRequestGet as teamProjectGet } from '../functions/api/teams/[teamId]/projects/[projectId]' +import { + onRequestGet as teamProjectsGet, + onRequestPost as teamProjectsPost, +} from '../functions/api/teams/[teamId]/projects/index' +import type { SessionRecord } from '../src/auth/session' +import { validateHead } from '../src/hub/head' +import { + MAX_ACTIVE_PROJECTS_PER_TENANT, + MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR, + MAX_PROJECTS_PER_TENANT, + PROJECT_CREATE_RATE, + PROJECT_LIST_RATE, +} from '../src/projects/limits' +import { prepareAuthorizedDefaultProjectInsert } from '../src/projects/store' +import { invoke } from './_helpers/ctx' +import { emptyState, makeDb, makeKv, makeR2 } from './_helpers/fakes' + +const BASE_URL = 'https://spool.example.test' +const USER_TOKEN = 'a'.repeat(40) +const MEMBER_TOKEN = 'b'.repeat(40) +const OUTSIDER_TOKEN = 'c'.repeat(40) +const TEAM_ID = `team_${'d'.repeat(32)}` + +function envFor() { + const { db, state } = makeDb(emptyState()) + const hub = makeR2() + return { + DB: db, + SESSIONS: makeKv(), + RATE: makeKv(), + HUB: hub.bucket, + state, + } +} + +type TestEnv = ReturnType + +function seedUser( + env: TestEnv, + id: string, + overrides: Partial = {}, +): void { + const now = Date.now() + env.state.users.push({ + id, + email: `${id}@example.test`, + name: id, + avatar_url: null, + created_at: now, + last_signin_at: now, + deletion_pending_until: null, + deleted_at: null, + ...overrides, + }) +} + +async function seedSession(kv: KVNamespace, token: string, userId: string): Promise { + const now = Date.now() + const session: SessionRecord = { + user_id: userId, + created: now, + exp: now + 60_000, + last_seen: now, + } + await kv.put(`session/${token}`, JSON.stringify(session), { expirationTtl: 60 }) +} + +async function seedActors(env: TestEnv): Promise { + seedUser(env, 'user-a') + seedUser(env, 'user-b') + seedUser(env, 'user-c') + await Promise.all([ + seedSession(env.SESSIONS, USER_TOKEN, 'user-a'), + seedSession(env.SESSIONS, MEMBER_TOKEN, 'user-b'), + seedSession(env.SESSIONS, OUTSIDER_TOKEN, 'user-c'), + ]) +} + +function jsonRequest( + path: string, + method: string, + body: unknown, + token = USER_TOKEN, + headers: Record = {}, +): Request { + const projectCreateHeaders = + method === 'POST' && + /\/projects$/.test(path) && + headers['idempotency-key'] === undefined && + headers['Idempotency-Key'] === undefined + ? { 'idempotency-key': `test-project-${crypto.randomUUID()}` } + : {} + return new Request(`${BASE_URL}${path}`, { + method, + headers: { + cookie: `spool_session=${token}`, + 'content-type': 'application/json', + ...projectCreateHeaders, + ...headers, + }, + body: JSON.stringify(body), + }) +} + +function getRequest(path: string, token?: string): Request { + return new Request(`${BASE_URL}${path}`, { + headers: token ? { cookie: `spool_session=${token}` } : {}, + }) +} + +async function createPersonalProject( + env: TestEnv, + body: Record = { name: 'Compiler work', slug: 'compiler-work' }, +): Promise> { + const response = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', body), + env, + ) + expect(response.status).toBe(201) + return (await response.json()) as Record +} + +describe('Projects API contract', () => { + it('creates and lists personal Projects with a non-null, non-email handle and fixed fields', async () => { + const env = envFor() + seedUser(env, 'user-a', { + email: 'private-address@example.test', + name: null, + display_name: null, + avatar_url: 'https://example.test/private.png', + avatar_visible: 0, + }) + await seedSession(env.SESSIONS, USER_TOKEN, 'user-a') + + const created = await createPersonalProject(env) + expect(created.project).toMatchObject({ + slug: 'compiler-work', + session_count: 0, + archived_at: null, + can_manage: true, + owner: { + kind: 'user', + id: 'user-a', + name: 'private-address', + avatar_url: null, + }, + }) + expect(created.project.owner.handle).toMatch(/^user-[a-z0-9]+$/) + expect(JSON.stringify(created)).not.toContain('@example.test') + + const listed = await invoke( + personalProjectsGet, + getRequest('/api/me/projects', USER_TOKEN), + env, + ) + expect(listed.status).toBe(200) + await expect(listed.json()).resolves.toMatchObject({ + projects: [ + { + id: created.project.id, + session_count: 0, + archived_at: null, + can_manage: true, + }, + ], + next_cursor: null, + }) + + const archived = await invoke( + personalProjectPatch, + jsonRequest(`/api/me/projects/${created.project.id}`, 'PATCH', { archived: true }), + env, + { projectId: created.project.id }, + ) + expect(archived.status).toBe(200) + await expect(archived.json()).resolves.toMatchObject({ + project: { archived_at: expect.any(Number), session_count: 0, can_manage: true }, + }) + }) + + it('atomically refuses to archive a Project after a Session is linked', async () => { + const env = envFor() + await seedActors(env) + const created = await createPersonalProject(env) + const projectId = created.project.id as string + env.state.hub_sessions.push(sessionRow(projectId)) + + const response = await invoke( + personalProjectPatch, + jsonRequest(`/api/me/projects/${projectId}`, 'PATCH', { archived: true }), + env, + { projectId }, + ) + expect(response.status).toBe(409) + expectPrivate(response) + await expect(response.json()).resolves.toMatchObject({ + error: 'CONFLICT', + detail: 'Move its Sessions before archiving', + }) + expect(env.state.projects.find((project) => project.id === projectId)?.archived_at).toBeNull() + }) + + it('archives a Project with only withdrawn Sessions, preserves history, and rejects a new head', async () => { + const env = envFor() + await seedActors(env) + const created = await createPersonalProject(env) + const projectId = created.project.id as string + const withdrawn = sessionRow(projectId, { withdrawn_at: 30 }) + env.state.hub_sessions.push(withdrawn) + + const response = await invoke( + personalProjectPatch, + jsonRequest(`/api/me/projects/${projectId}`, 'PATCH', { archived: true }), + env, + { projectId }, + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + project: { + id: projectId, + archived_at: expect.any(Number), + session_count: 0, + }, + }) + expect(env.state.hub_sessions[0]?.project_id).toBe(projectId) + + await expect( + validateHead(env.DB, 'user-a', withdrawn.sid, { + root: 'a'.repeat(64), + count: 1, + manifest: ['b'.repeat(64)], + sig: null, + cardJson: null, + summaryMd: null, + lineageJson: null, + viewOid: 'c'.repeat(64), + spoolFileOid: null, + }), + ).rejects.toMatchObject({ + code: 'CONFLICT', + detail: 'Session Project is archived or unavailable', + }) + }) + + it('keeps the fallback Project active and treats archive as a terminal transition', async () => { + const env = envFor() + await seedActors(env) + const created = await createPersonalProject(env) + const fallbackInsert = await prepareAuthorizedDefaultProjectInsert(env.DB, { + actorUserId: 'user-a', + tenant: { userId: 'user-a', teamId: null }, + now: Date.now(), + }).run() + expect(fallbackInsert.meta.changes).toBe(1) + + const fallbackId = 'project_default_user_user-a' + const archiveFallback = await invoke( + personalProjectPatch, + jsonRequest(`/api/me/projects/${fallbackId}`, 'PATCH', { archived: true }), + env, + { projectId: fallbackId }, + ) + expect(archiveFallback.status).toBe(409) + await expect(archiveFallback.json()).resolves.toMatchObject({ + error: 'CONFLICT', + detail: 'Default Project cannot be archived', + }) + expect(env.state.projects.find((project) => project.id === fallbackId)?.archived_at).toBeNull() + + const restoreActive = await invoke( + personalProjectPatch, + jsonRequest(`/api/me/projects/${created.project.id}`, 'PATCH', { archived: false }), + env, + { projectId: created.project.id as string }, + ) + expect(restoreActive.status).toBe(422) + await expect(restoreActive.json()).resolves.toMatchObject({ + error: 'UNPROCESSABLE', + detail: 'invalid Project update', + }) + + const legacy = envFor() + await seedActors(legacy) + await createPersonalProject(legacy) + legacy.state.projects.push( + projectRow({ + id: 'project_legacy_sessions', + slug: 'sessions', + name: 'Sessions', + ownerUserId: 'user-a', + ownerTeamId: null, + updatedAt: 2, + }), + ) + const archiveLegacyFallback = await invoke( + personalProjectPatch, + jsonRequest('/api/me/projects/project_legacy_sessions', 'PATCH', { archived: true }), + legacy, + { projectId: 'project_legacy_sessions' }, + ) + expect(archiveLegacyFallback.status).toBe(409) + await expect(archiveLegacyFallback.json()).resolves.toMatchObject({ + error: 'CONFLICT', + detail: 'Default Project cannot be archived', + }) + }) + + it('enforces the 100-active-Project quota in personal, Team, and Hub create paths', async () => { + const personal = envFor() + await seedActors(personal) + const firstPersonal = await createPersonalProject(personal) + seedProjectsToLimit(personal, { + ownerUserId: 'user-a', + ownerTeamId: null, + existing: 1, + }) + const personalResponse = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', { + name: 'Over quota', + slug: 'over-quota', + }), + personal, + ) + expect(personalResponse.status).toBe(409) + await expect(personalResponse.json()).resolves.toMatchObject({ + error: 'CONFLICT', + detail: `active Project limit reached (${MAX_ACTIVE_PROJECTS_PER_TENANT})`, + }) + expect(personal.state.projects).toHaveLength(MAX_ACTIVE_PROJECTS_PER_TENANT) + expect(firstPersonal.project.id).toBeTruthy() + const defaultAtCapacity = await prepareAuthorizedDefaultProjectInsert(personal.DB, { + actorUserId: 'user-a', + tenant: { userId: 'user-a', teamId: null }, + now: Date.now(), + }).run() + expect(defaultAtCapacity.meta.changes).toBe(0) + expect(personal.state.projects).toHaveLength(MAX_ACTIVE_PROJECTS_PER_TENANT) + + const team = envFor() + await seedActors(team) + team.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + team.state.team_memberships.push({ + team_id: TEAM_ID, + user_id: 'user-a', + role: 'owner', + }) + await invoke( + teamProjectsPost, + jsonRequest(`/api/teams/${TEAM_ID}/projects`, 'POST', { + name: 'First Team Project', + slug: 'first-team-project', + }), + team, + { teamId: TEAM_ID }, + ) + seedProjectsToLimit(team, { + ownerUserId: null, + ownerTeamId: TEAM_ID, + existing: 1, + }) + const teamResponse = await invoke( + teamProjectsPost, + jsonRequest(`/api/teams/${TEAM_ID}/projects`, 'POST', { + name: 'Team over quota', + slug: 'team-over-quota', + }), + team, + { teamId: TEAM_ID }, + ) + expect(teamResponse.status).toBe(409) + expect(team.state.projects).toHaveLength(MAX_ACTIVE_PROJECTS_PER_TENANT) + + const hub = envFor() + await seedActors(hub) + await createPersonalProject(hub) + seedProjectsToLimit(hub, { + ownerUserId: 'user-a', + ownerTeamId: null, + existing: 1, + }) + const hubResponse = await invoke( + hubProjectsPost, + jsonRequest( + '/api/hub/v1/projects', + 'POST', + { + owner: { kind: 'user', id: 'user-a' }, + name: 'Hub over quota', + slug: 'hub-over-quota', + }, + USER_TOKEN, + { 'idempotency-key': 'hub-project-over-quota' }, + ), + hub, + ) + expect(hubResponse.status).toBe(409) + expect(hub.state.projects).toHaveLength(MAX_ACTIVE_PROJECTS_PER_TENANT) + }) + + it('bounds archived Project history per tenant without deleting tombstones', async () => { + const env = envFor() + await seedActors(env) + await createPersonalProject(env) + env.state.projects[0]!.archived_at = 1 + for (let index = 1; index < MAX_PROJECTS_PER_TENANT; index += 1) { + env.state.projects.push( + projectRow({ + id: `project_history_${String(index).padStart(4, '0')}`, + slug: `history-${String(index).padStart(4, '0')}`, + name: `History ${index}`, + ownerUserId: 'user-a', + ownerTeamId: null, + updatedAt: index + 1, + archivedAt: index + 1, + }), + ) + } + + const response = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', { + name: 'Past history limit', + slug: 'past-history-limit', + }), + env, + ) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ + error: 'CONFLICT', + detail: `Project history limit reached (${MAX_PROJECTS_PER_TENANT})`, + }) + expect(env.state.projects).toHaveLength(MAX_PROJECTS_PER_TENANT) + + const fallbackAtCapacity = await prepareAuthorizedDefaultProjectInsert(env.DB, { + actorUserId: 'user-a', + tenant: { userId: 'user-a', teamId: null }, + now: Date.now(), + }).run() + expect(fallbackAtCapacity.meta.changes).toBe(0) + expect(env.state.projects).toHaveLength(MAX_PROJECTS_PER_TENANT) + }) + + it('bounds durable Project creation receipts per actor', async () => { + const env = envFor() + await seedActors(env) + await createPersonalProject(env) + for ( + let index = env.state.project_creation_requests.length; + index < MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR; + index += 1 + ) { + env.state.project_creation_requests.push({ + actor_user_id: 'user-a', + owner_scope: 'user:user-a', + owner_user_id: 'user-a', + owner_team_id: null, + idempotency_key: `historical-request-${index}`, + project_id: `project_historical_receipt_${index}`, + request_hash: String(index).padStart(64, '0'), + created_at: index, + }) + } + + const response = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', { + name: 'Past receipt limit', + slug: 'past-receipt-limit', + }), + env, + ) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ + error: 'CONFLICT', + detail: `Project creation receipt limit reached (${MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR})`, + }) + expect(env.state.projects).toHaveLength(1) + expect(env.state.project_creation_requests).toHaveLength( + MAX_PROJECT_CREATION_RECEIPTS_PER_ACTOR, + ) + }) + + it('enforces the Project description limit as 4 KiB of UTF-8', async () => { + const accepted = envFor() + await seedActors(accepted) + const acceptedResponse = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', { + name: 'Exact description', + slug: 'exact-description', + description: 'a'.repeat(4 * 1024), + }), + accepted, + ) + expect(acceptedResponse.status).toBe(201) + + const rejected = envFor() + await seedActors(rejected) + const rejectedResponse = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', { + name: 'Oversized description', + slug: 'oversized-description', + description: '中'.repeat(1_366), + }), + rejected, + ) + expect(rejectedResponse.status).toBe(422) + await expect(rejectedResponse.json()).resolves.toMatchObject({ + error: 'UNPROCESSABLE', + detail: 'invalid Project', + }) + }) + + it('paginates private Project lists stably and binds cursors to their scope', async () => { + const env = envFor() + await seedActors(env) + await createPersonalProject(env) + const original = env.state.projects[0]! + original.updated_at = 30 + env.state.projects.push( + projectRow({ + id: 'project_22222222', + slug: 'second', + name: 'Second', + ownerUserId: 'user-a', + ownerTeamId: null, + updatedAt: 20, + }), + projectRow({ + id: 'project_33333333', + slug: 'third', + name: 'Third', + ownerUserId: 'user-a', + ownerTeamId: null, + updatedAt: 10, + }), + ) + + const first = await invoke( + personalProjectsGet, + getRequest('/api/me/projects?limit=2', USER_TOKEN), + env, + ) + expect(first.status).toBe(200) + const firstBody = (await first.json()) as Record + expect(firstBody.projects.map((project: Record) => project.id)).toEqual([ + original.id, + 'project_22222222', + ]) + expect(firstBody.next_cursor).toEqual(expect.any(String)) + + const second = await invoke( + personalProjectsGet, + getRequest( + `/api/me/projects?limit=2&cursor=${encodeURIComponent(firstBody.next_cursor as string)}`, + USER_TOKEN, + ), + env, + ) + expect(second.status).toBe(200) + await expect(second.json()).resolves.toMatchObject({ + projects: [{ id: 'project_33333333' }], + next_cursor: null, + }) + + env.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + env.state.team_memberships.push({ + team_id: TEAM_ID, + user_id: 'user-a', + role: 'owner', + }) + const mismatched = await invoke( + teamProjectsGet, + getRequest( + `/api/teams/${TEAM_ID}/projects?limit=2&cursor=${encodeURIComponent( + firstBody.next_cursor as string, + )}`, + USER_TOKEN, + ), + env, + { teamId: TEAM_ID }, + ) + expect(mismatched.status).toBe(400) + }) + + it('rate-limits Project create and list entrypoints with the shared actor buckets', async () => { + const env = envFor() + await seedActors(env) + env.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + env.state.team_memberships.push({ + team_id: TEAM_ID, + user_id: 'user-a', + role: 'owner', + }) + const createSlot = Math.floor(Math.floor(Date.now() / 1000) / PROJECT_CREATE_RATE.windowSec) + await env.RATE.put( + `rate/${PROJECT_CREATE_RATE.bucket}/user-a/${createSlot}`, + String(PROJECT_CREATE_RATE.max), + ) + const webCreate = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', { name: 'Limited', slug: 'limited' }), + env, + ) + expect(webCreate.status).toBe(429) + const hubCreate = await invoke( + hubProjectsPost, + jsonRequest( + '/api/hub/v1/projects', + 'POST', + { + owner: { kind: 'user', id: 'user-a' }, + name: 'Limited Hub', + slug: 'limited-hub', + }, + USER_TOKEN, + { 'idempotency-key': 'limited-hub-project' }, + ), + env, + ) + expect(hubCreate.status).toBe(429) + const teamCreate = await invoke( + teamProjectsPost, + jsonRequest(`/api/teams/${TEAM_ID}/projects`, 'POST', { + name: 'Limited Team', + slug: 'limited-team', + }), + env, + { teamId: TEAM_ID }, + ) + expect(teamCreate.status).toBe(429) + + const listEnv = envFor() + await seedActors(listEnv) + listEnv.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + listEnv.state.team_memberships.push({ + team_id: TEAM_ID, + user_id: 'user-a', + role: 'owner', + }) + const listSlot = Math.floor(Math.floor(Date.now() / 1000) / PROJECT_LIST_RATE.windowSec) + await listEnv.RATE.put( + `rate/${PROJECT_LIST_RATE.bucket}/user-a/${listSlot}`, + String(PROJECT_LIST_RATE.max), + ) + const webList = await invoke( + personalProjectsGet, + getRequest('/api/me/projects', USER_TOKEN), + listEnv, + ) + expect(webList.status).toBe(429) + const hubList = await invoke( + hubProjectsGet, + getRequest('/api/hub/v1/projects', USER_TOKEN), + listEnv, + ) + expect(hubList.status).toBe(429) + const teamList = await invoke( + teamProjectsGet, + getRequest(`/api/teams/${TEAM_ID}/projects`, USER_TOKEN), + listEnv, + { teamId: TEAM_ID }, + ) + expect(teamList.status).toBe(429) + }) + + it('keeps Hub Project creation idempotent and enforces owner permissions', async () => { + const env = envFor() + await seedActors(env) + env.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + env.state.team_memberships.push( + { team_id: TEAM_ID, user_id: 'user-a', role: 'owner' }, + { team_id: TEAM_ID, user_id: 'user-b', role: 'member' }, + ) + + const body = { + owner: { kind: 'user', id: 'user-a' }, + name: 'Hub Project', + slug: 'hub-project', + } + const first = await invoke( + hubProjectsPost, + jsonRequest('/api/hub/v1/projects', 'POST', body, USER_TOKEN, { + 'idempotency-key': 'project-request-1', + }), + env, + ) + expect(first.status).toBe(201) + const firstBody = (await first.json()) as Record + + const replay = await invoke( + hubProjectsPost, + jsonRequest('/api/hub/v1/projects', 'POST', body, USER_TOKEN, { + 'idempotency-key': 'project-request-1', + }), + env, + ) + expect(replay.status).toBe(200) + await expect(replay.json()).resolves.toMatchObject({ + project: { + id: firstBody.project.id, + owner: { id: 'user-a', handle: expect.any(String) }, + session_count: 0, + archived_at: null, + can_manage: true, + }, + }) + expect(env.state.projects.filter((project) => project.slug === 'hub-project')).toHaveLength(1) + + const conflict = await invoke( + hubProjectsPost, + jsonRequest('/api/hub/v1/projects', 'POST', { ...body, name: 'Different' }, USER_TOKEN, { + 'idempotency-key': 'project-request-1', + }), + env, + ) + expect(conflict.status).toBe(409) + expectPrivate(conflict) + + const forbidden = await invoke( + hubProjectsPost, + jsonRequest( + '/api/hub/v1/projects', + 'POST', + { + owner: { kind: 'team', id: TEAM_ID }, + name: 'Member cannot create', + slug: 'member-cannot-create', + }, + MEMBER_TOKEN, + { 'idempotency-key': 'project-request-2' }, + ), + env, + ) + expect(forbidden.status).toBe(403) + expectPrivate(forbidden) + + const listed = await invoke(hubProjectsGet, getRequest('/api/hub/v1/projects', USER_TOKEN), env) + expect(listed.status).toBe(200) + await expect(listed.json()).resolves.toMatchObject({ + actor: { id: 'user-a' }, + projects: [ + { + id: firstBody.project.id, + owner: { handle: expect.any(String) }, + can_manage: true, + }, + ], + }) + }) + + it('replays personal and Team Project creates from the same durable receipt', async () => { + const env = envFor() + await seedActors(env) + const personalHeaders = { 'idempotency-key': 'personal-project-retry-1' } + const personalBody = { name: 'Retry safe', slug: 'retry-safe' } + const first = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', personalBody, USER_TOKEN, personalHeaders), + env, + ) + expect(first.status).toBe(201) + const firstBody = (await first.json()) as Record + const replay = await invoke( + personalProjectsPost, + jsonRequest('/api/me/projects', 'POST', personalBody, USER_TOKEN, personalHeaders), + env, + ) + expect(replay.status).toBe(200) + await expect(replay.json()).resolves.toMatchObject({ + project: { id: firstBody.project.id }, + }) + expect(env.state.projects).toHaveLength(1) + + const conflict = await invoke( + personalProjectsPost, + jsonRequest( + '/api/me/projects', + 'POST', + { ...personalBody, name: 'Different retry' }, + USER_TOKEN, + personalHeaders, + ), + env, + ) + expect(conflict.status).toBe(409) + + env.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + env.state.team_memberships.push({ + team_id: TEAM_ID, + user_id: 'user-a', + role: 'owner', + }) + const teamHeaders = { 'idempotency-key': 'team-project-retry-1' } + const teamBody = { name: 'Team retry safe', slug: 'team-retry-safe' } + const teamFirst = await invoke( + teamProjectsPost, + jsonRequest(`/api/teams/${TEAM_ID}/projects`, 'POST', teamBody, USER_TOKEN, teamHeaders), + env, + { teamId: TEAM_ID }, + ) + expect(teamFirst.status).toBe(201) + const teamFirstBody = (await teamFirst.json()) as Record + const teamReplay = await invoke( + teamProjectsPost, + jsonRequest(`/api/teams/${TEAM_ID}/projects`, 'POST', teamBody, USER_TOKEN, teamHeaders), + env, + { teamId: TEAM_ID }, + ) + expect(teamReplay.status).toBe(200) + await expect(teamReplay.json()).resolves.toMatchObject({ + project: { id: teamFirstBody.project.id }, + }) + expect(env.state.projects.filter((project) => project.owner_team_id === TEAM_ID)).toHaveLength( + 1, + ) + }) + + it('keeps Team Project APIs private and hides Team slug existence from outsiders', async () => { + const env = envFor() + await seedActors(env) + env.state.teams.push({ id: TEAM_ID, name: 'Paperboy', archived_at: null }) + env.state.team_memberships.push( + { team_id: TEAM_ID, user_id: 'user-a', role: 'owner' }, + { team_id: TEAM_ID, user_id: 'user-b', role: 'member' }, + ) + + const createdResponse = await invoke( + teamProjectsPost, + jsonRequest(`/api/teams/${TEAM_ID}/projects`, 'POST', { + name: 'Private Team Project', + slug: 'private-team-project', + }), + env, + { teamId: TEAM_ID }, + ) + expect(createdResponse.status).toBe(201) + expectPrivate(createdResponse) + const created = (await createdResponse.json()) as Record + expect(created.project.owner).toMatchObject({ + kind: 'team', + handle: expect.any(String), + avatar_url: null, + }) + + const memberList = await invoke( + teamProjectsGet, + getRequest(`/api/teams/${TEAM_ID}/projects`, MEMBER_TOKEN), + env, + { teamId: TEAM_ID }, + ) + expect(memberList.status).toBe(200) + expectPrivate(memberList) + await expect(memberList.json()).resolves.toMatchObject({ + projects: [ + { + id: created.project.id, + owner: { handle: expect.any(String) }, + session_count: 0, + archived_at: null, + can_manage: false, + }, + ], + next_cursor: null, + }) + + const memberDetail = await invoke( + teamProjectGet, + getRequest(`/api/teams/${TEAM_ID}/projects/${created.project.id}`, MEMBER_TOKEN), + env, + { teamId: TEAM_ID, projectId: created.project.id }, + ) + expect(memberDetail.status).toBe(200) + expectPrivate(memberDetail) + await expect(memberDetail.json()).resolves.toMatchObject({ + project: { id: created.project.id, can_manage: false }, + sessions: [], + next_cursor: null, + }) + + const handle = created.project.owner.handle as string + for (const slug of ['private-team-project', 'does-not-exist']) { + const anonymous = await invoke( + ownerProjectGet, + getRequest(`/api/owners/${handle}/projects/${slug}`), + env, + { handle, slug }, + ) + expect(anonymous.status).toBe(404) + expectPrivate(anonymous) + + const outsider = await invoke( + ownerProjectGet, + getRequest(`/api/owners/${handle}/projects/${slug}`, OUTSIDER_TOKEN), + env, + { handle, slug }, + ) + expect(outsider.status).toBe(404) + expectPrivate(outsider) + } + + const memberProfile = await invoke( + ownerProjectsGet, + getRequest(`/api/owners/${handle}/projects`, MEMBER_TOKEN), + env, + { handle }, + ) + expect(memberProfile.status).toBe(200) + expectPrivate(memberProfile) + await expect(memberProfile.json()).resolves.toMatchObject({ + owner: { kind: 'team', handle }, + projects: [{ id: created.project.id }], + session_count: 0, + sessions: [], + }) + }) + + it('returns public personal Projects and Sessions while only the author can manage them', async () => { + const env = envFor() + seedUser(env, 'user-a', { + email: 'reader-private@example.test', + name: null, + display_name: null, + custom_avatar_id: 'avatar-v1', + avatar_visible: 1, + }) + await seedSession(env.SESSIONS, USER_TOKEN, 'user-a') + const created = await createPersonalProject(env) + const projectId = created.project.id as string + env.state.hub_sessions.push(sessionRow(projectId)) + env.state.hub_session_discovery.push(discoveryRow()) + const handle = created.project.owner.handle as string + + const anonymous = await invoke( + ownerProjectsGet, + getRequest(`/api/owners/${handle}/projects`), + env, + { handle }, + ) + expect(anonymous.status).toBe(200) + const anonymousBody = (await anonymous.json()) as Record + expect(anonymousBody).toMatchObject({ + owner: { + handle, + name: 'reader-private', + avatar_url: '/api/avatars/user-a?v=avatar-v1', + }, + projects: [ + { + id: projectId, + session_count: 1, + archived_at: null, + can_manage: false, + }, + ], + sessions: [ + { + sid: 'claude_project-session-1', + published_at: 10, + updated_at: 20, + }, + ], + session_count: 1, + }) + expect(JSON.stringify(anonymousBody)).not.toContain('@example.test') + + const author = await invoke( + ownerProjectsGet, + getRequest(`/api/owners/${handle}/projects`, USER_TOKEN), + env, + { handle }, + ) + expect(author.status).toBe(200) + await expect(author.json()).resolves.toMatchObject({ + projects: [{ id: projectId, can_manage: true }], + }) + + const single = await invoke( + ownerProjectGet, + getRequest(`/api/owners/${handle}/projects/compiler-work`, USER_TOKEN), + env, + { handle, slug: 'compiler-work' }, + ) + expect(single.status).toBe(200) + await expect(single.json()).resolves.toMatchObject({ + project: { id: projectId, can_manage: true }, + sessions: [{ sid: 'claude_project-session-1' }], + next_cursor: null, + }) + + const global = await invoke(publicProjectsGet, getRequest('/api/projects'), env) + expect(global.status).toBe(200) + await expect(global.json()).resolves.toMatchObject({ + projects: [ + { + id: projectId, + owner: { handle, avatar_url: '/api/avatars/user-a?v=avatar-v1' }, + session_count: 1, + archived_at: null, + can_manage: false, + }, + ], + next_cursor: null, + }) + }) + + it('keeps an empty personal Project canonical while excluding it from the public directory', async () => { + const env = envFor() + await seedActors(env) + const created = await createPersonalProject(env) + const handle = created.project.owner.handle as string + + const canonical = await invoke( + ownerProjectGet, + getRequest(`/api/owners/${handle}/projects/compiler-work`), + env, + { handle, slug: 'compiler-work' }, + ) + expect(canonical.status).toBe(200) + await expect(canonical.json()).resolves.toMatchObject({ + project: { + id: created.project.id, + session_count: 0, + can_manage: false, + }, + sessions: [], + next_cursor: null, + }) + + const directory = await invoke(publicProjectsGet, getRequest('/api/projects'), env) + expect(directory.status).toBe(200) + await expect(directory.json()).resolves.toMatchObject({ + projects: [], + next_cursor: null, + }) + }) + + it('paginates public Project Sessions with an opaque cursor bound to that Project', async () => { + const env = envFor() + await seedActors(env) + const created = await createPersonalProject(env) + const projectId = created.project.id as string + const handle = created.project.owner.handle as string + env.state.hub_sessions.push( + sessionRow(projectId), + sessionRow(projectId, { + sid: 'claude_project-session-2', + created_at: 9, + updated_at: 19, + }), + ) + env.state.hub_session_discovery.push( + discoveryRow(), + discoveryRow({ + sid: 'claude_project-session-2', + published_at: 9, + updated_at: 19, + }), + ) + + const first = await invoke( + ownerProjectGet, + getRequest(`/api/owners/${handle}/projects/compiler-work?limit=1`), + env, + { handle, slug: 'compiler-work' }, + ) + expect(first.status).toBe(200) + const firstBody = (await first.json()) as Record + expect(firstBody.sessions.map((session: Record) => session.sid)).toEqual([ + 'claude_project-session-1', + ]) + expect(firstBody.sessions[0]).toMatchObject({ published_at: 10, updated_at: 20 }) + expect(firstBody.project.session_count).toBe(2) + expect(firstBody.next_cursor).toEqual(expect.any(String)) + + const second = await invoke( + ownerProjectGet, + getRequest( + `/api/owners/${handle}/projects/compiler-work?limit=1&cursor=${encodeURIComponent( + firstBody.next_cursor as string, + )}`, + ), + env, + { handle, slug: 'compiler-work' }, + ) + expect(second.status).toBe(200) + await expect(second.json()).resolves.toMatchObject({ + project: { id: projectId, session_count: 2 }, + sessions: [{ sid: 'claude_project-session-2', published_at: 9, updated_at: 19 }], + next_cursor: null, + }) + }) +}) + +function expectPrivate(response: Response): void { + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('vary')).toContain('Cookie') + expect(response.headers.get('vary')).toContain('Authorization') +} + +function projectRow(args: { + id: string + slug: string + name: string + ownerUserId: string | null + ownerTeamId: string | null + updatedAt: number + archivedAt?: number | null +}): TestEnv['state']['projects'][number] { + return { + id: args.id, + owner_user_id: args.ownerUserId, + owner_team_id: args.ownerTeamId, + slug: args.slug, + name: args.name, + description: null, + github_url: null, + created_by_user_id: 'user-a', + created_at: Math.min(1, args.updatedAt), + updated_at: args.updatedAt, + archived_at: args.archivedAt ?? null, + } +} + +function seedProjectsToLimit( + env: TestEnv, + args: { + ownerUserId: string | null + ownerTeamId: string | null + existing: number + }, +): void { + for (let index = args.existing; index < MAX_ACTIVE_PROJECTS_PER_TENANT; index += 1) { + env.state.projects.push( + projectRow({ + id: `project_quota_${String(index).padStart(3, '0')}`, + slug: `quota-${String(index).padStart(3, '0')}`, + name: `Quota ${index}`, + ownerUserId: args.ownerUserId, + ownerTeamId: args.ownerTeamId, + updatedAt: index + 1, + }), + ) + } +} + +function sessionRow( + projectId: string, + overrides: Partial = {}, +): TestEnv['state']['hub_sessions'][number] { + return { + sid: 'claude_project-session-1', + owner_user_id: 'user-a', + root: 'a'.repeat(64), + record_count: 2, + sig: null, + card_json: JSON.stringify({ title: 'Project Session' }), + note_md: 'Background and completed outcome.', + lineage_json: null, + view_oid: 'b'.repeat(64), + spool_file_oid: null, + cost_usd: 0.25, + total_tokens: 1_000, + visibility: 'unlisted', + team_id: null, + project_id: projectId, + withdrawn_at: null, + created_at: 10, + updated_at: 20, + ...overrides, + } +} + +function discoveryRow( + overrides: Partial = {}, +): TestEnv['state']['hub_session_discovery'][number] { + return { + sid: 'claude_project-session-1', + agent: 'claude', + title: 'Project Session', + title_json: null, + cost_usd: 0.25, + total_tokens: 1_000, + summary_text: 'Background and completed outcome.', + summary_text_zh: null, + search_text: 'project session', + message_count: 2, + tool_call_count: 1, + file_count: 1, + additions: 3, + deletions: 1, + lineage_source_sid: null, + quality_score: 1, + published_at: 10, + updated_at: 20, + ...overrides, + } +} diff --git a/apps/backend/tests/teams-api.test.ts b/apps/backend/tests/teams-api.test.ts index 47c17278..9a8e1e58 100644 --- a/apps/backend/tests/teams-api.test.ts +++ b/apps/backend/tests/teams-api.test.ts @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' const mocks = vi.hoisted(() => ({ audit: vi.fn(), assertCanManageMember: vi.fn(), + assertHandleAvailable: vi.fn(), + chooseAvailableTeamHandle: vi.fn(), + changeTeamHandle: vi.fn(), requireUser: vi.fn(), requireTeamAccess: vi.fn(), countActiveTeamsCreatedByUser: vi.fn(), @@ -36,6 +39,7 @@ const mocks = vi.hoisted(() => ({ updateLocalTeamName: vi.fn(), updateInvitationProjection: vi.fn(), archiveLocalTeam: vi.fn(), + adoptTeamCreationHandle: vi.fn(), beginTeamCreationRequest: vi.fn(), completeTeamCreationRequest: vi.fn(), failTeamCreationRequest: vi.fn(), @@ -65,12 +69,19 @@ const mocks = vi.hoisted(() => ({ vi.mock('../src/audit', () => ({ audit: mocks.audit })) vi.mock('../src/auth/require', () => ({ requireUser: mocks.requireUser })) +vi.mock('../src/handles', async (importOriginal) => ({ + ...(await importOriginal()), + assertHandleAvailable: mocks.assertHandleAvailable, + chooseAvailableTeamHandle: mocks.chooseAvailableTeamHandle, + changeTeamHandle: mocks.changeTeamHandle, +})) vi.mock('../src/store/d1', () => ({ getUserById: mocks.getUserById })) vi.mock('../src/teams/auth', () => ({ assertCanManageMember: mocks.assertCanManageMember, requireTeamAccess: mocks.requireTeamAccess, })) vi.mock('../src/teams/store', () => ({ + adoptTeamCreationHandle: mocks.adoptTeamCreationHandle, beginTeamInvitationCreationRequest: mocks.beginTeamInvitationCreationRequest, beginTeamCreationRequest: mocks.beginTeamCreationRequest, completeTeamInvitationCreationRequest: mocks.completeTeamInvitationCreationRequest, @@ -141,6 +152,7 @@ const TEAM_ID = 'team_00000000000000000000000000000000' const INVITATION_ID = 'tinv_00000000000000000000000000000000' const USER_ID = 'user_0000000000000000' const MEMBER_ID = 'member_000000000000' +const TEAM_HANDLE = 'original-team-0000000000' const TEAM_ROW = { id: TEAM_ID, workos_organization_id: 'org_1', @@ -191,6 +203,9 @@ beforeEach(() => { }, }) mocks.countActiveTeamsCreatedByUser.mockResolvedValue(0) + mocks.assertHandleAvailable.mockResolvedValue(undefined) + mocks.chooseAvailableTeamHandle.mockResolvedValue(TEAM_HANDLE) + mocks.changeTeamHandle.mockResolvedValue(undefined) mocks.countPendingTeamInvitations.mockResolvedValue(0) mocks.createLocalInvitation.mockResolvedValue(true) mocks.createLocalTeam.mockResolvedValue(true) @@ -203,12 +218,26 @@ beforeEach(() => { idempotency_key: args.idempotencyKey, team_id: args.teamId, normalized_name: args.name, + requested_handle: args.requestedHandle, status: 'pending', workos_organization_id: null, created_at: args.now, updated_at: args.now, }, })) + mocks.adoptTeamCreationHandle.mockImplementation( + async (_db, userId, idempotencyKey, requestedHandle, now) => ({ + user_id: userId, + idempotency_key: idempotencyKey, + team_id: TEAM_ID, + normalized_name: 'Original', + requested_handle: requestedHandle, + status: 'pending', + workos_organization_id: null, + created_at: now, + updated_at: now, + }), + ) mocks.completeTeamCreationRequest.mockResolvedValue(undefined) mocks.failTeamCreationRequest.mockResolvedValue(true) mocks.recordTeamCreationOrganization.mockResolvedValue(undefined) @@ -218,6 +247,7 @@ beforeEach(() => { role: 'owner', permissions: ['team:update', 'team:archive'], member_count: 1, + handle: TEAM_HANDLE, archived_at: null, }) mocks.getWorkosUserId.mockResolvedValue('workos_owner') @@ -506,12 +536,88 @@ describe('WorkOS compensation', () => { }) describe('Team creation idempotency', () => { + it('rejects an occupied explicit handle before writing a receipt or calling WorkOS', async () => { + mocks.assertHandleAvailable.mockRejectedValue(new ApiError('CONFLICT', 'handle taken')) + + const response = await invoke( + createTeam, + new Request('https://spool.new/api/teams', { + method: 'POST', + headers: { 'idempotency-key': 'team-create-handle-taken-0001' }, + body: JSON.stringify({ name: 'Original', handle: 'taken-handle' }), + }), + env(), + ) + + expect(response.status).toBe(409) + expect(mocks.beginTeamCreationRequest).not.toHaveBeenCalled() + expect(mocks.client.createOrganization).not.toHaveBeenCalled() + expect(mocks.client.createMembership).not.toHaveBeenCalled() + }) + + it('commits the selected handle in the same local operation as the Team', async () => { + mocks.getTeamForMember.mockResolvedValue({ + id: TEAM_ID, + name: 'Original', + role: 'owner', + permissions: ['team:update', 'team:archive'], + member_count: 1, + handle: 'atomic-handle', + archived_at: null, + }) + const response = await invoke( + createTeam, + new Request('https://spool.new/api/teams', { + method: 'POST', + headers: { 'idempotency-key': 'team-create-atomic-handle-0001' }, + body: JSON.stringify({ name: 'Original', handle: 'atomic-handle' }), + }), + env(), + ) + + expect(response.status).toBe(201) + expect(mocks.createLocalTeam).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ requestedHandle: 'atomic-handle' }), + ) + expect(mocks.changeTeamHandle).not.toHaveBeenCalled() + }) + + it('rejects the same idempotency key when its recorded handle intent differs', async () => { + mocks.getTeamCreationRequest.mockResolvedValue({ + user_id: USER_ID, + idempotency_key: 'team-create-handle-intent-0001', + team_id: TEAM_ID, + normalized_name: 'Original', + requested_handle: 'first-handle', + status: 'pending', + workos_organization_id: null, + created_at: 1, + updated_at: 1, + }) + + const response = await invoke( + createTeam, + new Request('https://spool.new/api/teams', { + method: 'POST', + headers: { 'idempotency-key': 'team-create-handle-intent-0001' }, + body: JSON.stringify({ name: 'Original', handle: 'second-handle' }), + }), + env(), + ) + + expect(response.status).toBe(409) + expect(mocks.client.createOrganization).not.toHaveBeenCalled() + expect(mocks.client.createMembership).not.toHaveBeenCalled() + }) + it('replays a completed browser operation without another WorkOS mutation', async () => { mocks.getTeamCreationRequest.mockResolvedValue({ user_id: USER_ID, idempotency_key: 'team-create-replay-0001', team_id: TEAM_ID, normalized_name: 'Original', + requested_handle: TEAM_HANDLE, status: 'completed', workos_organization_id: 'org_1', created_at: 1, @@ -529,6 +635,7 @@ describe('Team creation idempotency', () => { expect(response.status).toBe(200) expect(mocks.client.createOrganization).not.toHaveBeenCalled() expect(mocks.client.createMembership).not.toHaveBeenCalled() + expect(mocks.changeTeamHandle).not.toHaveBeenCalled() }) it('treats a same-key concurrent local commit as success and never deletes its Organization', async () => { @@ -548,6 +655,103 @@ describe('Team creation idempotency', () => { expect(mocks.failTeamCreationRequest).not.toHaveBeenCalled() expect(mocks.client.deleteOrganization).not.toHaveBeenCalled() }) + + it('compensates WorkOS when another operation wins the atomic handle claim', async () => { + mocks.createLocalTeam.mockRejectedValue(new Error('UNIQUE constraint failed: handles.handle')) + mocks.getTeamById.mockResolvedValue(null) + + const response = await invoke( + createTeam, + new Request('https://spool.new/api/teams', { + method: 'POST', + headers: { 'idempotency-key': 'team-create-handle-race-0001' }, + body: JSON.stringify({ name: 'Original', handle: 'racing-handle' }), + }), + env(), + ) + + expect(response.status).toBe(409) + expect(mocks.failTeamCreationRequest).toHaveBeenCalled() + expect(mocks.client.deleteOrganization).toHaveBeenCalledWith('org_1') + expect(mocks.completeWorkosCleanup).toHaveBeenCalledWith( + expect.anything(), + 'organization.delete', + 'org_1', + ) + }) + + it('repairs a pre-handle receipt once and then completes it', async () => { + mocks.getTeamCreationRequest.mockResolvedValue({ + user_id: USER_ID, + idempotency_key: 'team-create-legacy-handle-0001', + team_id: TEAM_ID, + normalized_name: 'Original', + requested_handle: null, + status: 'pending', + workos_organization_id: 'org_1', + created_at: 1, + updated_at: 1, + }) + mocks.adoptTeamCreationHandle.mockResolvedValue({ + user_id: USER_ID, + idempotency_key: 'team-create-legacy-handle-0001', + team_id: TEAM_ID, + normalized_name: 'Original', + requested_handle: TEAM_HANDLE, + status: 'pending', + workos_organization_id: 'org_1', + created_at: 1, + updated_at: 2, + }) + const withoutHandle = { + id: TEAM_ID, + name: 'Original', + role: 'owner', + permissions: ['team:update', 'team:archive'], + member_count: 1, + handle: null, + archived_at: null, + } + const withHandle = { ...withoutHandle, handle: TEAM_HANDLE } + mocks.getTeamForMember + .mockResolvedValueOnce(withoutHandle) + .mockResolvedValueOnce(withoutHandle) + .mockResolvedValueOnce(withHandle) + + const response = await invoke( + createTeam, + new Request('https://spool.new/api/teams', { + method: 'POST', + headers: { 'idempotency-key': 'team-create-legacy-handle-0001' }, + body: JSON.stringify({ name: 'Original' }), + }), + env(), + ) + + expect(response.status).toBe(200) + expect(mocks.adoptTeamCreationHandle).toHaveBeenCalledWith( + expect.anything(), + USER_ID, + 'team-create-legacy-handle-0001', + TEAM_HANDLE, + expect.any(Number), + ) + expect(mocks.changeTeamHandle).toHaveBeenCalledWith(expect.anything(), { + teamId: TEAM_ID, + actorUserId: USER_ID, + handle: TEAM_HANDLE, + now: expect.any(Number), + }) + expect(mocks.completeTeamCreationRequest).toHaveBeenCalledWith( + expect.anything(), + USER_ID, + 'team-create-legacy-handle-0001', + TEAM_ID, + TEAM_HANDLE, + expect.any(Number), + ) + expect(mocks.client.createOrganization).not.toHaveBeenCalled() + }) }) describe('Team invitation idempotency', () => { diff --git a/apps/backend/tests/teams-store.test.ts b/apps/backend/tests/teams-store.test.ts index 8fe72a82..cc45e0b2 100644 --- a/apps/backend/tests/teams-store.test.ts +++ b/apps/backend/tests/teams-store.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test' import { archiveLocalTeam, + beginTeamCreationRequest, createLocalInvitation, + createLocalTeam, failTeamCreationRequest, failTeamInvitationCreationRequest, insertInvitationProjection, @@ -245,6 +247,80 @@ describe('Team permissions', () => { }) }) +describe('Team creation transaction', () => { + it('persists immutable handle intent on the idempotency receipt', async () => { + const request = { + user_id: 'local_owner', + idempotency_key: 'team-create-store-0001', + team_id: TEAM.id, + normalized_name: TEAM.name, + requested_handle: 'team-handle', + status: 'pending' as const, + workos_organization_id: null, + created_at: 100, + updated_at: 100, + } + const { db, prepared } = fakeDb((sql) => + sql.includes('/* teams:get-creation-request */') ? request : null, + ) + + await expect( + beginTeamCreationRequest(db, { + userId: 'local_owner', + idempotencyKey: request.idempotency_key, + teamId: TEAM.id, + name: TEAM.name, + requestedHandle: request.requested_handle, + now: 100, + }), + ).resolves.toEqual({ created: true, request }) + + const begin = prepared.find((statement) => + statement.sql.includes('/* teams:begin-creation-request */'), + ) + expect(begin?.sql).toContain('requested_handle') + expect(begin?.params).toContain(request.requested_handle) + }) + + it('creates Team, owner, handle, and completed receipt in one D1 batch', async () => { + const { db, batches } = fakeDb() + + await expect( + createLocalTeam(db, { + id: TEAM.id, + name: TEAM.name, + workosOrganizationId: TEAM.workos_organization_id, + workosMembershipId: 'membership_owner', + workosMembershipUpdatedAt: 50, + idempotencyKey: 'team-create-store-0002', + ownerUserId: 'local_owner', + requestedHandle: 'team-handle', + now: 100, + }), + ).resolves.toBe(true) + + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(4) + const create = batches[0]!.find((statement) => statement.sql.includes('/* teams:create */')) + const owner = batches[0]!.find((statement) => + statement.sql.includes('/* teams:create-owner */'), + ) + const handle = batches[0]!.find((statement) => + statement.sql.includes('/* teams:create-handle */'), + ) + const complete = batches[0]!.find((statement) => + statement.sql.includes('/* teams:complete-creation-request */'), + ) + expect(create?.sql).toContain('requested_handle=?') + expect(create?.sql).toContain('workos_organization_id=?') + expect(owner?.sql).toContain('INSERT INTO team_memberships') + expect(handle?.sql).toContain('INSERT INTO handles') + expect(handle?.params[0]).toBe('team-handle') + expect(complete?.sql).toContain('h.handle=?') + expect(complete?.params.at(-1)).toBe('team-handle') + }) +}) + describe('Team membership removal tombstones', () => { it('stores both the local and WorkOS user ids in the block', async () => { const { db, batches } = fakeDb((sql) => diff --git a/apps/cli/README.md b/apps/cli/README.md index 08f4d51f..854cdd81 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -30,18 +30,21 @@ spool logout ## Continuous Publishing ```bash -spool subscribe [dir] --team # Team · {name}: current members only +spool subscribe [dir] --team # Team · {name}: current members only spool subscribe [dir] --link-only # anyone with the URL spool subscribe [dir] --public # explicit opt-in to Explore/search spool unsubscribe [dir] spool subscriptions spool teams # list the Teams you belong to +spool projects list # list writable personal and Team Projects +spool projects bind [dir] --project +spool projects move --project spool daemon start|stop|status|logs|run ``` -`--team` accepts a Team name (or id when two Teams share a name); `spool teams` shows exactly the names to use. +`--team` accepts a stable Team handle, an id, or a unique Team name. When names collide, Spool fails closed and asks for a handle or id. -Subscribing a directory is the one-time disclosure decision: from then on, the daemon publishes new and updated Sessions recorded in that directory — including its git worktrees and worktrees managed by tools like superset or orca — without prompting. The disclosure target is always an explicit choice among `Team · {name}`, Link-only, and Public; **there is no implicit default and Public is never preselected**. Interactive `spool subscribe` offers your Teams first. +Subscribing a directory is the one-time disclosure and Project decision: from then on, the daemon publishes new and updated Sessions recorded in that directory — including its git worktrees and worktrees managed by tools like superset or orca — without prompting. The disclosure target is always an explicit choice among `Team · {name}`, Link-only, and Public; **there is no implicit default and Public is never preselected**. Every hosted Session also belongs to one personal or Team Project. Interactive `spool subscribe` offers a Project or can create one. Automation must pass `--project ` or `--create-project ` unless an account-, tenant-, Hub-, and local-Project-specific binding already exists; `--yes` never chooses a Project. `spool daemon start` registers the watcher with launchd (macOS) or a systemd user unit (Linux) so it runs at login and restarts on failure. `spool daemon run` is the same loop in the foreground. @@ -64,11 +67,18 @@ Useful publishing options: spool share @12 # first 12 records spool share --no-agent-summary # skip local Agent generation spool share --spool-file x.spool # attach a curated document +spool share --project evan/spool # bind this local Project to an existing Hub Project +spool share --create-project Spool # create and bind a Project +spool share --team paperboy --project paperboy/react-vapor # Team-only from the first upload spool share --visibility-confirmed # acknowledge visibility without a TTY spool share --yes # skip all confirmations, including secret findings ``` -For the normal interactive flow, omit Summary options. After the Session URL is live, Spool can ask a detected local Agent to draft the optional Summary. `--summary ` is an advanced manual or automation input: it uploads exactly the Markdown supplied by the caller and does not generate a Summary. +For the normal interactive flow, omit Summary options. Spool resolves the exact local Project joined to the selected Session—even when the command is run from another directory—and asks for a Hub Project the first time. Re-sharing keeps the Session's existing remote Project. After the Session URL is live, Spool can ask a detected local Agent to draft the optional Summary. `--summary ` is an advanced manual or automation input: it uploads exactly the Markdown supplied by the caller and does not generate a Summary. + +`--team ` is the direct one-off Team path. It requires a Team-owned Project (explicitly or through an existing Team binding), names the Team and ownership transfer in the confirmation, shows the selected Project before upload, and writes `Team · {name}` on the first Hub head—there is no intermediate Public Session. + +Move an already-hosted Session between Projects owned by the same user or Team with `spool projects move --project `. The command sends the current Project as an optimistic precondition and never changes records, visibility, authorship, stars, or verified-fork lineage. Use `spool visibility … team` instead when the tenant itself must change. For Claude Code and Codex CLI shares, Resume verifies the shared records, writes a new provider-native Session, preserves the source relationship, and launches the agent. Use `--workspace ` to choose the project or `--no-exec` to prepare without launching. @@ -77,7 +87,8 @@ For Claude Code and Codex CLI shares, Resume verifies the shared records, writes ```bash spool visibility public # Team → Public keeps Team ownership spool visibility link-only -spool visibility team --team +spool visibility team --team --project +spool visibility team --team --create-project ``` Disclosure changes are named, confirmed actions and never re-upload records. Changing a Team-owned Session requires a Team Owner or Admin role; moving a personal Session into a Team transfers ownership to the Team. The same control exists on spool.new under your account's Sessions list. @@ -115,6 +126,7 @@ The CLI uses `~/.spool/` by default: - `spool.db` — local Session metadata, messages, search, and state - `hub-credentials.json` — revocable Hub credential from `spool login` - `subscriptions.json` — directories subscribed for continuous publishing +- `project-bindings.json` — `0600` local-to-Hub Project bindings scoped by Hub, account, and tenant - `auto-publish-state.json` — per-Session fingerprints that keep auto-publish incremental - `daemon.json` / `daemon.log` — daemon heartbeat and log diff --git a/apps/cli/src/cli.test.ts b/apps/cli/src/cli.test.ts index 90386cce..54c2cb60 100644 --- a/apps/cli/src/cli.test.ts +++ b/apps/cli/src/cli.test.ts @@ -275,6 +275,7 @@ describe('cli entry point', () => { expect(out).toContain('Usage: spool') expect(out).toContain('subscribe') expect(out).toContain('teams') + expect(out).toContain('projects') expect(out).toContain('daemon') expect(out).toContain('sessions') expect(out).toContain('visibility') @@ -284,7 +285,6 @@ describe('cli entry point', () => { expect(out).toContain('withdraw') expect(out).toContain('Usage: spool [options] [command]') // Deprecated top-level commands must not resurface. - expect(out).not.toContain('projects') expect(out).not.toContain('pinned') }) @@ -582,19 +582,16 @@ describe('subscriptions', () => { } }) - it('subscribe --link-only --yes records and unsubscribe removes', () => { + it('subscribe --link-only --yes still fails closed without login and a Project', () => { const home = mkdtempSync(join(tmpdir(), 'spool-cli-home-')) const project = mkdtempSync(join(tmpdir(), 'spool-cli-project-')) try { - const out = run(['subscribe', project, '--link-only', '--yes'], { HOME: home }) - expect(out).toContain('Subscribed') + const out = runFail(['subscribe', project, '--link-only', '--yes'], { HOME: home }) + expect(out).toContain('Not logged in') expect(out).toContain('Link-only') const listed = run(['subscriptions'], { HOME: home }) - expect(listed).toContain('Link-only') - - const removed = run(['unsubscribe', project], { HOME: home }) - expect(removed).toContain('Unsubscribed') + expect(listed).toContain('No subscribed directories') } finally { rmSync(home, { recursive: true, force: true }) rmSync(project, { recursive: true, force: true }) diff --git a/apps/cli/src/commands/projects.test.ts b/apps/cli/src/commands/projects.test.ts new file mode 100644 index 00000000..120fbb28 --- /dev/null +++ b/apps/cli/src/commands/projects.test.ts @@ -0,0 +1,235 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' + +import type { HubProject } from '../hub/client.js' +import { loadProjectBindings } from '../hub/project-bindings.js' +import { createTextUi } from '../ui.js' +import { + handleProjectsBindCommand, + handleProjectsListCommand, + handleProjectsMoveCommand, + projectsCommand, +} from './projects.js' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +const project: HubProject = { + id: 'project_spool0001', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user', id: 'user_1', handle: 'evan', name: 'Evan' }, + can_manage: true, +} +const destination: HubProject = { + ...project, + id: 'project_vapor0001', + slug: 'react-vapor', + name: 'React Vapor', +} +const teamProject: HubProject = { + ...project, + id: 'project_team00001', + slug: 'spool', + owner: { kind: 'team', id: 'team_1', handle: 'paperboy', name: 'Paperboy' }, +} + +function sessionResponse(projectValue: HubProject = project) { + return { + sid: 'claude_abc12345', + root: 'root', + count: 1, + sig: null, + cardJson: null, + summaryMd: null, + lineageJson: null, + viewOid: 'view', + createdAt: 1, + updatedAt: 1, + visibility: 'public', + project: projectValue, + author: { handle: 'evan', displayName: 'Evan', avatarUrl: null }, + } +} + +function setup() { + const homeDir = mkdtempSync(join(tmpdir(), 'spool-project-command-home-')) + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'spool-project-command-cwd-'))) + dirs.push(homeDir, cwd) + const output: string[] = [] + const errors: string[] = [] + const ui = createTextUi( + (message) => output.push(message), + (message) => errors.push(message), + ) + const dependencies = { + ui, + homeDir, + cwd, + env: { SPOOL_HUB_URL: 'https://hub.test', SPOOL_HUB_TOKEN: 'token' }, + listProjects: async () => ({ actor: { id: 'user_1' }, projects: [project] }), + resolveLocalIdentity: () => ({ + kind: 'git_remote' as const, + key: 'github.com/paperboytm/spool', + displayName: 'spool', + }), + } + return { homeDir, cwd, output, errors, dependencies } +} + +describe('projects command', () => { + it('lists writable Projects and binds an exact owner/slug', async () => { + const { homeDir, cwd, output, errors, dependencies } = setup() + expect(await handleProjectsListCommand({}, dependencies)).toBe(0) + expect(output.join('\n')).toContain('evan/spool') + + expect(await handleProjectsBindCommand(cwd, 'evan/spool', dependencies)).toBe(0) + expect(errors).toEqual([]) + expect(loadProjectBindings({ homeDir })[0]).toMatchObject({ + actorId: 'user_1', + project: { id: project.id }, + localIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/spool', + }, + }) + }) + + it('filters Team Projects by a stable @handle', async () => { + const { dependencies, output } = setup() + expect( + await handleProjectsListCommand( + { team: '@paperboy' }, + { + ...dependencies, + listProjects: async () => ({ + actor: { id: 'user_1' }, + projects: [project, teamProject], + }), + }, + ), + ).toBe(0) + expect(output.join('\n')).toContain('paperboy/spool') + expect(output.join('\n')).not.toContain('evan/spool') + }) + + it('moves a hosted Session only within its current tenant with an optimistic precondition', async () => { + const { dependencies, output, errors } = setup() + const fetch = vi.fn(async (url: unknown, init?: RequestInit) => { + const path = new URL(String(url)).pathname + if (init?.method === 'GET' && path === '/api/hub/v1/sessions/claude_abc12345') { + return Response.json(sessionResponse()) + } + if (init?.method === 'PATCH' && path === '/api/me/sessions/claude_abc12345') { + return Response.json({ + session: { + sid: 'claude_abc12345', + visibility: 'public', + team_id: null, + team_name: null, + project_id: destination.id, + project: destination, + }, + }) + } + throw new Error(`Unexpected request: ${init?.method} ${path}`) + }) + + expect( + await handleProjectsMoveCommand( + 'claude_abc12345', + 'evan/react-vapor', + { yes: true }, + { + ...dependencies, + fetch: fetch as never, + listProjects: async () => ({ + actor: { id: 'user_1' }, + projects: [project, destination, teamProject], + }), + }, + ), + ).toBe(0) + + const [, patch] = fetch.mock.calls.find((call) => call[1]?.method === 'PATCH') as [ + unknown, + RequestInit, + ] + expect(JSON.parse(String(patch.body))).toEqual({ + visibility: 'public', + project_id: destination.id, + expected_project_id: project.id, + }) + expect(errors).toEqual([]) + expect(output.join('\n')).toContain('visibility, authorship, stars') + expect(output.join('\n')).toContain('Moved Session claude_abc12345') + }) + + it('rejects a Project from another tenant before sending a mutation', async () => { + const { dependencies, errors } = setup() + const fetch = vi.fn(async (url: unknown, init?: RequestInit) => { + const path = new URL(String(url)).pathname + if (init?.method === 'GET' && path === '/api/hub/v1/sessions/claude_abc12345') { + return Response.json(sessionResponse()) + } + throw new Error(`Unexpected request: ${init?.method} ${path}`) + }) + + expect( + await handleProjectsMoveCommand( + 'claude_abc12345', + 'paperboy/spool', + { yes: true }, + { + ...dependencies, + fetch: fetch as never, + listProjects: async () => ({ + actor: { id: 'user_1' }, + projects: [project, destination, teamProject], + }), + }, + ), + ).toBe(1) + expect(errors.join('\n')).toContain('different owner') + expect(errors.join('\n')).toContain('cannot change the Session tenant') + expect(fetch.mock.calls.some((call) => call[1]?.method === 'PATCH')).toBe(false) + }) + + it('requires an explicit confirmation in non-interactive use', async () => { + const { dependencies, errors } = setup() + const fetch = vi.fn(async () => Response.json(sessionResponse())) + + expect( + await handleProjectsMoveCommand( + 'claude_abc12345', + destination.id, + {}, + { + ...dependencies, + fetch: fetch as never, + listProjects: async () => ({ + actor: { id: 'user_1' }, + projects: [project, destination], + }), + }, + ), + ).toBe(1) + expect(errors.join('\n')).toContain('Re-run with `--yes`') + expect(fetch.mock.calls.some((call) => call[1]?.method === 'PATCH')).toBe(false) + }) + + it('exposes the move command and its Project option in help', () => { + const move = projectsCommand.commands.find((command) => command.name() === 'move') + expect(move).toBeDefined() + expect(move?.helpInformation()).toContain('--project ') + expect(move?.helpInformation()).toContain('--yes') + }) +}) diff --git a/apps/cli/src/commands/projects.ts b/apps/cli/src/commands/projects.ts new file mode 100644 index 00000000..31c35145 --- /dev/null +++ b/apps/cli/src/commands/projects.ts @@ -0,0 +1,313 @@ +import { + formatCliCommand, + formatCliInstallHint, + getDB, + resolveLocalProjectIdentity, + type ProjectIdentity, +} from '@spool-lab/core' +import { Command } from 'commander' + +import { + HubClient, + HubHttpError, + type HubFetch, + type HubProject, + type HubProjectsResponse, +} from '../hub/client.js' +import { loadHubCredentials, type HubCredentialOptions } from '../hub/credentials.js' +import { upsertProjectBinding } from '../hub/project-bindings.js' +import { findProjectByReference, projectLabel } from '../hub/project-resolution.js' +import { resolveSessionRef } from '../hub/ref.js' +import { canonicalSubscriptionPath } from '../subscriptions.js' +import { createClackUi, createTextUi, type CliUi } from '../ui.js' + +export interface ProjectsCommandDependencies extends HubCredentialOptions { + fetch?: HubFetch + ui?: CliUi + cwd?: string + listProjects?: () => Promise + resolveLocalIdentity?: (path: string) => ProjectIdentity +} + +export interface ProjectsMoveOptions { + yes?: boolean +} + +export async function handleProjectsListCommand( + options: { team?: string; json?: boolean }, + dependencies: ProjectsCommandDependencies = {}, +): Promise<0 | 1> { + const ui = dependencies.ui ?? createTextUi() + try { + const response = await loadProjects(dependencies) + let projects = response.projects + if (options.team !== undefined) { + const wanted = options.team.trim() + const handle = wanted.startsWith('@') ? wanted.slice(1) : wanted + projects = projects.filter( + (project) => + project.owner.kind === 'team' && + (project.owner.id === wanted || + project.owner.name === wanted || + project.owner.handle?.toLowerCase() === handle.toLowerCase()), + ) + } + if (options.json === true) { + ui.info(JSON.stringify({ actor: response.actor, projects }, null, 2)) + return 0 + } + if (projects.length === 0) { + ui.info( + options.team === undefined + ? 'No writable Hub Projects.' + : `No writable Projects for Team "${options.team}".`, + ) + return 0 + } + for (const project of projects) { + ui.info(`${project.id} ${projectLabel(project)} ${project.name} (${ownerLabel(project)})`) + } + return 0 + } catch (cause) { + ui.error(cause instanceof Error ? cause.message : String(cause)) + return 1 + } +} + +export async function handleProjectsBindCommand( + directory: string | undefined, + projectRef: string, + dependencies: ProjectsCommandDependencies = {}, +): Promise<0 | 1> { + const ui = dependencies.ui ?? createTextUi() + try { + const credentialOptions = pickCredentialOptions(dependencies) + const credentials = loadHubCredentials(credentialOptions) + if (!credentials.token) { + ui.error(`Not logged in. Run \`${formatCliCommand('login')}\` first.`) + return 1 + } + const cwd = dependencies.cwd ?? process.cwd() + const path = canonicalSubscriptionPath(directory ?? cwd, cwd) + const localIdentity = + dependencies.resolveLocalIdentity?.(path) ?? resolveLocalProjectIdentity(getDB(true), path) + const response = await loadProjects(dependencies) + const project = findProjectByReference(response.projects, projectRef) + if (!project) { + ui.error( + response.projects.length === 0 + ? 'No writable Hub Projects are available.' + : `No Project matches "${projectRef}". Available Projects: ${response.projects.map(projectLabel).join(', ')}`, + ) + return 1 + } + upsertProjectBinding( + { + hubUrl: credentials.hubUrl, + actorId: response.actor.id, + tenant: { kind: project.owner.kind, id: project.owner.id }, + localIdentity: { + kind: localIdentity.kind, + key: localIdentity.key, + displayName: localIdentity.displayName, + }, + project, + }, + credentialOptions, + ) + ui.success(`Bound local Project "${localIdentity.displayName}" to ${projectLabel(project)}.`) + return 0 + } catch (cause) { + ui.error(cause instanceof Error ? cause.message : String(cause)) + return 1 + } +} + +export async function handleProjectsMoveCommand( + input: string, + projectRef: string, + options: ProjectsMoveOptions, + dependencies: ProjectsCommandDependencies = {}, +): Promise<0 | 1> { + const ui = dependencies.ui ?? createTextUi() + ui.intro('Move Session to Project') + try { + const credentialOptions = pickCredentialOptions(dependencies) + const credentials = loadHubCredentials(credentialOptions) + if (!credentials.token) { + ui.error(`Not logged in. Run \`${formatCliCommand('login')}\` first.`) + ui.info(formatCliInstallHint()) + return 1 + } + + const ref = resolveSessionRef(input) + const client = new HubClient({ + hubUrl: ref.hubUrl ?? credentials.hubUrl, + token: credentials.token, + ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), + }) + const current = await client.getSession(ref.sid) + if (!current.project) { + ui.error( + 'This Hub did not report the Session’s current Project. Update the Hub before moving it.', + ) + return 1 + } + if (!current.visibility) { + ui.error( + 'This Hub did not report the Session’s visibility. The CLI will not guess during a Project move.', + ) + return 1 + } + + const response = dependencies.listProjects + ? await dependencies.listProjects() + : await client.listProjects() + const tenant = current.project.owner + const eligible = response.projects.filter( + (project) => project.owner.kind === tenant.kind && project.owner.id === tenant.id, + ) + const project = findProjectByReference(eligible, projectRef) + if (!project) { + const otherTenant = findProjectByReference(response.projects, projectRef) + if (otherTenant) { + ui.error( + `Project ${projectLabel(otherTenant)} belongs to a different owner. ` + + 'A Project move cannot change the Session tenant; use `spool visibility … team` for a Personal → Team transfer.', + ) + } else { + ui.error( + eligible.length === 0 + ? `No writable Projects are available for ${ownerLabel(current.project)}.` + : `No same-owner Project matches "${projectRef}". Available Projects: ${eligible.map(projectLabel).join(', ')}`, + ) + } + return 1 + } + if (project.id === current.project.id) { + ui.info(`Session ${ref.sid} already belongs to ${projectLabel(project)}.`) + return 0 + } + + const confirmation = + `Move Session ${ref.sid} from ${projectLabel(current.project)} to ${projectLabel(project)}? ` + + 'Records, visibility, authorship, stars, and verified-fork lineage stay unchanged.' + if (options.yes !== true) { + if (!ui.interactive) { + ui.error('Cannot confirm a Project move without a TTY. Re-run with `--yes`.') + return 1 + } + const approved = await ui.confirm(confirmation, true) + if (approved !== true) { + ui.cancel('Project unchanged.') + return 1 + } + } else { + ui.info(confirmation) + } + + await client.updateSessionVisibility(ref.sid, current.visibility, { + projectId: project.id, + expectedProjectId: current.project.id, + }) + ui.success(`Moved Session ${ref.sid} to ${projectLabel(project)}.`) + ui.outro('Project updated; Session records and disclosure are unchanged.') + return 0 + } catch (cause) { + if (cause instanceof HubHttpError) { + if (cause.status === 409) { + ui.error('The Session Project changed before this move completed. Review it and try again.') + } else if (cause.status === 401) { + ui.error(`Authentication failed. Run \`${formatCliCommand('login')}\` again.`) + ui.info(formatCliInstallHint()) + } else if (cause.status === 403) { + ui.error('You do not have permission to move this Session.') + } else if (cause.status === 404) { + ui.error(`Session or Project not found: ${input}`) + } else { + ui.error(`Hub returned HTTP ${cause.status}: ${cause.bodyMessage}`) + } + } else { + ui.error(cause instanceof Error ? cause.message : String(cause)) + } + return 1 + } +} + +async function loadProjects( + dependencies: ProjectsCommandDependencies, +): Promise { + if (dependencies.listProjects) return dependencies.listProjects() + const credentials = loadHubCredentials(pickCredentialOptions(dependencies)) + if (!credentials.token) { + throw new Error(`Not logged in. Run \`${formatCliCommand('login')}\` first.`) + } + return new HubClient({ + hubUrl: credentials.hubUrl, + token: credentials.token, + ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), + }).listProjects() +} + +function ownerLabel(project: HubProject): string { + return project.owner.kind === 'team' + ? `Team · ${project.owner.name ?? project.owner.handle ?? project.owner.id}` + : 'Personal' +} + +export const projectsCommand = new Command('projects').description( + 'List Hub Projects, bind local Projects, and move hosted Sessions', +) + +projectsCommand + .command('list') + .description('List writable Hub Projects') + .option('--team ', 'Only Projects owned by this Team') + .option('--json', 'Print machine-readable JSON') + .action(async (options: { team?: string; json?: boolean }) => { + const exitCode = await handleProjectsListCommand(options, { ui: createClackUi() }) + if (exitCode !== 0) process.exitCode = exitCode + }) + +projectsCommand + .command('bind') + .description('Bind a local Project to a Hub Project') + .argument('[dir]', 'Directory whose local Project identity should be bound') + .requiredOption('--project ', 'Hub Project id or owner/slug') + .action(async (directory: string | undefined, options: { project: string }) => { + const exitCode = await handleProjectsBindCommand(directory, options.project, { + ui: createClackUi(), + }) + if (exitCode !== 0) process.exitCode = exitCode + }) + +projectsCommand + .command('move') + .description('Move a hosted Session to another Project owned by the same user or Team') + .argument('', 'Shared Session ID or URL') + .requiredOption('--project ', 'Same-owner target Hub Project') + .option('--yes', 'Skip the Project move confirmation') + .action( + async ( + input: string, + options: { + project: string + yes?: boolean + }, + ) => { + const exitCode = await handleProjectsMoveCommand( + input, + options.project, + { ...(options.yes === undefined ? {} : { yes: options.yes }) }, + { ui: createClackUi() }, + ) + if (exitCode !== 0) process.exitCode = exitCode + }, + ) + +function pickCredentialOptions(dependencies: HubCredentialOptions): HubCredentialOptions { + return { + ...(dependencies.homeDir === undefined ? {} : { homeDir: dependencies.homeDir }), + ...(dependencies.env === undefined ? {} : { env: dependencies.env }), + } +} diff --git a/apps/cli/src/commands/share-resume.test.ts b/apps/cli/src/commands/share-resume.test.ts index c88a9235..eb08168d 100644 --- a/apps/cli/src/commands/share-resume.test.ts +++ b/apps/cli/src/commands/share-resume.test.ts @@ -15,8 +15,9 @@ import { sequenceRoot, serializePortableSession } from '@spool-lab/session-kit' import Database from 'better-sqlite3' import { describe, expect, it } from 'vite-plus/test' -import type { HubFetch } from '../hub/client.js' +import type { HubFetch, HubProject } from '../hub/client.js' import type { LocalSummaryAgent } from '../hub/local-summary-agent.js' +import { upsertProjectBinding } from '../hub/project-bindings.js' import type { CliSpinner, CliUi } from '../ui.js' import { handleResumeCommand } from './resume.js' import { @@ -31,6 +32,24 @@ import { const SESSION_UUID = '6f9a1b2c-3d4e-5f60-8a9b-0c1d2e3f4a5b' const HUB_URL = 'https://hub.test' +const ACTOR_ID = 'user_00000001' +const HUB_PROJECT: HubProject = { + id: 'project_spool0001', + slug: 'spool', + name: 'Spool', + description: null, + github_url: 'https://github.com/paperboytm/spool', + owner: { kind: 'user', id: ACTOR_ID, handle: 'author', name: 'Author' }, + can_manage: true, +} +const TEAM_ID = 'team_00000001' +const TEAM_PROJECT: HubProject = { + ...HUB_PROJECT, + id: 'project_paperboy01', + slug: 'react-vapor', + name: 'React Vapor', + owner: { kind: 'team', id: TEAM_ID, handle: 'paperboy', name: 'Paperboy' }, +} const BILINGUAL_SUMMARY = [ '---', 'title: Rename alpha to beta', @@ -56,12 +75,18 @@ interface StoredHead { lineageJson: string | null viewOid: string spoolFileOid?: string | null + projectId: string + expectedProjectId?: string | null + visibility?: 'public' | 'link-only' | 'team' + teamId?: string + expectedTeamId?: string | null withdrawn?: boolean } function makeHub() { const objects = new Map() const sessions = new Map() + const writes: Array<{ action: 'push' | 'head'; sid: string; body: StoredHead }> = [] const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { @@ -77,6 +102,26 @@ function makeHub() { /^\/api\/hub\/v1\/sessions\/([^/]+)(?:\/(push|head|records|view|withdraw|resume-grant))?$/, ) + if (method === 'GET' && path === '/api/hub/v1/projects') { + return json({ actor: { id: ACTOR_ID }, projects: [HUB_PROJECT, TEAM_PROJECT] }) + } + + if (method === 'GET' && path === '/api/hub/v1/teams') { + return json({ + teams: [ + { + id: TEAM_ID, + name: 'Paperboy', + handle: 'paperboy', + role: 'owner', + permissions: [], + member_count: 1, + archived_at: null, + }, + ], + }) + } + if (method === 'POST' && path === '/api/hub/v1/objects/batch') { const lines = String(init?.body ?? '') .split('\n') @@ -93,6 +138,19 @@ function makeHub() { const action = sidMatch[2] if (method === 'POST' && (action === 'push' || action === 'head')) { const body = JSON.parse(String(init?.body)) as StoredHead + writes.push({ action, sid, body }) + if ( + body.expectedProjectId !== undefined && + (sessions.get(sid)?.projectId ?? null) !== body.expectedProjectId + ) { + return json({ error: 'CONFLICT', detail: 'Project changed' }, 409) + } + if ( + body.expectedTeamId !== undefined && + (sessions.get(sid)?.teamId ?? null) !== body.expectedTeamId + ) { + return json({ error: 'CONFLICT', detail: 'Team changed' }, 409) + } const wanted = [ ...new Set([ ...body.manifest, @@ -121,6 +179,14 @@ function makeHub() { viewOid: session.viewOid, createdAt: 1, updatedAt: 1, + visibility: session.visibility ?? 'public', + team: session.teamId === TEAM_ID ? { id: TEAM_ID, name: 'Paperboy' } : null, + project: + session.projectId === HUB_PROJECT.id + ? HUB_PROJECT + : session.projectId === TEAM_PROJECT.id + ? TEAM_PROJECT + : null, author: { handle: 'author', displayName: 'Author', avatarUrl: null }, }) } @@ -148,7 +214,7 @@ function makeHub() { return json({ error: 'NOT_FOUND', detail: `unhandled ${method} ${path}` }, 404) } - return { fetchImpl, objects, sessions } + return { fetchImpl, objects, sessions, writes } } function writeFixtureSession(workspaceRoot: string): string { @@ -244,9 +310,14 @@ function interactiveUi( ? options.confirm(message) : (options.confirm ?? true) }, - select: async ({ message }) => { + select: async ({ message, choices }) => { events.push(`select:${message}`) - return options.selected ?? 'claude' + const wanted = options.selected ?? 'claude' + return choices.find((choice) => choice.value === wanted)?.value ?? choices[0]?.value ?? null + }, + autocomplete: async ({ message, choices }) => { + events.push(`autocomplete:${message}`) + return choices[0]?.value ?? null }, spinner, } @@ -260,6 +331,21 @@ function shareDeps( ) { const logs: string[] = [] const errors: string[] = [] + const projectIdentity = { + kind: 'git_remote' as const, + key: 'github.com/paperboytm/spool', + displayName: 'spool', + } + upsertProjectBinding( + { + hubUrl: HUB_URL, + actorId: ACTOR_ID, + tenant: { kind: 'user', id: ACTOR_ID }, + localIdentity: projectIdentity, + project: HUB_PROJECT, + }, + { homeDir: home }, + ) return { logs, errors, @@ -276,6 +362,7 @@ function shareDeps( sessionUuid: SESSION_UUID, filePath, cwd: workspaceRoot, + projectIdentity, }), }, } @@ -349,6 +436,11 @@ describe('spool share local Agent Summary flow', () => { sessionUuid: SESSION_UUID, filePath: '/tmp/unused-session.jsonl', cwd: '/tmp/example', + projectIdentity: { + kind: 'path', + key: '/tmp/example', + displayName: 'example', + }, }), }, ), @@ -523,11 +615,108 @@ describe('spool share local Agent Summary flow', () => { expect(exit).toBe(0) expect(detected).toBe(false) expect(share.logs.join('\n')).toContain( - 'This Session will be Public and can appear in Explore and search.', + 'This Session will be Public in Project Spool and can appear in Explore and search.', ) expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() }) + it('fails closed without an explicit or saved Project in non-interactive mode', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-project-nontty-')) + const home = mkdtempSync(join(tmpdir(), 'spool-project-nontty-home-')) + const filePath = writeFixtureSession(workspace) + const logs: string[] = [] + const errors: string[] = [] + + const exit = await handleShareCommand( + undefined, + { agentSummary: false, visibilityConfirmed: true, yes: true }, + { + fetch: hub.fetchImpl, + homeDir: home, + env: { SPOOL_HUB_URL: HUB_URL, SPOOL_HUB_TOKEN: 'test-token' }, + cwd: workspace, + log: (message) => logs.push(message), + error: (message) => errors.push(message), + resolveTarget: () => ({ + provider: 'claude', + sessionUuid: SESSION_UUID, + filePath, + cwd: workspace, + projectIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/unbound', + displayName: 'unbound', + }, + }), + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.size).toBe(0) + expect(errors.join('\n')).toContain('--yes` never chooses a Project') + }) + + it('publishes directly to a Team Project without creating a Public head', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-team-share-')) + const home = mkdtempSync(join(tmpdir(), 'spool-team-share-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + + const exit = await handleShareCommand( + undefined, + { + agentSummary: false, + summary: BILINGUAL_SUMMARY, + team: '@paperboy', + project: TEAM_PROJECT.id, + visibilityConfirmed: true, + yes: true, + }, + share.deps, + ) + + expect(exit).toBe(0) + const writes = hub.writes.filter((write) => write.sid === `claude_${SESSION_UUID}`) + expect(writes).not.toHaveLength(0) + expect(writes.every((write) => write.body.visibility === 'team')).toBe(true) + expect(writes.every((write) => write.body.teamId === TEAM_ID)).toBe(true) + expect(writes.every((write) => write.body.projectId === TEAM_PROJECT.id)).toBe(true) + expect( + writes.filter((write) => write.action === 'head').map((write) => write.body.expectedTeamId), + ).toEqual([null, TEAM_ID]) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)).toMatchObject({ + visibility: 'team', + teamId: TEAM_ID, + projectId: TEAM_PROJECT.id, + }) + expect(share.logs.join('\n')).toContain('Only current members') + }) + + it('still asks an interactive Team share to select a Project when --yes is set', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-team-project-select-')) + const home = mkdtempSync(join(tmpdir(), 'spool-team-project-select-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + + const exit = await handleShareCommand( + undefined, + { agentSummary: false, team: 'paperboy', visibilityConfirmed: true, yes: true }, + { ...share.deps, ui: interactiveUi({ events }) }, + ) + + expect(exit).toBe(0) + expect(events.join('\n')).toContain('select:Which Hub Project should "spool" publish to?') + expect(hub.sessions.get(`claude_${SESSION_UUID}`)).toMatchObject({ + visibility: 'team', + teamId: TEAM_ID, + projectId: TEAM_PROJECT.id, + }) + }) + it('aborts before upload when non-interactive visibility is not acknowledged', async () => { const hub = makeHub() const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-nontty-unconfirmed-')) @@ -542,6 +731,27 @@ describe('spool share local Agent Summary flow', () => { expect(share.errors.join('\n')).toContain('--visibility-confirmed') }) + it('does not create a Project before the user accepts the disclosure', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-project-before-disclosure-')) + const home = mkdtempSync(join(tmpdir(), 'spool-project-before-disclosure-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + + const exit = await handleShareCommand( + undefined, + { agentSummary: false, createProject: 'Should not exist' }, + { + ...share.deps, + ui: interactiveUi({ confirm: false }), + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.size).toBe(0) + expect(hub.writes).toEqual([]) + }) + it('preserves an existing Summary when the user declines regeneration', async () => { const hub = makeHub() const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-preserve-')) @@ -562,7 +772,7 @@ describe('spool share local Agent Summary flow', () => { { ...share.deps, ui: interactiveUi({ - confirm: (message) => message.startsWith('Publish this Session as Public?'), + confirm: (message) => message.startsWith('Publish this Session as Public'), }), detectSummaryAgents: async () => [ { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, @@ -587,7 +797,7 @@ describe('spool share local Agent Summary flow', () => { { ...share.deps, ui: interactiveUi({ - confirm: (message) => message.startsWith('Publish this Session as Public?'), + confirm: (message) => message.startsWith('Publish this Session as Public'), }), detectSummaryAgents: async () => [ { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, @@ -638,7 +848,11 @@ describe('spool share → spool resume round trip', () => { const exit = await handleShareCommand( sessionUuid, - { agentSummary: false, visibilityConfirmed: true }, + { + agentSummary: false, + visibilityConfirmed: true, + project: HUB_PROJECT.id, + }, { fetch: hub.fetchImpl, homeDir: home, @@ -651,6 +865,11 @@ describe('spool share → spool resume round trip', () => { sessionUuid, filePath: `/virtual/${provider}`, cwd: workspace, + projectIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/spool', + displayName: 'spool', + }, jsonl, }), }, @@ -706,6 +925,16 @@ describe('spool share → spool resume round trip', () => { expect(head?.count).toBe(4) expect(head?.root).toBe(await sequenceRoot(head?.manifest ?? [])) expect(head?.summaryMd).toBe(summary) + expect(head?.projectId).toBe(HUB_PROJECT.id) + expect(hub.writes).not.toHaveLength(0) + expect(new Set(hub.writes.map((write) => write.body.projectId))).toEqual( + new Set([HUB_PROJECT.id]), + ) + expect( + hub.writes + .filter((write) => write.action === 'head') + .map((write) => write.body.expectedProjectId), + ).toEqual([null, HUB_PROJECT.id]) expect(hub.objects.has(head?.viewOid ?? '')).toBe(true) for (const oid of head?.manifest ?? []) { expect(hub.objects.get(oid)).not.toContain(authorWs) @@ -821,6 +1050,11 @@ describe('spool share → spool resume round trip', () => { sessionUuid: SESSION_UUID, filePath, cwd: authorWs, + projectIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/spool', + displayName: 'spool', + }, }), }, ) diff --git a/apps/cli/src/commands/share.ts b/apps/cli/src/commands/share.ts index 62790a53..8b9fa15a 100644 --- a/apps/cli/src/commands/share.ts +++ b/apps/cli/src/commands/share.ts @@ -6,7 +6,9 @@ import { formatCliCommand, getDB, getSessionWithMessages, + resolveSessionProjectIdentity, serializeIndexedSession, + type ProjectIdentity, } from '@spool-lab/core' import { canonicalizeRecord, @@ -14,6 +16,7 @@ import { isResumableSessionProvider, parseSummaryFrontMatter, parseSessionText, + sessionRecordData, SESSION_PROVIDERS, type SessionProvider, } from '@spool-lab/session-kit' @@ -21,16 +24,24 @@ import { Command } from 'commander' import { copyTextToClipboard } from '../clipboard.js' import { buildSessionSummaryPrompt } from '../hub/agent-summary-prompt.js' -import { HubClient, HubHttpError, type HubFetch } from '../hub/client.js' +import { + HubClient, + HubHttpError, + type HubFetch, + type HubSessionMeta, + type HubTeam, +} from '../hub/client.js' import { loadHubCredentials, type HubCredentialOptions } from '../hub/credentials.js' import { detectLocalSummaryAgents, runLocalSummaryAgent, type LocalSummaryAgent, } from '../hub/local-summary-agent.js' +import { persistResolvedProject, resolveHubProject } from '../hub/project-resolution.js' import { publishPreparedShare } from '../hub/publish.js' import { formatRedactSummary, scanRecordsForSecrets } from '../hub/redact-gate.js' import { prepareShare, type PreparedShare } from '../hub/share-pipeline.js' +import { resolveTeamReference } from '../hub/team-resolution.js' import { buildWorkspaceCard, detectWorkspaceRoot } from '../hub/workspace.js' import { expandLocalSessionUuid } from '../local-session-ref.js' import { createClackUi, createTextUi, type CliUi } from '../ui.js' @@ -48,6 +59,14 @@ export interface ShareTarget { sessionUuid: string filePath: string cwd: string | null + /** + * Exact identity joined through sessions.project_id in the local index. + * + * Optional only at the injected resolver boundary for compatibility with + * programmatic callers. The command fails closed before preparation or + * upload when it is absent; it never substitutes the caller's cwd. + */ + projectIdentity?: ProjectIdentity /** Portable JSONL for indexed sources without native provider records. */ jsonl?: string } @@ -64,6 +83,12 @@ export interface ShareCommandOptions { visibilityDisclosed?: boolean /** Path to a .spool document to attach to the share. */ spoolFile?: string + /** Existing Hub Project id or owner/slug. */ + project?: string + /** Create and bind a Hub Project with this name. */ + createProject?: string + /** Publish directly into a Team tenant instead of the personal default. */ + team?: string } export interface ShareCommandDependencies extends HubCredentialOptions { @@ -79,9 +104,15 @@ export interface ShareCommandDependencies extends HubCredentialOptions { buildSummaryPrompt?: (target: ShareTarget, prepared: PreparedShare) => string /** Best-effort clipboard writer; injected in tests. */ copyToClipboard?: (text: string) => boolean | Promise + listTeams?: (client: HubClient) => Promise cwd?: string } +type ShareDisclosure = + | { kind: 'public' } + | { kind: 'link-only' } + | { kind: 'team'; id: string; name: string } + export async function handleShareCommand( input: string | undefined, options: ShareCommandOptions, @@ -94,17 +125,31 @@ export async function handleShareCommand( ui.intro('Share a session') try { + if (options.project !== undefined && options.createProject !== undefined) { + ui.error('`--project` and `--create-project` cannot be used together.') + return 1 + } const { sessionUuid, position } = parseShareRef(input) const resolveTarget = dependencies.resolveTarget ?? resolveTargetFromIndex const target = resolveTarget(sessionUuid, cwd) - const publicByDefault = isDiscoverySessionProvider(target.provider) - + if (!target.projectIdentity) { + throw new Error( + `Session ${target.sessionUuid} has no local Project identity. ` + + 'Refresh the local index or update the programmatic resolver before sharing.', + ) + } + const projectIdentity = target.projectIdentity const credentials = loadHubCredentials(pickCredentialOptions(dependencies)) if (!credentials.token) { ui.error(`Not logged in. Run \`${formatCliCommand('login')}\` first.`) ui.info(formatCliInstallHint()) return 1 } + const client = new HubClient({ + hubUrl: credentials.hubUrl, + token: credentials.token, + ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), + }) if (options.summary !== undefined && options.summary.trim() === '') { ui.error('`--summary` cannot be empty. Omit it to use the local Agent flow.') @@ -136,7 +181,9 @@ export async function handleShareCommand( throw cause } - const secrets = scanRecordsForSecrets(prepared.records.map((record) => record.data)) + const secrets = scanRecordsForSecrets( + prepared.records.map((record) => sessionRecordData(record)), + ) if (secrets.total > 0) { ui.warn(formatRedactSummary(secrets)) if (options.yes !== true) { @@ -154,9 +201,28 @@ export async function handleShareCommand( } } - const visibilityConfirmation = publicByDefault - ? 'Publish this Session as Public? It can appear in Explore and search.' - : 'Share this Session as Link-only? Anyone with the URL can read it.' + const spoolFile = + options.spoolFile === undefined ? null : await readSpoolFileObject(options.spoolFile) + const existing = await existingSession(client, prepared.sid) + const existingProject = existing?.project ?? null + const requestedTeam = + options.team === undefined + ? undefined + : await resolveRequestedTeam(client, options.team, dependencies) + if ( + requestedTeam && + existingProject && + (existingProject.owner.kind !== 'team' || existingProject.owner.id !== requestedTeam.id) + ) { + throw new Error( + `Session is already in Project ${existingProject.owner.handle ?? existingProject.owner.id}/${existingProject.slug}. ` + + `Re-sharing preserves its remote Project; use \`${formatCliCommand( + `visibility ${prepared.sid} team --team ${requestedTeam.id} --project `, + )}\` for an explicit ownership transfer.`, + ) + } + const disclosure = resolveShareDisclosure(existing, requestedTeam, target.provider) + const visibilityConfirmation = shareConfirmation(disclosure) let visibilityDisclosed = options.visibilityDisclosed === true if (options.yes !== true && options.visibilityConfirmed !== true) { if (!ui.interactive) { @@ -173,22 +239,47 @@ export async function handleShareCommand( } visibilityDisclosed = true } - if (!visibilityDisclosed) { - ui.info( - publicByDefault - ? 'This Session will be Public and can appear in Explore and search.' - : 'This Session will be Link-only; anyone with the URL can read it.', - ) - } - const client = new HubClient({ + const resolvedProject = await resolveHubProject({ + client, + ui, hubUrl: credentials.hubUrl, - token: credentials.token, - ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), + localIdentity: projectIdentity, + tenant: requestedTeam + ? { kind: 'team', id: requestedTeam.id } + : existingProject?.owner.kind === 'team' + ? { kind: 'team', id: existingProject.owner.id } + : { kind: 'personal' }, + ...(options.project === undefined ? {} : { projectRef: options.project }), + ...(options.createProject === undefined ? {} : { createProjectName: options.createProject }), + existingProject, + ...pickCredentialOptions(dependencies), }) - const spoolFile = - options.spoolFile === undefined ? null : await readSpoolFileObject(options.spoolFile) - const initialSummary = await existingSummary(client, prepared.sid) + if (!resolvedProject) return 1 + + if (!visibilityDisclosed) { + ui.info(shareDisclosureInfo(disclosure, resolvedProject.project.name)) + } + + const directTeamTarget = + requestedTeam === undefined ? {} : ({ visibility: 'team', teamId: requestedTeam.id } as const) + const initialOwnershipExpectation = + requestedTeam === undefined ? {} : { expectedTeamId: existing?.team?.id ?? null } + const subsequentOwnershipExpectation = + requestedTeam === undefined ? {} : { expectedTeamId: requestedTeam.id } + const initialProjectWrite = { + projectId: resolvedProject.project.id, + expectedProjectId: existingProject?.id ?? null, + ...directTeamTarget, + ...initialOwnershipExpectation, + } + const subsequentProjectWrite = { + projectId: resolvedProject.project.id, + expectedProjectId: resolvedProject.project.id, + ...directTeamTarget, + ...subsequentOwnershipExpectation, + } + const initialSummary = existing?.summaryMd ?? null const upload = ui.spinner() upload.start('Uploading session') @@ -198,10 +289,12 @@ export async function handleShareCommand( card, summary: initialSummary, spoolFile, + ...initialProjectWrite, onUploadProgress: (uploaded, total) => upload.message(`Uploading session objects ${uploaded}/${total}`), }) url = published.url + persistResolvedProject(resolvedProject, pickCredentialOptions(dependencies)) upload.stop(`Uploaded ${prepared.count} records`) } catch (cause) { upload.error('Session upload failed') @@ -210,7 +303,7 @@ export async function handleShareCommand( await announceShareComplete( ui, url, - publicByDefault, + disclosure, dependencies.copyToClipboard ?? copyTextToClipboard, ) @@ -222,6 +315,7 @@ export async function handleShareCommand( card, summary: options.summary, spoolFile, + ...subsequentProjectWrite, onUploadProgress: (uploaded, total) => summaryUpload.message(`Uploading Summary objects ${uploaded}/${total}`), }) @@ -302,6 +396,7 @@ export async function handleShareCommand( card, summary, spoolFile, + ...subsequentProjectWrite, onUploadProgress: (uploaded, total) => generation.message(`Uploading Summary objects ${uploaded}/${total}`), }) @@ -373,13 +468,79 @@ function stripClosingHeadingSequence(heading: string): string { return heading.slice(0, separator) } +async function resolveRequestedTeam( + client: HubClient, + reference: string, + dependencies: ShareCommandDependencies, +): Promise { + const teams = await (dependencies.listTeams ?? ((forClient) => forClient.listTeams()))(client) + const team = resolveTeamReference(teams, reference) + if (team) return team + throw new Error( + teams.length === 0 + ? 'You are not a member of any Team.' + : `No Team matches "${reference}". Your Teams: ${teams + .map((entry) => (entry.handle ? `@${entry.handle}` : entry.name)) + .join(', ')}`, + ) +} + +function resolveShareDisclosure( + existing: HubSessionMeta | null, + requestedTeam: HubTeam | undefined, + provider: SessionProvider, +): ShareDisclosure { + if (requestedTeam) { + return { kind: 'team', id: requestedTeam.id, name: requestedTeam.name } + } + if (existing?.visibility === 'team') { + const id = existing.team?.id ?? existing.project?.owner.id ?? 'unknown' + const name = existing.team?.name ?? existing.project?.owner.name ?? id + return { kind: 'team', id, name } + } + if (existing?.visibility === 'public') return { kind: 'public' } + if (existing?.visibility === 'link-only') return { kind: 'link-only' } + return isDiscoverySessionProvider(provider) ? { kind: 'public' } : { kind: 'link-only' } +} + +function shareConfirmation(disclosure: ShareDisclosure): string { + if (disclosure.kind === 'team') { + return ( + `Publish this Session to Team · ${disclosure.name}? ` + + 'Only current members can read it, and the Team owns the hosted Session.' + ) + } + return disclosure.kind === 'public' + ? 'Publish this Session as Public? It can appear in Explore and search.' + : 'Share this Session as Link-only? Anyone with the URL can read it.' +} + +function shareDisclosureInfo(disclosure: ShareDisclosure, projectName: string): string { + if (disclosure.kind === 'team') { + return ( + `This Session will be Team · ${disclosure.name} / Project ${projectName}. ` + + 'Only current members can read it, and the Team owns the hosted Session.' + ) + } + return disclosure.kind === 'public' + ? `This Session will be Public in Project ${projectName} and can appear in Explore and search.` + : `This Session will be Link-only in Project ${projectName}; anyone with the URL can read it.` +} + async function announceShareComplete( ui: CliUi, url: string, - publicByDefault: boolean, + disclosure: ShareDisclosure, copyToClipboard: (text: string) => boolean | Promise, ): Promise { - ui.note(url, publicByDefault ? 'Public Session URL' : 'Link-only URL') + ui.note( + url, + disclosure.kind === 'public' + ? 'Public Session URL' + : disclosure.kind === 'team' + ? `Team · ${disclosure.name} Session URL` + : 'Link-only URL', + ) let copied = false if (ui.interactive) { try { @@ -388,15 +549,24 @@ async function announceShareComplete( // Clipboard access is a convenience; a live share must still succeed. } } - const result = publicByDefault ? 'Session published.' : 'Session shared as Link-only.' + const result = + disclosure.kind === 'public' + ? 'Session published.' + : disclosure.kind === 'team' + ? `Session shared to Team · ${disclosure.name}.` + : 'Session shared as Link-only.' ui.success(copied ? `${result} Link copied to clipboard.` : result) if (ui.interactive && !copied) { ui.info('Could not copy automatically. Copy the Session URL above to share it.') } - if (publicByDefault) { + if (disclosure.kind === 'public') { ui.info('This Session can appear in Explore and search. The source Session stays unchanged.') + } else if (disclosure.kind === 'team') { + ui.info( + `Only current members of Team · ${disclosure.name} can read it, and the Team owns the hosted Session.`, + ) } else { - ui.info('This provider is not yet supported in Explore, so the Session remains Link-only.') + ui.info('Anyone with the URL can read it; it does not appear in Explore or search.') } } @@ -414,6 +584,9 @@ export const shareCommand = new Command('share') .option('--visibility-confirmed', 'Acknowledge visibility when running without a TTY') .option('--yes', 'Skip all confirmations, including secret findings') .option('--spool-file ', 'Attach a .spool document to the share') + .option('--team ', 'Publish directly to this Team') + .option('--project ', 'Publish to this Hub Project') + .option('--create-project ', 'Create and bind a Hub Project') .action( async ( session: string | undefined, @@ -423,6 +596,9 @@ export const shareCommand = new Command('share') visibilityConfirmed?: boolean yes?: boolean spoolFile?: string + team?: string + project?: string + createProject?: string }, ) => { const exitCode = await handleShareCommand( @@ -435,6 +611,9 @@ export const shareCommand = new Command('share') : { visibilityConfirmed: opts.visibilityConfirmed }), ...(opts.yes === undefined ? {} : { yes: opts.yes }), ...(opts.spoolFile === undefined ? {} : { spoolFile: opts.spoolFile }), + ...(opts.team === undefined ? {} : { team: opts.team }), + ...(opts.project === undefined ? {} : { project: opts.project }), + ...(opts.createProject === undefined ? {} : { createProject: opts.createProject }), }, { ui: createClackUi() }, ) @@ -463,7 +642,7 @@ async function chooseSummaryAgent( } function buildPreparedSummaryPrompt(target: ShareTarget, prepared: PreparedShare): string { - const raw = prepared.records.map((record) => record.data).join('\n') + const raw = prepared.records.map((record) => sessionRecordData(record)).join('\n') const parsed = parseSessionText(target.provider, raw, target.filePath) if (parsed.kind !== 'parsed') { throw new Error('Could not extract the shared conversation for Summary generation.') @@ -471,9 +650,9 @@ function buildPreparedSummaryPrompt(target: ShareTarget, prepared: PreparedShare return buildSessionSummaryPrompt(parsed.session, parsed.session.messages).prompt } -async function existingSummary(client: HubClient, sid: string): Promise { +async function existingSession(client: HubClient, sid: string): Promise { try { - return (await client.getSession(sid)).summaryMd + return await client.getSession(sid) } catch (cause) { if (cause instanceof HubHttpError && (cause.status === 404 || cause.status === 410)) return null throw cause @@ -535,6 +714,7 @@ function resolveTargetFromIndex(sessionUuid: string | undefined, cwd: string): S sessionUuid: session.sessionUuid, filePath: session.filePath, cwd: session.cwd, + projectIdentity: resolveSessionProjectIdentity(db, session.sessionUuid), ...(isResumableSessionProvider(session.source) ? {} : { jsonl: serializeIndexedSession(session, found.messages) }), diff --git a/apps/cli/src/commands/show.ts b/apps/cli/src/commands/show.ts index b3374950..9bcdecba 100644 --- a/apps/cli/src/commands/show.ts +++ b/apps/cli/src/commands/show.ts @@ -6,6 +6,7 @@ import { deriveView, extractEditEvents, isResumableSessionProvider, + sessionRecordData, splitRecords, type SessionDiff, type SessionProvider, @@ -112,7 +113,7 @@ async function showHub( ) const record = records[0] if (!record) throw new Error(`Record ${parsed.recordIndex} could not be fetched`) - log(prettyRecord(record.data)) + log(prettyRecord(sessionRecordData(record))) return 0 } diff --git a/apps/cli/src/commands/subscribe.test.ts b/apps/cli/src/commands/subscribe.test.ts index 95825cd3..e9f759b3 100644 --- a/apps/cli/src/commands/subscribe.test.ts +++ b/apps/cli/src/commands/subscribe.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vite-plus/test' -import type { HubTeam } from '../hub/client.js' +import type { HubProject, HubProjectsResponse, HubTeam } from '../hub/client.js' import { loadSubscriptions } from '../subscriptions.js' import { createTextUi } from '../ui.js' import { @@ -42,6 +42,7 @@ const TEAMS: HubTeam[] = [ { id: 'team_00000001', name: 'Paperboy', + handle: 'paperboy', role: 'member', permissions: [], member_count: 3, @@ -49,6 +50,39 @@ const TEAMS: HubTeam[] = [ }, ] +const PERSONAL_PROJECT: HubProject = { + id: 'project_personal01', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user', id: 'user_00000001', handle: 'evan', name: 'Evan' }, + can_manage: true, +} +const TEAM_PROJECT: HubProject = { + ...PERSONAL_PROJECT, + id: 'project_team000001', + slug: 'paperboy', + name: 'Paperboy', + owner: { kind: 'team', id: 'team_00000001', handle: 'paperboy', name: 'Paperboy' }, +} + +function projectDeps(projectPath: string, projects: HubProject[] = [PERSONAL_PROJECT]) { + const response: HubProjectsResponse = { + actor: { id: 'user_00000001' }, + projects, + } + return { + env: { SPOOL_HUB_URL: 'https://hub.test', SPOOL_HUB_TOKEN: 'token' }, + resolveLocalIdentity: () => ({ + kind: 'path' as const, + key: projectPath, + displayName: 'spool', + }), + listProjects: async () => response, + } +} + describe('subscribe command', () => { it('requires an explicit disclosure choice without a TTY', async () => { const home = tempDir('spool-subscribe-home-') @@ -85,13 +119,30 @@ describe('subscribe command', () => { expect( await handleSubscribeCommand( undefined, - { linkOnly: true, yes: true }, - { ui, homeDir: home, cwd: project, now: () => '2026-07-24T00:00:00.000Z' }, + { linkOnly: true, yes: true, project: PERSONAL_PROJECT.id }, + { + ui, + homeDir: home, + cwd: project, + now: () => '2026-07-24T00:00:00.000Z', + ...projectDeps(project), + }, ), ).toBe(0) expect(output.join('\n')).toContain('Link-only') expect(loadSubscriptions({ homeDir: home })).toEqual([ - { path: project, visibility: 'link-only', addedAt: '2026-07-24T00:00:00.000Z' }, + { + path: project, + visibility: 'link-only', + project: { + hubUrl: 'https://hub.test', + actorId: 'user_00000001', + tenant: { kind: 'user', id: 'user_00000001' }, + localIdentity: { kind: 'path', key: project, displayName: 'spool' }, + remote: PERSONAL_PROJECT, + }, + addedAt: '2026-07-24T00:00:00.000Z', + }, ]) }) @@ -103,13 +154,14 @@ describe('subscribe command', () => { expect( await handleSubscribeCommand( project, - { team: 'Paperboy', yes: true }, + { team: 'Paperboy', yes: true, project: TEAM_PROJECT.id }, { ui, homeDir: home, cwd: project, listTeams: async () => TEAMS, now: () => '2026-07-24T00:00:00.000Z', + ...projectDeps(project, [PERSONAL_PROJECT, TEAM_PROJECT]), }, ), ).toBe(0) @@ -120,6 +172,13 @@ describe('subscribe command', () => { visibility: 'team', teamId: 'team_00000001', teamName: 'Paperboy', + project: { + hubUrl: 'https://hub.test', + actorId: 'user_00000001', + tenant: { kind: 'team', id: 'team_00000001' }, + localIdentity: { kind: 'path', key: project, displayName: 'spool' }, + remote: TEAM_PROJECT, + }, addedAt: '2026-07-24T00:00:00.000Z', }, ]) @@ -141,6 +200,52 @@ describe('subscribe command', () => { expect(loadSubscriptions({ homeDir: home })).toEqual([]) }) + it('accepts a Team handle and rejects an ambiguous display name', async () => { + const home = tempDir('spool-subscribe-home-') + const project = tempDir('spool-subscribe-project-') + const { ui, errors } = capturingUi() + const duplicateTeams: HubTeam[] = [ + { ...TEAMS[0]!, id: 'team_a', name: 'Shared', handle: 'shared-a' }, + { ...TEAMS[0]!, id: 'team_b', name: 'Shared', handle: 'shared-b' }, + ] + + expect( + await handleSubscribeCommand( + project, + { team: 'Shared', createProject: 'Project', yes: true }, + { + ui, + homeDir: home, + cwd: project, + listTeams: async () => duplicateTeams, + ...projectDeps(project, []), + }, + ), + ).toBe(1) + expect(errors.join('\n')).toContain('More than one Team is named "Shared"') + expect(loadSubscriptions({ homeDir: home })).toEqual([]) + + const teamBProject = { + ...TEAM_PROJECT, + id: 'project_team_b', + owner: { ...TEAM_PROJECT.owner, id: 'team_b', handle: 'shared-b', name: 'Shared' }, + } + expect( + await handleSubscribeCommand( + project, + { team: '@shared-b', project: teamBProject.id, yes: true }, + { + ui, + homeDir: home, + cwd: project, + listTeams: async () => duplicateTeams, + ...projectDeps(project, [teamBProject]), + }, + ), + ).toBe(0) + expect(loadSubscriptions({ homeDir: home })[0]?.teamId).toBe('team_b') + }) + it('subscribes Public only as an explicit opt-in and lists it', async () => { const home = tempDir('spool-subscribe-home-') const project = tempDir('spool-subscribe-project-') @@ -149,14 +254,14 @@ describe('subscribe command', () => { expect( await handleSubscribeCommand( project, - { public: true, yes: true }, - { ui, homeDir: home, cwd: project }, + { public: true, yes: true, project: PERSONAL_PROJECT.id }, + { ui, homeDir: home, cwd: project, ...projectDeps(project) }, ), ).toBe(0) expect(loadSubscriptions({ homeDir: home })[0]?.visibility).toBe('public') expect(handleSubscriptionsCommand({ ui, homeDir: home })).toBe(0) - expect(output.join('\n')).toContain(`${project} (Public)`) + expect(output.join('\n')).toContain(`${project} (Public · Project Spool)`) }) it('unsubscribes by path and reports unknown directories', async () => { @@ -166,8 +271,8 @@ describe('subscribe command', () => { await handleSubscribeCommand( project, - { linkOnly: true, yes: true }, - { ui, homeDir: home, cwd: project }, + { linkOnly: true, yes: true, project: PERSONAL_PROJECT.id }, + { ui, homeDir: home, cwd: project, ...projectDeps(project) }, ) expect(await handleUnsubscribeCommand(project, { ui, homeDir: home, cwd: project })).toBe(0) expect(loadSubscriptions({ homeDir: home })).toEqual([]) diff --git a/apps/cli/src/commands/subscribe.ts b/apps/cli/src/commands/subscribe.ts index 8753fed8..8feea5de 100644 --- a/apps/cli/src/commands/subscribe.ts +++ b/apps/cli/src/commands/subscribe.ts @@ -1,8 +1,15 @@ -import { formatCliCommand } from '@spool-lab/core' +import { + formatCliCommand, + getDB, + resolveLocalProjectIdentity, + type ProjectIdentity, +} from '@spool-lab/core' import { Command } from 'commander' -import { HubClient, type HubFetch, type HubTeam } from '../hub/client.js' +import { HubClient, type HubFetch, type HubProjectsResponse, type HubTeam } from '../hub/client.js' import { loadHubCredentials, type HubCredentialOptions } from '../hub/credentials.js' +import { persistResolvedProject, resolveHubProject } from '../hub/project-resolution.js' +import { resolveTeamReference } from '../hub/team-resolution.js' import { addSubscription, canonicalSubscriptionPath, @@ -23,6 +30,8 @@ export interface SubscribeCommandOptions { linkOnly?: boolean public?: boolean yes?: boolean + project?: string + createProject?: string } export interface SubscribeCommandDependencies extends HubCredentialOptions { @@ -31,6 +40,8 @@ export interface SubscribeCommandDependencies extends HubCredentialOptions { fetch?: HubFetch listTeams?: () => Promise now?: () => string + resolveLocalIdentity?: (path: string) => ProjectIdentity + listProjects?: () => Promise } interface VisibilityTarget { @@ -55,6 +66,10 @@ export async function handleSubscribeCommand( ui.error('Pick exactly one of `--team`, `--link-only`, or `--public`.') return 1 } + if (options.project !== undefined && options.createProject !== undefined) { + ui.error('`--project` and `--create-project` cannot be used together.') + return 1 + } const path = canonicalSubscriptionPath(directory ?? cwd, cwd) const target = await resolveVisibilityTarget(ui, options, dependencies) @@ -75,14 +90,57 @@ export async function handleSubscribeCommand( ui.info(disclosureCopy(target)) } + const credentialOptions = pickCredentialOptions(dependencies) + const credentials = loadHubCredentials(credentialOptions) + if (!credentials.token) { + ui.error(`Not logged in. Run \`${formatCliCommand('login')}\` first.`) + return 1 + } + const client = new HubClient({ + hubUrl: credentials.hubUrl, + token: credentials.token, + ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), + }) + const localIdentity = + dependencies.resolveLocalIdentity?.(path) ?? resolveLocalProjectIdentity(getDB(true), path) + const resolvedProject = await resolveHubProject({ + client, + ui, + hubUrl: credentials.hubUrl, + localIdentity, + tenant: + target.visibility === 'team' && target.teamId + ? { kind: 'team', id: target.teamId } + : { kind: 'personal' }, + ...(options.project === undefined ? {} : { projectRef: options.project }), + ...(options.createProject === undefined ? {} : { createProjectName: options.createProject }), + ...(dependencies.listProjects === undefined + ? {} + : { listResponse: await dependencies.listProjects() }), + ...credentialOptions, + }) + if (!resolvedProject) return 1 + const subscription: Subscription = { path, visibility: target.visibility, ...(target.teamId === undefined ? {} : { teamId: target.teamId }), ...(target.teamName === undefined ? {} : { teamName: target.teamName }), + project: { + hubUrl: resolvedProject.hubUrl, + actorId: resolvedProject.actorId, + tenant: resolvedProject.tenant, + localIdentity: { + kind: localIdentity.kind, + key: localIdentity.key, + displayName: localIdentity.displayName, + }, + remote: resolvedProject.project, + }, addedAt: (dependencies.now ?? (() => new Date().toISOString()))(), } - const { added } = addSubscription(subscription, pickCredentialOptions(dependencies)) + const { added } = addSubscription(subscription, credentialOptions) + persistResolvedProject(resolvedProject, credentialOptions) ui.success(added ? `Subscribed ${path}` : `Already subscribed: ${path} (settings updated)`) ui.outro( `Run \`${formatCliCommand('daemon start')}\` to keep subscribed sessions continuously published.`, @@ -109,7 +167,7 @@ async function resolveVisibilityTarget( if (options.team !== undefined) { const teams = await listTeams() const wanted = options.team.trim() - const team = teams.find((entry) => entry.id === wanted || entry.name === wanted) + const team = resolveTeamReference(teams, wanted) if (!team) { ui.error( teams.length === 0 @@ -240,27 +298,39 @@ export function handleSubscriptionsCommand(dependencies: SubscribeCommandDepende } export function subscriptionLabel(subscription: Subscription): string { + const project = subscription.project + ? ` · Project ${subscription.project.remote.name}` + : ' · Project not configured' switch (subscription.visibility) { case 'team': - return `Team · ${subscription.teamName ?? subscription.teamId ?? 'unknown'}` + return `Team · ${subscription.teamName ?? subscription.teamId ?? 'unknown'}${project}` case 'link-only': - return 'Link-only' + return `Link-only${project}` case 'public': - return 'Public' + return `Public${project}` } } export const subscribeCommand = new Command('subscribe') .description('Auto-publish sessions from a directory and its worktrees') .argument('[dir]', 'Directory to subscribe; defaults to the current directory') - .option('--team ', 'Publish subscribed sessions to this Team') + .option('--team ', 'Publish subscribed sessions to this Team') .option('--link-only', 'Publish subscribed sessions as Link-only') .option('--public', 'Publish subscribed sessions as Public (explicit opt-in)') + .option('--project ', 'Bind to this Hub Project') + .option('--create-project ', 'Create and bind a Hub Project') .option('--yes', 'Skip the one-time visibility confirmation') .action( async ( dir: string | undefined, - opts: { team?: string; linkOnly?: boolean; public?: boolean; yes?: boolean }, + opts: { + team?: string + linkOnly?: boolean + public?: boolean + yes?: boolean + project?: string + createProject?: string + }, ) => { const exitCode = await handleSubscribeCommand( dir, @@ -269,6 +339,8 @@ export const subscribeCommand = new Command('subscribe') ...(opts.linkOnly === undefined ? {} : { linkOnly: opts.linkOnly }), ...(opts.public === undefined ? {} : { public: opts.public }), ...(opts.yes === undefined ? {} : { yes: opts.yes }), + ...(opts.project === undefined ? {} : { project: opts.project }), + ...(opts.createProject === undefined ? {} : { createProject: opts.createProject }), }, { ui: createClackUi() }, ) diff --git a/apps/cli/src/commands/sync.test.ts b/apps/cli/src/commands/sync.test.ts index 4e67f3a9..2d93fa93 100644 --- a/apps/cli/src/commands/sync.test.ts +++ b/apps/cli/src/commands/sync.test.ts @@ -32,6 +32,7 @@ describe('createAutoPublisher', () => { published: [], unchanged: 0, skippedSecrets: 0, + skippedUnbound: 0, failed: 0, } diff --git a/apps/cli/src/commands/teams.test.ts b/apps/cli/src/commands/teams.test.ts index ec76f099..4d132195 100644 --- a/apps/cli/src/commands/teams.test.ts +++ b/apps/cli/src/commands/teams.test.ts @@ -38,6 +38,7 @@ const TEAMS: HubTeam[] = [ { id: 'team_00000001', name: 'Paperboy', + handle: 'paperboy', role: 'owner', permissions: [], member_count: 3, @@ -46,6 +47,7 @@ const TEAMS: HubTeam[] = [ { id: 'team_00000002', name: 'Weekend Hacks', + handle: 'weekend-hacks', role: 'member', permissions: [], member_count: 1, @@ -60,7 +62,7 @@ describe('teams command', () => { expect(errors.join('\n')).toContain('Not logged in') }) - it('lists teams with role and member count, and hints at name usage', async () => { + it('lists teams with stable handles, role, and member count', async () => { const { ui, output } = capturingUi() expect( await handleTeamsCommand( @@ -69,9 +71,10 @@ describe('teams command', () => { ), ).toBe(0) const text = output.join('\n') - expect(text).toContain('Team · Paperboy (owner, 3 members)') - expect(text).toContain('Team · Weekend Hacks (member, 1 member)') - expect(text).toContain('subscribe --team ') + expect(text).toContain('Team · Paperboy @paperboy (owner, 3 members)') + expect(text).toContain('Team · Weekend Hacks @weekend-hacks (member, 1 member)') + expect(text).toContain('subscribe --team ') + expect(text).toContain('share --team ') }) it('reports an empty membership', async () => { diff --git a/apps/cli/src/commands/teams.ts b/apps/cli/src/commands/teams.ts index 62cb239a..448ca353 100644 --- a/apps/cli/src/commands/teams.ts +++ b/apps/cli/src/commands/teams.ts @@ -5,9 +5,8 @@ import { HubClient, type HubFetch, type HubTeam } from '../hub/client.js' import { loadHubCredentials, type HubCredentialOptions } from '../hub/credentials.js' import { createClackUi, createTextUi, type CliUi } from '../ui.js' -// Read-only: the Teams the signed-in user belongs to. Names printed here are -// exactly what `spool subscribe --team ` and `spool visibility … --team -// ` accept — the id is only needed when two Teams share a name. +// Read-only: the Teams the signed-in user belongs to. Stable handles are the +// preferred CLI reference; a unique display name remains a convenience. export interface TeamsCommandOptions { json?: boolean @@ -53,10 +52,11 @@ export async function handleTeamsCommand( } for (const team of teams) { const members = `${team.member_count} member${team.member_count === 1 ? '' : 's'}` - ui.info(`Team · ${team.name} (${team.role}, ${members})`) + const handle = team.handle ? ` @${team.handle}` : '' + ui.info(`Team · ${team.name}${handle} (${team.role}, ${members})`) } ui.info( - `Use a name with \`${formatCliCommand('subscribe --team ')}\` or \`${formatCliCommand('visibility team --team ')}\`.`, + `Use a handle with \`${formatCliCommand('subscribe --team ')}\`, \`${formatCliCommand('share --team ')}\`, or \`${formatCliCommand('visibility team --team ')}\`.`, ) return 0 } catch (cause) { diff --git a/apps/cli/src/commands/visibility.test.ts b/apps/cli/src/commands/visibility.test.ts index 14bf75ef..0258fc7a 100644 --- a/apps/cli/src/commands/visibility.test.ts +++ b/apps/cli/src/commands/visibility.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' -import type { HubTeam } from '../hub/client.js' +import type { HubProject, HubTeam } from '../hub/client.js' import { createTextUi } from '../ui.js' import { handleVisibilityCommand } from './visibility.js' @@ -38,17 +38,57 @@ const TEAMS: HubTeam[] = [ { id: 'team_00000001', name: 'Paperboy', + handle: 'paperboy', role: 'admin', permissions: [], member_count: 3, archived_at: null, }, ] +const PERSONAL_PROJECT: HubProject = { + id: 'project_personal01', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user', id: 'user_00000001', handle: 'evan', name: 'Evan' }, + can_manage: true, +} +const TEAM_PROJECT: HubProject = { + ...PERSONAL_PROJECT, + id: 'project_team000001', + slug: 'paperboy', + name: 'Paperboy', + owner: { kind: 'team', id: 'team_00000001', handle: 'paperboy', name: 'Paperboy' }, +} function patchResponder( session: Record, ): (url: unknown, init?: RequestInit) => Promise { return async (url, init) => { + const path = new URL(String(url)).pathname + if (init?.method === 'GET' && path === '/api/hub/v1/sessions/claude_abc12345') { + return Response.json({ + sid: 'claude_abc12345', + root: 'root', + count: 1, + sig: null, + cardJson: null, + summaryMd: null, + lineageJson: null, + viewOid: 'view', + createdAt: 1, + updatedAt: 1, + project: PERSONAL_PROJECT, + author: { handle: 'evan', displayName: 'Evan', avatarUrl: null }, + }) + } + if (init?.method === 'GET' && path === '/api/hub/v1/projects') { + return Response.json({ + actor: { id: 'user_00000001' }, + projects: [PERSONAL_PROJECT, TEAM_PROJECT], + }) + } if (init?.method === 'PATCH') { return new Response(JSON.stringify({ session }), { status: 200, @@ -136,7 +176,7 @@ describe('visibility command', () => { await handleVisibilityCommand( 'claude_abc12345', 'team', - { team: 'Paperboy', yes: true }, + { team: '@paperboy', project: TEAM_PROJECT.id, yes: true }, { ui, homeDir: tempHome(), @@ -147,14 +187,53 @@ describe('visibility command', () => { ), ).toBe(0) - const [, init] = fetch.mock.calls[0] as [string, RequestInit] + const [, init] = fetch.mock.calls.find((call) => call[1]?.method === 'PATCH') as [ + string, + RequestInit, + ] expect(JSON.parse(String(init.body))).toEqual({ visibility: 'team', team_id: 'team_00000001', + project_id: TEAM_PROJECT.id, + expected_project_id: PERSONAL_PROJECT.id, }) expect(output.join('\n')).toContain('Team · Paperboy only') }) + it('rejects an ambiguous Team display name before creating or transferring', async () => { + const { ui, errors } = capturingUi() + const fetch = vi.fn( + patchResponder({ + sid: 'claude_abc12345', + visibility: 'team', + team_id: 'team_a', + team_name: 'Shared', + }), + ) + const duplicateTeams: HubTeam[] = [ + { ...TEAMS[0]!, id: 'team_a', name: 'Shared', handle: 'shared-a' }, + { ...TEAMS[0]!, id: 'team_b', name: 'Shared', handle: 'shared-b' }, + ] + + expect( + await handleVisibilityCommand( + 'claude_abc12345', + 'team', + { team: 'Shared', createProject: 'Project', yes: true }, + { + ui, + homeDir: tempHome(), + env: LOGGED_IN, + fetch: fetch as never, + listTeams: async () => duplicateTeams, + }, + ), + ).toBe(1) + expect(errors.join('\n')).toContain('More than one Team is named "Shared"') + expect(fetch.mock.calls.some((call) => call[1]?.method === 'PATCH')).toBe(false) + expect(fetch.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false) + }) + it('explains a 403 as a Team role problem', async () => { const { ui, errors } = capturingUi() const fetch = vi.fn(async () => new Response('forbidden', { status: 403 })) diff --git a/apps/cli/src/commands/visibility.ts b/apps/cli/src/commands/visibility.ts index d4e1511b..8c04bd1e 100644 --- a/apps/cli/src/commands/visibility.ts +++ b/apps/cli/src/commands/visibility.ts @@ -1,9 +1,20 @@ +import { createHash } from 'node:crypto' + import { formatCliCommand, formatCliInstallHint } from '@spool-lab/core' import { Command } from 'commander' -import { HubClient, HubHttpError, type HubFetch, type HubTeam } from '../hub/client.js' +import { + HubClient, + HubHttpError, + type HubFetch, + type HubProject, + type HubSessionMeta, + type HubTeam, +} from '../hub/client.js' import { loadHubCredentials, type HubCredentialOptions } from '../hub/credentials.js' +import { findProjectByReference, projectLabel } from '../hub/project-resolution.js' import { resolveSessionRef } from '../hub/ref.js' +import { resolveTeamReference } from '../hub/team-resolution.js' import { createClackUi, createTextUi, type CliUi } from '../ui.js' // Disclosure changes are named, confirmed actions (DESIGN.md): moving a @@ -17,6 +28,8 @@ export type VisibilityTargetName = (typeof TARGETS)[number] export interface VisibilityCommandOptions { team?: string yes?: boolean + project?: string + createProject?: string } export interface VisibilityCommandDependencies extends HubCredentialOptions { @@ -39,6 +52,17 @@ export async function handleVisibilityCommand( return 1 } const visibility = target as VisibilityTargetName + if (options.project !== undefined && options.createProject !== undefined) { + ui.error('`--project` and `--create-project` cannot be used together.') + return 1 + } + if ( + visibility !== 'team' && + (options.project !== undefined || options.createProject !== undefined) + ) { + ui.error('`--project` and `--create-project` are only valid when targeting `team`.') + return 1 + } const ref = resolveSessionRef(input) const credentials = loadHubCredentials(pickCredentialOptions(dependencies)) @@ -52,15 +76,20 @@ export async function handleVisibilityCommand( token: credentials.token, ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), }) + let current: HubSessionMeta | undefined let teamId: string | undefined let teamName: string | undefined + let project: HubProject | undefined + let pendingProjectName: string | undefined + let projectActorId: string | undefined if (visibility === 'team') { + current = await client.getSession(ref.sid) const listTeams = dependencies.listTeams ?? ((forClient) => forClient.listTeams()) const teams = await listTeams(client) if (options.team !== undefined) { const wanted = options.team.trim() - const team = teams.find((entry) => entry.id === wanted || entry.name === wanted) + const team = resolveTeamReference(teams, wanted) if (!team) { ui.error( teams.length === 0 @@ -89,9 +118,76 @@ export async function handleVisibilityCommand( ui.error('Pass `--team ` to choose the target Team.') return 1 } + + const { actor, projects } = await client.listProjects() + projectActorId = actor.id + const teamProjects = projects.filter( + (entry) => entry.owner.kind === 'team' && entry.owner.id === teamId, + ) + if (options.project !== undefined) { + project = findProjectByReference(teamProjects, options.project) + if (!project) { + ui.error( + teamProjects.length === 0 + ? `Team · ${teamName ?? teamId} has no writable Projects.` + : `No Project matches "${options.project}". Available Projects: ${teamProjects.map(projectLabel).join(', ')}`, + ) + return 1 + } + } else if (options.createProject !== undefined) { + const name = options.createProject.trim() + if (!name) { + ui.error('`--create-project` requires a non-empty Project name.') + return 1 + } + pendingProjectName = name + } else if (current.project?.owner.kind === 'team' && current.project.owner.id === teamId) { + project = current.project + } else if (ui.interactive) { + const createValue = '__create_project__' + const selected = await ui.select({ + message: `Which Team Project should own this Session?`, + choices: [ + ...teamProjects.map((entry) => ({ + value: entry.id, + label: entry.name, + hint: projectLabel(entry), + })), + { + value: createValue, + label: `Create Project “${current.project?.name ?? 'Imported sessions'}”`, + hint: `owned by Team · ${teamName ?? teamId}`, + }, + ], + initialValue: teamProjects[0]?.id ?? createValue, + }) + if (selected === null) { + ui.cancel('Visibility unchanged.') + return 1 + } + if (selected === createValue) { + pendingProjectName = current.project?.name ?? 'Imported sessions' + } else { + project = teamProjects.find((entry) => entry.id === selected) + } + } else { + ui.error( + 'Moving a Session to a Team requires an explicit Team Project. ' + + 'Pass `--project ` or `--create-project `.', + ) + return 1 + } + if (!project && !pendingProjectName) { + ui.error('The selected Team Project is no longer available.') + return 1 + } } - const confirmation = confirmationCopy(visibility, teamName ?? teamId) + const confirmation = confirmationCopy( + visibility, + teamName ?? teamId, + project?.name ?? pendingProjectName, + ) if (options.yes !== true) { if (!ui.interactive) { ui.error('Cannot confirm a disclosure change without a TTY. Re-run with `--yes`.') @@ -106,7 +202,27 @@ export async function handleVisibilityCommand( ui.info(confirmation) } - const session = await client.updateSessionVisibility(ref.sid, visibility, teamId) + if (pendingProjectName) { + if (!teamId || !projectActorId || !current) { + throw new Error('Cannot create a Team Project without a resolved Team and Session.') + } + project = await client.createProject( + { name: pendingProjectName, owner: { kind: 'team', id: teamId } }, + visibilityProjectIdempotencyKey( + ref.sid, + projectActorId, + teamId, + current.project?.id ?? null, + pendingProjectName, + ), + ) + } + + const session = await client.updateSessionVisibility(ref.sid, visibility, { + ...(teamId === undefined ? {} : { teamId }), + ...(project === undefined ? {} : { projectId: project.id }), + ...(project === undefined ? {} : { expectedProjectId: current?.project?.id ?? null }), + }) ui.success(`Session ${ref.sid} is now ${describeVisibility(session)}.`) ui.outro('Disclosure updated.') return 0 @@ -121,14 +237,18 @@ export async function handleVisibilityCommand( } } -function confirmationCopy(visibility: VisibilityTargetName, teamLabel?: string): string { +function confirmationCopy( + visibility: VisibilityTargetName, + teamLabel?: string, + projectName?: string, +): string { switch (visibility) { case 'public': return 'Make this Session Public? It can appear in Explore, search, and your Profile; a Team-owned Session stays owned by the Team.' case 'link-only': return 'Make this Session Link-only? Anyone with the URL can read it, and it leaves Explore and search.' case 'team': - return `Move this Session to Team · ${teamLabel ?? 'unknown'}? Only current members can read it, the Team owns it from now on, and any public discovery is removed.` + return `Move this Session to Team · ${teamLabel ?? 'unknown'} / Project ${projectName ?? 'unknown'}? Only current members can read it, the Team owns it from now on, and any public discovery is removed.` } } @@ -160,20 +280,47 @@ export const visibilityCommand = new Command('visibility') .description('Change a published session’s disclosure (Team, Link-only, or Public)') .argument('', 'Shared session ID or URL') .argument('', `One of: ${TARGETS.join(', ')}`) - .option('--team ', 'Target Team when moving a session to a Team') + .option('--team ', 'Target Team when moving a session to a Team') + .option('--project ', 'Target Project when moving a session to a Team') + .option('--create-project ', 'Create the target Team Project') .option('--yes', 'Skip the confirmation') - .action(async (input: string, target: string, opts: { team?: string; yes?: boolean }) => { - const exitCode = await handleVisibilityCommand( - input, - target, - { - ...(opts.team === undefined ? {} : { team: opts.team }), - ...(opts.yes === undefined ? {} : { yes: opts.yes }), + .action( + async ( + input: string, + target: string, + opts: { + team?: string + yes?: boolean + project?: string + createProject?: string }, - { ui: createClackUi() }, - ) - if (exitCode !== 0) process.exitCode = exitCode - }) + ) => { + const exitCode = await handleVisibilityCommand( + input, + target, + { + ...(opts.team === undefined ? {} : { team: opts.team }), + ...(opts.yes === undefined ? {} : { yes: opts.yes }), + ...(opts.project === undefined ? {} : { project: opts.project }), + ...(opts.createProject === undefined ? {} : { createProject: opts.createProject }), + }, + { ui: createClackUi() }, + ) + if (exitCode !== 0) process.exitCode = exitCode + }, + ) + +function visibilityProjectIdempotencyKey( + sid: string, + actorId: string, + teamId: string, + currentProjectId: string | null, + name: string, +): string { + return `spool-project-${createHash('sha256') + .update(JSON.stringify({ sid, actorId, teamId, currentProjectId, name })) + .digest('hex')}` +} function pickCredentialOptions(dependencies: HubCredentialOptions): HubCredentialOptions { return { diff --git a/apps/cli/src/hub/auto-publish.test.ts b/apps/cli/src/hub/auto-publish.test.ts index a9a23adf..e9ce6e99 100644 --- a/apps/cli/src/hub/auto-publish.test.ts +++ b/apps/cli/src/hub/auto-publish.test.ts @@ -2,11 +2,16 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { runMigrations } from '@spool-lab/core' +import Database from 'better-sqlite3' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' import type { Subscription } from '../subscriptions.js' import { createTextUi } from '../ui.js' import { + autoPublishStateKey, + listCandidatesFromIndex, + mostSpecificMatchingSubscription, publishTarget, runAutoPublish, type AutoPublishCandidate, @@ -43,8 +48,45 @@ const LOGGED_IN_ENV = { SPOOL_HUB_URL: 'https://hub.test', SPOOL_HUB_TOKEN: 'tes const SUBSCRIPTION: Subscription = { path: '/repos/spool', visibility: 'public', + project: { + hubUrl: 'https://hub.test', + actorId: 'user_00000001', + tenant: { kind: 'user', id: 'user_00000001' }, + localIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/spool', + displayName: 'spool', + }, + remote: { + id: 'project_personal01', + slug: 'spool', + name: 'Spool', + description: null, + github_url: 'https://github.com/paperboytm/spool', + owner: { kind: 'user', id: 'user_00000001', handle: 'evan', name: 'Evan' }, + can_manage: true, + }, + }, addedAt: '2026-07-24T00:00:00.000Z', } +const TEAM_PROJECT = { + ...SUBSCRIPTION.project!.remote, + id: 'project_team000001', + slug: 'paperboy', + name: 'Paperboy', + owner: { kind: 'team' as const, id: 'team_00000001', handle: 'paperboy', name: 'Paperboy' }, +} +const TEAM_SUBSCRIPTION: Subscription = { + ...SUBSCRIPTION, + visibility: 'team', + teamId: 'team_00000001', + teamName: 'Paperboy', + project: { + ...SUBSCRIPTION.project!, + tenant: { kind: 'team', id: 'team_00000001' }, + remote: TEAM_PROJECT, + }, +} function candidate(overrides: Partial = {}): AutoPublishCandidate { return { @@ -52,6 +94,11 @@ function candidate(overrides: Partial = {}): AutoPublishCa sessionUuid: 'abc12345', filePath: '/repos/spool/session.jsonl', cwd: '/repos/spool/src', + localIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/spool', + displayName: 'spool', + }, jsonl: '{"type":"message"}', ...overrides, } @@ -83,10 +130,14 @@ function engineDeps( fetch: async () => new Response('not found', { status: 404 }), match: { resolvers: [], listWorktrees: () => [] }, loadSubscriptions: () => [SUBSCRIPTION], + listProjects: async () => ({ + actor: { id: 'user_00000001' }, + projects: [SUBSCRIPTION.project!.remote, TEAM_PROJECT], + }), listCandidates: () => [candidate()], prepare: async () => fakePrepared(['{"type":"message"}']), publish: async () => ({ url: 'https://hub.test/s/claude_abc12345' }), - loadState: () => ({ version: 1, sessions: {} }), + loadState: () => ({ version: 2, sessions: {} }), saveState: (state) => savedStates.push(state), savedStates, ...overrides, @@ -94,6 +145,59 @@ function engineDeps( } describe('runAutoPublish', () => { + it('queries candidates through the migrated SQLite schema used in production', () => { + const db = new Database(':memory:') + try { + runMigrations(db) + db.prepare( + `INSERT INTO projects + (source_id, slug, display_path, display_name, identity_kind, identity_key) + VALUES ( + (SELECT id FROM sources WHERE name = 'codex'), + 'spool', + '/repos/spool', + 'Spool', + 'git_remote', + 'github.com/paperboytm/spool' + )`, + ).run() + db.prepare( + `INSERT INTO sessions + (project_id, source_id, session_uuid, file_path, title, started_at, ended_at, + message_count, has_tool_use, cwd, raw_file_mtime) + VALUES ( + (SELECT id FROM projects WHERE slug = 'spool'), + (SELECT id FROM sources WHERE name = 'codex'), + '019f0000-0000-7000-8000-000000000001', + '/repos/spool/session.jsonl', + 'Ship Projects', + '2026-07-26T00:00:00.000Z', + '2026-07-26T01:00:00.000Z', + 1, + 0, + '/repos/spool', + '2026-07-26T01:00:00.000Z' + )`, + ).run() + + expect(listCandidatesFromIndex(db)).toEqual([ + { + provider: 'codex', + sessionUuid: '019f0000-0000-7000-8000-000000000001', + filePath: '/repos/spool/session.jsonl', + cwd: '/repos/spool', + localIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/spool', + displayName: 'Spool', + }, + }, + ]) + } finally { + db.close() + } + }) + it('returns null when nothing is subscribed', async () => { const { ui, output, errors } = capturingUi() const result = await runAutoPublish(ui, engineDeps({ loadSubscriptions: () => [] })) @@ -120,6 +224,7 @@ describe('runAutoPublish', () => { published: [{ sid: 'claude_abc12345', url: 'https://hub.test/s/claude_abc12345' }], unchanged: 0, skippedSecrets: 0, + skippedUnbound: 0, failed: 0, }) expect(publish).toHaveBeenCalledTimes(1) @@ -128,8 +233,9 @@ describe('runAutoPublish', () => { const state = deps.savedStates[0] as { sessions: Record } - expect(state.sessions['claude_abc12345']?.url).toBe('https://hub.test/s/claude_abc12345') - expect(state.sessions['claude_abc12345']?.fingerprint).toMatch(/^sha256:/) + const key = autoPublishStateKey('https://hub.test', 'user_00000001', 'claude_abc12345') + expect(state.sessions[key]?.url).toBe('https://hub.test/s/claude_abc12345') + expect(state.sessions[key]?.fingerprint).toMatch(/^sha256:/) }) it('skips sessions whose fingerprint has not changed', async () => { @@ -142,7 +248,7 @@ describe('runAutoPublish', () => { .sessions const second = await runAutoPublish( ui, - engineDeps({ publish, loadState: () => ({ version: 1, sessions: recorded }) }), + engineDeps({ publish, loadState: () => ({ version: 2, sessions: recorded }) }), ) expect(first?.published).toHaveLength(1) @@ -161,6 +267,63 @@ describe('runAutoPublish', () => { expect(publish).not.toHaveBeenCalled() }) + it('uses the most specific nested subscription regardless of file order', async () => { + const nested: Subscription = { + ...SUBSCRIPTION, + path: '/repos/spool/packages/vapor', + visibility: 'link-only', + project: { + ...SUBSCRIPTION.project!, + localIdentity: { + kind: 'git_remote', + key: 'github.com/paperboytm/react-vapor', + displayName: 'react-vapor', + }, + remote: { + ...SUBSCRIPTION.project!.remote, + id: 'project_vapor00001', + slug: 'react-vapor', + name: 'React Vapor', + }, + }, + } + const nestedCandidate = candidate({ + cwd: '/repos/spool/packages/vapor/src', + localIdentity: nested.project!.localIdentity, + }) + expect( + mostSpecificMatchingSubscription(nestedCandidate.cwd, [SUBSCRIPTION, nested], { + resolvers: [], + listWorktrees: () => [], + }), + ).toBe(nested) + + const seen: unknown[] = [] + const publish = vi.fn(async (_client: unknown, _prepared: unknown, options: unknown) => { + seen.push(options) + return { url: 'https://hub.test/s/x' } + }) + const { ui } = capturingUi() + const result = await runAutoPublish( + ui, + engineDeps({ + loadSubscriptions: () => [SUBSCRIPTION, nested], + listProjects: async () => ({ + actor: { id: 'user_00000001' }, + projects: [SUBSCRIPTION.project!.remote, nested.project!.remote], + }), + listCandidates: () => [nestedCandidate], + publish: publish as never, + }), + ) + + expect(result).toMatchObject({ matched: 1, published: [{ sid: 'claude_abc12345' }] }) + expect(seen[0]).toMatchObject({ + visibility: 'link-only', + projectId: nested.project!.remote.id, + }) + }) + it('never auto-publishes a session with secret findings and warns once per content', async () => { const { ui, output } = capturingUi() const publish = vi.fn(async () => ({ url: 'https://hub.test/s/x' })) @@ -179,7 +342,8 @@ describe('runAutoPublish', () => { sessions: Record } ).sessions - expect(recorded['claude_abc12345']?.skippedSecrets).toBe(true) + const key = autoPublishStateKey('https://hub.test', 'user_00000001', 'claude_abc12345') + expect(recorded[key]?.skippedSecrets).toBe(true) // The unchanged transcript does not warn again on the next pass. const { ui: secondUi, output: secondOutput } = capturingUi() @@ -188,7 +352,7 @@ describe('runAutoPublish', () => { engineDeps({ publish, prepare: async () => fakePrepared(['{"key":"AKIAQZWSXEDCRFVTGBYH"}']), - loadState: () => ({ version: 1, sessions: recorded }), + loadState: () => ({ version: 2, sessions: recorded }), }), ) expect(second).toMatchObject({ unchanged: 1, skippedSecrets: 0 }) @@ -210,15 +374,14 @@ describe('runAutoPublish', () => { }), ) expect(seen[0]).toMatchObject({ visibility: 'link-only' }) + expect(seen[0]).toMatchObject({ projectId: 'project_personal01' }) seen.length = 0 await runAutoPublish( ui, engineDeps({ publish: publish as never, - loadSubscriptions: () => [ - { ...SUBSCRIPTION, visibility: 'team', teamId: 'team_00000001', teamName: 'Paperboy' }, - ], + loadSubscriptions: () => [TEAM_SUBSCRIPTION], }), ) expect(seen[0]).toMatchObject({ visibility: 'team', teamId: 'team_00000001' }) @@ -233,9 +396,10 @@ describe('runAutoPublish', () => { // Public subscriptions degrade to Link-only for providers Explore // does not support yet, instead of failing every pass. expect(publishTarget(SUBSCRIPTION, 'gemini')).toEqual({ visibility: 'link-only' }) - expect( - publishTarget({ ...SUBSCRIPTION, visibility: 'team', teamId: 'team_00000001' }, 'gemini'), - ).toEqual({ visibility: 'team', teamId: 'team_00000001' }) + expect(publishTarget(TEAM_SUBSCRIPTION, 'gemini')).toEqual({ + visibility: 'team', + teamId: 'team_00000001', + }) expect(publishTarget({ ...SUBSCRIPTION, visibility: 'link-only' }, 'claude')).toEqual({ visibility: 'link-only', }) @@ -254,4 +418,65 @@ describe('runAutoPublish', () => { expect(result).toMatchObject({ failed: 1, published: [] }) expect(output.join('\n')).toContain('hub unavailable') }) + + it('fails closed for legacy subscriptions without a Project binding', async () => { + const { ui, output } = capturingUi() + const publish = vi.fn(async () => ({ url: 'https://hub.test/s/x' })) + const result = await runAutoPublish( + ui, + engineDeps({ + publish, + loadSubscriptions: () => [ + { + path: SUBSCRIPTION.path, + visibility: 'public', + addedAt: SUBSCRIPTION.addedAt, + }, + ], + }), + ) + expect(result).toMatchObject({ skippedUnbound: 1, published: [] }) + expect(publish).not.toHaveBeenCalled() + expect(output.join('\n')).toContain('legacy subscription has no Hub Project') + }) + + it('fails closed when the subscription belongs to another Hub or account', async () => { + const { ui, output } = capturingUi() + const publish = vi.fn(async () => ({ url: 'https://hub.test/s/x' })) + const wrongHub = await runAutoPublish( + ui, + engineDeps({ + publish, + loadSubscriptions: () => [ + { + ...SUBSCRIPTION, + project: { ...SUBSCRIPTION.project!, hubUrl: 'https://other.test' }, + }, + ], + }), + ) + expect(wrongHub).toMatchObject({ skippedUnbound: 1, published: [] }) + + const wrongActor = await runAutoPublish( + ui, + engineDeps({ + publish, + listProjects: async () => ({ actor: { id: 'user_00000002' }, projects: [] }), + }), + ) + expect(wrongActor).toMatchObject({ skippedUnbound: 1, published: [] }) + expect(publish).not.toHaveBeenCalled() + expect(output.join('\n')).toContain('different Hub') + expect(output.join('\n')).toContain('different signed-in account') + }) + + it('scopes incremental state by Hub and actor', () => { + const sid = 'claude_abc12345' + expect(autoPublishStateKey('https://hub.test', 'user_1', sid)).not.toBe( + autoPublishStateKey('https://other.test', 'user_1', sid), + ) + expect(autoPublishStateKey('https://hub.test', 'user_1', sid)).not.toBe( + autoPublishStateKey('https://hub.test', 'user_2', sid), + ) + }) }) diff --git a/apps/cli/src/hub/auto-publish.ts b/apps/cli/src/hub/auto-publish.ts index 29982878..6ebe72dc 100644 --- a/apps/cli/src/hub/auto-publish.ts +++ b/apps/cli/src/hub/auto-publish.ts @@ -3,17 +3,30 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { getDB, getSessionWithMessages, serializeIndexedSession } from '@spool-lab/core' +import { + getDB, + getSessionWithMessages, + serializeIndexedSession, + type ProjectIdentity, +} from '@spool-lab/core' import { isDiscoverySessionProvider, isResumableSessionProvider, + sessionRecordData, type SessionProvider, } from '@spool-lab/session-kit' +import type Database from 'better-sqlite3' import { loadSubscriptions, type Subscription } from '../subscriptions.js' import type { CliUi } from '../ui.js' -import { HubClient, HubHttpError, type HubFetch } from './client.js' -import { loadHubCredentials, type HubCredentialOptions } from './credentials.js' +import { + HubClient, + HubHttpError, + type HubFetch, + type HubProjectsResponse, + type HubSessionMeta, +} from './client.js' +import { loadHubCredentials, normalizeHubUrl, type HubCredentialOptions } from './credentials.js' import { publishPreparedShare } from './publish.js' import { formatRedactSummary, scanRecordsForSecrets } from './redact-gate.js' import { prepareShare, type PreparedShare } from './share-pipeline.js' @@ -32,6 +45,7 @@ export interface AutoPublishCandidate { sessionUuid: string filePath: string cwd: string + localIdentity: ProjectIdentity /** Portable JSONL for indexed sources without native provider records. */ jsonl?: string } @@ -41,6 +55,7 @@ export interface AutoPublishResult { published: { sid: string; url: string }[] unchanged: number skippedSecrets: number + skippedUnbound: number failed: number } @@ -51,7 +66,7 @@ interface AutoPublishStateEntry { } interface AutoPublishState { - version: 1 + version: 2 sessions: Record } @@ -62,6 +77,7 @@ export interface AutoPublishDependencies extends HubCredentialOptions { listCandidates?: () => AutoPublishCandidate[] prepare?: typeof prepareShare publish?: typeof publishPreparedShare + listProjects?: (client: HubClient) => Promise loadState?: () => AutoPublishState saveState?: (state: AutoPublishState) => void now?: () => string @@ -95,8 +111,10 @@ export async function runAutoPublish( const candidates = (dependencies.listCandidates ?? listCandidatesFromIndex)() const matched: Array<{ candidate: AutoPublishCandidate; subscription: Subscription }> = [] for (const candidate of candidates) { - const subscription = subscriptions.find((entry) => - sessionMatchesSubscription(candidate.cwd, entry.path, dependencies.match ?? {}), + const subscription = mostSpecificMatchingSubscription( + candidate.cwd, + subscriptions, + dependencies.match ?? {}, ) if (subscription) matched.push({ candidate, subscription }) } @@ -106,6 +124,7 @@ export async function runAutoPublish( published: [], unchanged: 0, skippedSecrets: 0, + skippedUnbound: 0, failed: 0, } if (matched.length === 0) return result @@ -120,12 +139,52 @@ export async function runAutoPublish( token: credentials.token, ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }), }) + const listProjects = dependencies.listProjects ?? ((forClient) => forClient.listProjects()) + const projectsResponse = await listProjects(client) const prepare = dependencies.prepare ?? prepareShare const publish = dependencies.publish ?? publishPreparedShare const homeDir = dependencies.homeDir ?? homedir() + const warnedUnboundPaths = new Set() for (const { candidate, subscription } of matched) { const sid = `${candidate.provider}_${candidate.sessionUuid}` + if (!subscription.project) { + result.skippedUnbound += 1 + if (!warnedUnboundPaths.has(subscription.path)) { + warnedUnboundPaths.add(subscription.path) + ui.warn( + `Skipped auto-publish for ${subscription.path}: this legacy subscription has no Hub Project. ` + + `Run \`spool subscribe ${JSON.stringify(subscription.path)} --project \` to bind it.`, + ) + } + continue + } + const bindingProblem = subscriptionProjectBindingProblem( + subscription, + credentials.hubUrl, + projectsResponse, + ) + if (bindingProblem) { + result.skippedUnbound += 1 + if (!warnedUnboundPaths.has(subscription.path)) { + warnedUnboundPaths.add(subscription.path) + ui.warn(`Skipped auto-publish for ${subscription.path}: ${bindingProblem}`) + } + continue + } + if ( + subscription.project.localIdentity.kind !== candidate.localIdentity.kind || + subscription.project.localIdentity.key !== candidate.localIdentity.key + ) { + result.skippedUnbound += 1 + if (!warnedUnboundPaths.has(subscription.path)) { + warnedUnboundPaths.add(subscription.path) + ui.warn( + `Skipped auto-publish for ${subscription.path}: its local Project no longer matches the subscription binding.`, + ) + } + continue + } let jsonl: string try { jsonl = candidate.jsonl ?? readFileSync(candidate.filePath, 'utf8') @@ -133,9 +192,16 @@ export async function runAutoPublish( // Provider file pruned between indexing and publish — nothing to send. continue } - const fingerprint = candidateFingerprint(candidate, jsonl) - const previous = state.sessions[sid] - if (previous?.fingerprint === fingerprint) { + const stateKey = autoPublishStateKey(credentials.hubUrl, projectsResponse.actor.id, sid) + const scopedFingerprint = publicationFingerprint( + candidate, + jsonl, + subscription, + credentials.hubUrl, + projectsResponse.actor.id, + ) + const previous = state.sessions[stateKey] + if (previous?.fingerprint === scopedFingerprint) { result.unchanged += 1 continue } @@ -155,12 +221,14 @@ export async function runAutoPublish( continue } - const secrets = scanRecordsForSecrets(prepared.records.map((record) => record.data)) + const secrets = scanRecordsForSecrets( + prepared.records.map((record) => sessionRecordData(record)), + ) if (secrets.total > 0) { result.skippedSecrets += 1 // Remember the fingerprint so the warning fires once per content change // instead of on every pass. - state.sessions[sid] = { fingerprint, skippedSecrets: true } + state.sessions[stateKey] = { fingerprint: scopedFingerprint, skippedSecrets: true } ui.warn( `Skipped auto-publish of ${sid}.\n${formatRedactSummary(secrets)}\n` + 'Review it and publish manually with `spool share` if intended.', @@ -169,13 +237,23 @@ export async function runAutoPublish( } try { - const summary = await existingSummary(client, prepared.sid) + const existing = await existingSession(client, prepared.sid) + if (existing?.project && existing.project.id !== subscription.project.remote.id) { + result.failed += 1 + ui.warn( + `Could not publish ${sid}: the hosted Session belongs to Project ${existing.project.name}; ` + + 'automatic publishing will not move it silently.', + ) + continue + } const published = await publish(client, prepared, { card: buildWorkspaceCard(detectWorkspaceRoot(candidate.cwd)), - summary, + summary: existing?.summaryMd ?? null, + projectId: subscription.project.remote.id, + expectedProjectId: existing?.project?.id ?? null, ...publishTarget(subscription, candidate.provider), }) - state.sessions[sid] = { fingerprint, url: published.url } + state.sessions[stateKey] = { fingerprint: scopedFingerprint, url: published.url } result.published.push({ sid, url: published.url }) } catch (cause) { result.failed += 1 @@ -198,6 +276,11 @@ export function reportAutoPublish(ui: CliUi, result: AutoPublishResult | null): `${result.failed} subscribed session${result.failed === 1 ? '' : 's'} failed to publish.`, ) } + if (result.skippedUnbound > 0) { + ui.warn( + `${result.skippedUnbound} subscribed session${result.skippedUnbound === 1 ? '' : 's'} skipped because Project binding is missing or stale.`, + ) + } } /** Map the subscribed disclosure to a publish target. Public is only sent @@ -221,19 +304,43 @@ export function publishTarget( } } -function listCandidatesFromIndex(): AutoPublishCandidate[] { - const db = getDB(true) +/** + * Read publish candidates from the real local index schema. + * + * Keep the database injectable so the production SQL is exercised against a + * migrated SQLite database in tests. This query must not be replaced by a + * hand-built fixture: schema drift here disables the daemon for every + * subscription at once. + */ +export function listCandidatesFromIndex( + db: Database.Database = getDB(true), +): AutoPublishCandidate[] { const rows = db .prepare( - `SELECT session_uuid, source, file_path, cwd FROM sessions - WHERE cwd IS NOT NULL AND file_path NOT LIKE 'spool:%' - ORDER BY ended_at DESC`, + `SELECT s.session_uuid, + src.name AS source, + s.file_path, + s.cwd, + p.identity_kind, + p.identity_key, + p.display_name + FROM sessions s + JOIN sources src ON src.id = s.source_id + JOIN projects p ON p.id = s.project_id + WHERE s.cwd IS NOT NULL + AND s.file_path NOT LIKE 'spool:%' + AND p.identity_kind IS NOT NULL + AND p.identity_key IS NOT NULL + ORDER BY s.ended_at DESC`, ) .all() as Array<{ session_uuid: string source: SessionProvider file_path: string cwd: string + identity_kind: ProjectIdentity['kind'] + identity_key: string + display_name: string }> const candidates: AutoPublishCandidate[] = [] for (const row of rows) { @@ -243,6 +350,11 @@ function listCandidatesFromIndex(): AutoPublishCandidate[] { sessionUuid: row.session_uuid, filePath: row.file_path, cwd: row.cwd, + localIdentity: { + kind: row.identity_kind, + key: row.identity_key, + displayName: row.display_name, + }, }) continue } @@ -253,6 +365,11 @@ function listCandidatesFromIndex(): AutoPublishCandidate[] { sessionUuid: row.session_uuid, filePath: row.file_path, cwd: row.cwd, + localIdentity: { + kind: row.identity_kind, + key: row.identity_key, + displayName: row.display_name, + }, jsonl: serializeIndexedSession(found.session, found.messages), }) } @@ -274,9 +391,113 @@ function candidateFingerprint(candidate: AutoPublishCandidate, jsonl: string): s return `sha256:${createHash('sha256').update(jsonl).digest('hex')}` } -async function existingSummary(client: HubClient, sid: string): Promise { +function publicationFingerprint( + candidate: AutoPublishCandidate, + jsonl: string, + subscription: Subscription, + hubUrl: string, + actorId: string, +): string { + return `sha256:${createHash('sha256') + .update(candidateFingerprint(candidate, jsonl)) + .update( + JSON.stringify({ + hubUrl: normalizeHubUrl(hubUrl), + actorId, + visibility: subscription.visibility, + teamId: subscription.teamId ?? null, + projectId: subscription.project?.remote.id ?? null, + tenant: subscription.project?.tenant ?? null, + }), + ) + .digest('hex')}` +} + +export function autoPublishStateKey(hubUrl: string, actorId: string, sid: string): string { + const digest = createHash('sha256') + .update(JSON.stringify({ hubUrl: normalizeHubUrl(hubUrl), actorId, sid })) + .digest('hex') + return `hub-session-${digest}` +} + +/** + * Nested subscriptions are valid: a monorepo may have a broad default while + * one package publishes to a different Hub Project. Always choose the deepest + * matching root so file order cannot silently decide the remote Project. + */ +export function mostSpecificMatchingSubscription( + sessionCwd: string, + subscriptions: readonly Subscription[], + deps: SubscriptionMatchDeps = {}, +): Subscription | undefined { + let selected: Subscription | undefined + let selectedDepth = -1 + let selectedLength = -1 + for (const subscription of subscriptions) { + if (!sessionMatchesSubscription(sessionCwd, subscription.path, deps)) continue + const depth = subscriptionPathDepth(subscription.path) + if ( + selected === undefined || + depth > selectedDepth || + (depth === selectedDepth && subscription.path.length > selectedLength) + ) { + selected = subscription + selectedDepth = depth + selectedLength = subscription.path.length + } + } + return selected +} + +function subscriptionPathDepth(path: string): number { + return path.split(/[\\/]+/).filter(Boolean).length +} + +function subscriptionProjectBindingProblem( + subscription: Subscription, + currentHubUrl: string, + response: HubProjectsResponse, +): string | null { + const binding = subscription.project + if (!binding) return 'this legacy subscription has no Hub Project binding.' + if (normalizeHubUrl(binding.hubUrl) !== normalizeHubUrl(currentHubUrl)) { + return 'its Project binding belongs to a different Hub. Run `spool subscribe` again.' + } + if (binding.actorId !== response.actor.id) { + return 'its Project binding belongs to a different signed-in account. Run `spool subscribe` again.' + } + if ( + binding.remote.owner.kind !== binding.tenant.kind || + binding.remote.owner.id !== binding.tenant.id + ) { + return 'its saved Project and tenant no longer agree. Run `spool subscribe` again.' + } + if (subscription.visibility === 'team') { + if ( + binding.tenant.kind !== 'team' || + !subscription.teamId || + binding.tenant.id !== subscription.teamId + ) { + return 'its Team disclosure and Project tenant no longer agree. Run `spool subscribe` again.' + } + } else if (binding.tenant.kind !== 'user' || binding.tenant.id !== response.actor.id) { + return 'its personal disclosure is bound to another tenant. Run `spool subscribe` again.' + } + const live = response.projects.find( + (project) => + project.id === binding.remote.id && + project.owner.kind === binding.tenant.kind && + project.owner.id === binding.tenant.id, + ) + if (!live) { + return 'its Project no longer exists or is not writable. Run `spool subscribe` again.' + } + return null +} + +async function existingSession(client: HubClient, sid: string): Promise { try { - return (await client.getSession(sid)).summaryMd + return await client.getSession(sid) } catch (cause) { if (cause instanceof HubHttpError && (cause.status === 404 || cause.status === 410)) return null throw cause @@ -290,15 +511,16 @@ function readState(options: HubCredentialOptions): AutoPublishState { if ( typeof parsed === 'object' && parsed !== null && + (parsed as { version?: unknown }).version === 2 && typeof (parsed as AutoPublishState).sessions === 'object' && (parsed as AutoPublishState).sessions !== null ) { - return { version: 1, sessions: (parsed as AutoPublishState).sessions } + return { version: 2, sessions: (parsed as AutoPublishState).sessions } } } catch { // Missing or corrupt state only costs a re-publish pass; never fail sync. } - return { version: 1, sessions: {} } + return { version: 2, sessions: {} } } function writeState(state: AutoPublishState, options: HubCredentialOptions): void { diff --git a/apps/cli/src/hub/client.test.ts b/apps/cli/src/hub/client.test.ts index 60057fb1..5b5531a7 100644 --- a/apps/cli/src/hub/client.test.ts +++ b/apps/cli/src/hub/client.test.ts @@ -60,6 +60,51 @@ describe('HubClient', () => { }) }) + it('authenticates Team Session metadata reads and preserves its Project context', async () => { + const fetchMock = vi.fn(async () => + Response.json({ + sid: SID, + root: 'root-1', + count: 1, + sig: null, + cardJson: null, + summaryMd: '# Team summary', + lineageJson: null, + viewOid: 'view-oid', + createdAt: 1, + updatedAt: 1, + visibility: 'public', + team: { id: 'team_1', name: 'Paperboy' }, + project: { + id: 'project_team_1', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'team', id: 'team_1', handle: 'paperboy', name: 'Paperboy' }, + can_manage: true, + }, + author: { handle: 'evan', displayName: 'Evan', avatarUrl: null }, + }), + ) + const client = new HubClient({ + hubUrl: 'https://hub.example', + token: 'hub-token', + fetch: fetchMock as typeof fetch, + }) + + await expect(client.getSession(SID)).resolves.toMatchObject({ + team: { id: 'team_1' }, + project: { + id: 'project_team_1', + owner: { kind: 'team', id: 'team_1' }, + }, + }) + expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get('authorization')).toBe( + 'Bearer hub-token', + ) + }) + it('uploads object batches as NDJSON', async () => { const fetchMock = vi.fn(async () => Response.json({ stored: 2 })) const client = new HubClient({ @@ -144,6 +189,89 @@ describe('HubClient', () => { expect(new Headers(init?.headers).get('authorization')).toBe('Bearer hub-token') }) + it('lists and creates Projects through the Hub contract', async () => { + const project = { + id: 'project_spool0001', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user' as const, id: 'user_1', handle: 'evan', name: 'Evan' }, + can_manage: true, + } + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + const path = new URL(String(input)).pathname + if (path === '/api/hub/v1/projects' && init?.method === 'GET') { + return Response.json({ actor: { id: 'user_1' }, projects: [project] }) + } + return Response.json({ project }) + }) + const client = new HubClient({ + hubUrl: 'https://hub.example', + token: 'hub-token', + fetch: fetchMock as typeof fetch, + }) + + await expect(client.listProjects()).resolves.toEqual({ + actor: { id: 'user_1' }, + projects: [project], + }) + await expect( + client.createProject( + { name: 'Spool', owner: { kind: 'user', id: 'user_1' } }, + 'spool-project-key', + ), + ).resolves.toEqual(project) + + const [, init] = fetchMock.mock.calls[1]! + expect(new Headers(init?.headers).get('idempotency-key')).toBe('spool-project-key') + expect(JSON.parse(String(init?.body))).toMatchObject({ + name: 'Spool', + owner: { kind: 'user', id: 'user_1' }, + idempotency_key: 'spool-project-key', + }) + }) + + it('follows every bounded Hub Project page before resolving Project identity', async () => { + const project = (id: string) => ({ + id, + slug: id, + name: id, + description: null, + github_url: null, + owner: { kind: 'user' as const, id: 'user_1', handle: 'evan', name: 'Evan' }, + can_manage: true, + }) + const fetchMock = vi.fn(async (input: unknown) => { + const url = new URL(String(input)) + expect(url.searchParams.get('limit')).toBe('100') + if (url.searchParams.get('cursor') === null) { + return Response.json({ + actor: { id: 'user_1' }, + projects: [project('first')], + next_cursor: 'page-two', + }) + } + expect(url.searchParams.get('cursor')).toBe('page-two') + return Response.json({ + actor: { id: 'user_1' }, + projects: [project('second')], + next_cursor: null, + }) + }) + const client = new HubClient({ + hubUrl: 'https://hub.example', + token: 'hub-token', + fetch: fetchMock as typeof fetch, + }) + + await expect(client.listProjects()).resolves.toMatchObject({ + actor: { id: 'user_1' }, + projects: [{ id: 'first' }, { id: 'second' }], + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it.each([ [401, { message: 'invalid token' }, 'invalid token'], [404, { error: 'NOT_FOUND', detail: 'session missing' }, 'session missing'], diff --git a/apps/cli/src/hub/client.ts b/apps/cli/src/hub/client.ts index 7e061e79..3166c0a4 100644 --- a/apps/cli/src/hub/client.ts +++ b/apps/cli/src/hub/client.ts @@ -23,6 +23,10 @@ export interface HubSessionWriteRequest { teamId?: string /** Optional optimistic tenant precondition; null means personal/new. */ expectedTeamId?: string | null + /** Required Project association for new hosted Sessions. */ + projectId?: string + /** Optimistic Project precondition; null means the Session is not hosted yet. */ + expectedProjectId?: string | null } export interface HubPushResponse { @@ -54,6 +58,40 @@ export interface HubAuthor { avatarUrl: string | null } +export interface HubProjectOwner { + kind: 'user' | 'team' + id: string + handle: string | null + name: string | null +} + +export interface HubProject { + id: string + slug: string + name: string + description: string | null + github_url: string | null + owner: HubProjectOwner + can_manage: boolean +} + +export interface HubProjectsResponse { + actor: { id: string } + projects: HubProject[] +} + +const HUB_PROJECT_LIST_PAGE_SIZE = 100 +const MAX_HUB_PROJECT_LIST_TOTAL = 10_000 + +export interface HubCreateProjectInput { + name: string + slug?: string + description?: string + github_url?: string + owner: { kind: 'user' | 'team'; id: string } + idempotency_key?: string +} + export interface HubSessionMeta { sid: string root: string @@ -69,6 +107,7 @@ export interface HubSessionMeta { visibility?: 'public' | 'link-only' | 'team' /** Durable workspace owner, independent of the current disclosure level. */ team?: { id: string; name: string } | null + project?: HubProject | null author: HubAuthor } @@ -93,6 +132,8 @@ export interface HubCliAuthPollResponse { export interface HubTeam { id: string name: string + /** Stable namespace handle; optional only while older Hubs roll forward. */ + handle?: string | null role: 'owner' | 'admin' | 'member' permissions: string[] member_count: number @@ -105,6 +146,8 @@ export interface HubManagedSession { visibility: 'public' | 'link-only' | 'team' team_id: string | null team_name: string | null + project_id?: string | null + project?: HubProject | null } export interface HubRecord { @@ -196,6 +239,9 @@ export class HubClient { return { ...meta, summaryMd: meta.summaryMd ?? noteMd ?? null, + ...(meta.project === undefined + ? {} + : { project: meta.project === null ? null : normalizeHubProject(meta.project, false) }), } } @@ -204,18 +250,85 @@ export class HubClient { return (await this.getJson<{ teams: HubTeam[] }>('/api/hub/v1/teams')).teams } + /** Projects the authenticated actor may publish into. */ + async listProjects(): Promise { + type ProjectPage = Omit & { + projects?: HubProject[] + teams?: Array<{ projects?: HubProject[] }> + next_cursor?: string | null + } + let cursor: string | null = null + let actorId: string | null = null + const projects: HubProject[] = [] + const seenCursors = new Set() + do { + const query = new URLSearchParams({ limit: String(HUB_PROJECT_LIST_PAGE_SIZE) }) + if (cursor !== null) query.set('cursor', cursor) + const response = await this.getJson(`/api/hub/v1/projects?${query.toString()}`) + if (actorId !== null && actorId !== response.actor.id) { + throw new Error('Hub Project pagination changed actor identity') + } + actorId = response.actor.id + // Early Project-enabled Hubs grouped Team Projects separately. Flatten + // that read shape for upgrade compatibility; the canonical contract is + // one writable `projects` array. + projects.push( + ...[ + ...(response.projects ?? []), + ...(response.teams ?? []).flatMap((entry) => entry.projects ?? []), + ].map((project) => normalizeHubProject(project, true)), + ) + if (projects.length > MAX_HUB_PROJECT_LIST_TOTAL) { + throw new Error( + `Hub returned more than ${MAX_HUB_PROJECT_LIST_TOTAL} Projects; narrow Team membership before retrying`, + ) + } + const next = response.next_cursor ?? null + if (next !== null) { + if (next.length === 0 || seenCursors.has(next)) { + throw new Error('Hub Project pagination returned an invalid or repeated cursor') + } + seenCursors.add(next) + } + cursor = next + } while (cursor !== null) + if (actorId === null) throw new Error('Hub Project list did not identify the actor') + return { actor: { id: actorId }, projects } + } + + /** Create a personal or Team Project. */ + async createProject(input: HubCreateProjectInput, idempotencyKey?: string): Promise { + const response = await this.postJson<{ project: HubProject }>( + '/api/hub/v1/projects', + { + ...input, + ...(idempotencyKey === undefined ? {} : { idempotency_key: idempotencyKey }), + }, + idempotencyKey === undefined ? undefined : { 'Idempotency-Key': idempotencyKey }, + ) + return normalizeHubProject(response.project, true) + } + /** Change a published Session's disclosure without re-pushing records. */ async updateSessionVisibility( sid: string, visibility: 'public' | 'link-only' | 'team', - teamId?: string, + options: { + teamId?: string + projectId?: string + expectedProjectId?: string | null + } = {}, ): Promise { const response = await this.request(`/api/me/sessions/${encodeURIComponent(sid)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ visibility, - ...(teamId === undefined ? {} : { team_id: teamId }), + ...(options.teamId === undefined ? {} : { team_id: options.teamId }), + ...(options.projectId === undefined ? {} : { project_id: options.projectId }), + ...(options.expectedProjectId === undefined + ? {} + : { expected_project_id: options.expectedProjectId }), }), }) return (await parseJsonResponse<{ session: HubManagedSession }>(response)).session @@ -240,10 +353,16 @@ export class HubClient { return parseJsonResponse(response) } - async postJson(path: string, body: unknown): Promise { + async postJson( + path: string, + body: unknown, + extraHeaders?: Record, + ): Promise { + const headers = new Headers(extraHeaders) + headers.set('Content-Type', 'application/json') const response = await this.request(path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }) return parseJsonResponse(response) @@ -399,3 +518,10 @@ function normalizeHubUrl(value: string): string { function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } + +function normalizeHubProject(project: HubProject, writableFallback: boolean): HubProject { + return { + ...project, + can_manage: typeof project.can_manage === 'boolean' ? project.can_manage : writableFallback, + } +} diff --git a/apps/cli/src/hub/credentials.ts b/apps/cli/src/hub/credentials.ts index 11104ba4..62262a08 100644 --- a/apps/cli/src/hub/credentials.ts +++ b/apps/cli/src/hub/credentials.ts @@ -117,7 +117,7 @@ function stripTrailingSlashes(value: string): string { return value.slice(0, end) } -function normalizeHubUrl(value: string): string { +export function normalizeHubUrl(value: string): string { const normalized = stripTrailingSlashes(requireNonEmpty(value, 'Hub URL')) try { const url = new URL(normalized) diff --git a/apps/cli/src/hub/materialize.ts b/apps/cli/src/hub/materialize.ts index 107d2800..243bc94d 100644 --- a/apps/cli/src/hub/materialize.ts +++ b/apps/cli/src/hub/materialize.ts @@ -1,4 +1,4 @@ -import type { ResumableSessionProvider } from '@spool-lab/session-kit' +import { restoreSessionRecord, type ResumableSessionProvider } from '@spool-lab/session-kit' import { buildBirthText, type BirthPayload } from './birth.js' @@ -48,14 +48,15 @@ export function materializeClaudeSession(opts: MaterializeOptions): Materialized let lastCwd: string | null = null for (const record of sorted) { - const parsed = parseRecordObject( - restorePlaceholders(record.data, opts.workspaceRoot, opts.homeDir), - record.i, - ) - if ('sessionId' in parsed) parsed['sessionId'] = opts.sessionId + const restored = restorePlaceholders(record.data, opts.workspaceRoot, opts.homeDir) + const parsed = parseRecordObject(restored, record.i) + const line = + 'sessionId' in parsed + ? replaceJsonStringAtPath(restored, ['sessionId'], opts.sessionId, record.i) + : restored if (typeof parsed['uuid'] === 'string') lastUuid = parsed['uuid'] if (typeof parsed['cwd'] === 'string') lastCwd = parsed['cwd'] - lines.push(JSON.stringify(parsed)) + lines.push(line) } const now = (opts.now ?? new Date()).toISOString() @@ -86,10 +87,9 @@ export function materializeCodexSession(opts: MaterializeOptions): MaterializedS const lines: string[] = [] for (const record of sorted) { - const parsed = parseRecordObject( - restorePlaceholders(record.data, opts.workspaceRoot, opts.homeDir), - record.i, - ) + const restored = restorePlaceholders(record.data, opts.workspaceRoot, opts.homeDir) + const parsed = parseRecordObject(restored, record.i) + let line = restored // The rollout's identity lives in session_meta.payload — `id` on older // CLIs, plus a `session_id` alias on ≥0.144 — and `codex resume` // additionally matches the uuid in the file name. Rewrite every copy @@ -98,11 +98,13 @@ export function materializeCodexSession(opts: MaterializeOptions): MaterializedS const payload = parsed['payload'] if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) { const meta = payload as Record - meta['id'] = opts.sessionId - if ('session_id' in meta) meta['session_id'] = opts.sessionId + line = replaceJsonStringAtPath(line, ['payload', 'id'], opts.sessionId, record.i) + if ('session_id' in meta) { + line = replaceJsonStringAtPath(line, ['payload', 'session_id'], opts.sessionId, record.i) + } } } - lines.push(JSON.stringify(parsed)) + lines.push(line) } const iso = (opts.now ?? new Date()).toISOString() @@ -142,15 +144,105 @@ function parseRecordObject(restored: string, index: number): Record'}`, + ) + } + const range = ranges[0] as { start: number; end: number } + return `${data.slice(0, range.start)}${JSON.stringify(replacement)}${data.slice(range.end)}` +} + +function findJsonStringRanges( + data: string, + targetPath: readonly string[], +): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = [] + + const skipWhitespace = (from: number): number => { + let index = from + while (index < data.length && /\s/.test(data[index] as string)) index += 1 + return index + } + + const stringEnd = (from: number): number => { + let index = from + 1 + while (index < data.length) { + const char = data[index] as string + if (char === '\\') { + index += 2 + continue + } + if (char === '"') return index + 1 + index += 1 + } + return data.length + } + + const pathMatches = (path: readonly string[]): boolean => + path.length === targetPath.length && + path.every((segment, index) => segment === targetPath[index]) + + const visitValue = (from: number, path: readonly string[]): number => { + let index = skipWhitespace(from) + const char = data[index] + if (char === '"') { + const end = stringEnd(index) + if (pathMatches(path)) ranges.push({ start: index, end }) + return end + } + if (char === '{') { + index = skipWhitespace(index + 1) + if (data[index] === '}') return index + 1 + while (index < data.length) { + const keyStart = index + const keyEnd = stringEnd(keyStart) + const key = JSON.parse(data.slice(keyStart, keyEnd)) as string + index = skipWhitespace(keyEnd) + index = skipWhitespace(index + 1) // colon + index = visitValue(index, [...path, key]) + index = skipWhitespace(index) + if (data[index] === '}') return index + 1 + index = skipWhitespace(index + 1) // comma + } + return index + } + if (char === '[') { + index = skipWhitespace(index + 1) + if (data[index] === ']') return index + 1 + let item = 0 + while (index < data.length) { + index = visitValue(index, [...path, String(item)]) + item += 1 + index = skipWhitespace(index) + if (data[index] === ']') return index + 1 + index = skipWhitespace(index + 1) // comma + } + return index + } + while (index < data.length && !/[\s,\]}]/.test(data[index] as string)) index += 1 + return index + } + + visitValue(0, []) + return ranges } /** Claude Code names project dirs by the cwd with every non-alphanumeric character mapped to '-'. */ diff --git a/apps/cli/src/hub/project-bindings.test.ts b/apps/cli/src/hub/project-bindings.test.ts new file mode 100644 index 00000000..78d511da --- /dev/null +++ b/apps/cli/src/hub/project-bindings.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vite-plus/test' + +import type { HubProject } from './client.js' +import { + findProjectBinding, + loadProjectBindings, + projectBindingsPath, + upsertProjectBinding, +} from './project-bindings.js' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +const project: HubProject = { + id: 'project_spool0001', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user', id: 'user_1', handle: 'evan', name: 'Evan' }, + can_manage: true, +} + +describe('Project bindings', () => { + it('stores a 0600 binding scoped by Hub, actor, tenant, and local identity', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'spool-project-bindings-')) + dirs.push(homeDir) + const localIdentity = { + kind: 'git_remote' as const, + key: 'github.com/paperboytm/spool', + displayName: 'spool', + } + upsertProjectBinding( + { + hubUrl: 'https://hub.example/', + actorId: 'user_1', + tenant: { kind: 'user', id: 'user_1' }, + localIdentity, + project, + }, + { homeDir }, + ) + + const path = projectBindingsPath({ homeDir }) + expect(statSync(path).mode & 0o777).toBe(0o600) + const bindings = loadProjectBindings({ homeDir }) + expect( + findProjectBinding(bindings, { + hubUrl: 'https://hub.example', + actorId: 'user_1', + tenant: { kind: 'user', id: 'user_1' }, + localIdentity, + })?.project.id, + ).toBe(project.id) + expect( + findProjectBinding(bindings, { + hubUrl: 'https://hub.example', + actorId: 'another-user', + tenant: { kind: 'user', id: 'another-user' }, + localIdentity, + }), + ).toBeUndefined() + }) +}) diff --git a/apps/cli/src/hub/project-bindings.ts b/apps/cli/src/hub/project-bindings.ts new file mode 100644 index 00000000..868816d7 --- /dev/null +++ b/apps/cli/src/hub/project-bindings.ts @@ -0,0 +1,222 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import type { ProjectIdentity, ProjectIdentityKind } from '@spool-lab/core' + +import type { HubProject } from './client.js' +import { normalizeHubUrl, type HubCredentialOptions } from './credentials.js' + +export interface ProjectBindingTenant { + kind: 'user' | 'team' + id: string +} + +export interface ProjectBinding { + hubUrl: string + actorId: string + tenant: ProjectBindingTenant + localIdentity: { + kind: ProjectIdentityKind + key: string + displayName: string + } + project: HubProject + updatedAt: string +} + +interface ProjectBindingsFile { + version: 1 + bindings: ProjectBinding[] +} + +export function projectBindingsPath(options: HubCredentialOptions = {}): string { + const env = options.env ?? process.env + const home = options.homeDir ?? nonEmpty(env['HOME']) ?? homedir() + return join(home, '.spool', 'project-bindings.json') +} + +export function loadProjectBindings(options: HubCredentialOptions = {}): ProjectBinding[] { + const path = projectBindingsPath(options) + let raw: string + try { + raw = readFileSync(path, 'utf8') + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return [] + throw error + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (cause) { + throw new Error( + `Invalid Project bindings at ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if (!isRecord(parsed) || parsed['version'] !== 1 || !Array.isArray(parsed['bindings'])) { + throw new Error(`Invalid Project bindings at ${path}: expected version 1 bindings`) + } + return parsed['bindings'].map((value, index) => parseBinding(value, index, path)) +} + +export function saveProjectBindings( + bindings: readonly ProjectBinding[], + options: HubCredentialOptions = {}, +): string { + const path = projectBindingsPath(options) + const stored: ProjectBindingsFile = { version: 1, bindings: [...bindings] } + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeFileSync(path, `${JSON.stringify(stored, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + chmodSync(path, 0o600) + return path +} + +export function findProjectBinding( + bindings: readonly ProjectBinding[], + input: { + hubUrl: string + actorId: string + tenant: ProjectBindingTenant + localIdentity: Pick + }, +): ProjectBinding | undefined { + const hubUrl = normalizeHubUrl(input.hubUrl) + return bindings.find( + (binding) => + binding.hubUrl === hubUrl && + binding.actorId === input.actorId && + binding.tenant.kind === input.tenant.kind && + binding.tenant.id === input.tenant.id && + binding.localIdentity.kind === input.localIdentity.kind && + binding.localIdentity.key === input.localIdentity.key, + ) +} + +export function upsertProjectBinding( + input: Omit & { + hubUrl: string + updatedAt?: string + }, + options: HubCredentialOptions = {}, +): ProjectBinding { + const binding: ProjectBinding = { + ...input, + hubUrl: normalizeHubUrl(input.hubUrl), + updatedAt: input.updatedAt ?? new Date().toISOString(), + } + const existing = loadProjectBindings(options) + const remaining = existing.filter( + (entry) => + !( + entry.hubUrl === binding.hubUrl && + entry.actorId === binding.actorId && + entry.tenant.kind === binding.tenant.kind && + entry.tenant.id === binding.tenant.id && + entry.localIdentity.kind === binding.localIdentity.kind && + entry.localIdentity.key === binding.localIdentity.key + ), + ) + saveProjectBindings([...remaining, binding], options) + return binding +} + +function parseBinding(value: unknown, index: number, path: string): ProjectBinding { + if ( + !isRecord(value) || + typeof value['hubUrl'] !== 'string' || + typeof value['actorId'] !== 'string' || + !isTenant(value['tenant']) || + !isLocalIdentity(value['localIdentity']) || + !isHubProject(value['project']) || + typeof value['updatedAt'] !== 'string' + ) { + throw new Error(`Invalid Project bindings at ${path}: malformed entry ${index}`) + } + return { + hubUrl: normalizeHubUrl(value['hubUrl']), + actorId: value['actorId'], + tenant: value['tenant'], + localIdentity: value['localIdentity'], + project: value['project'], + updatedAt: value['updatedAt'], + } +} + +function isTenant(value: unknown): value is ProjectBindingTenant { + return ( + isRecord(value) && + (value['kind'] === 'user' || value['kind'] === 'team') && + typeof value['id'] === 'string' && + value['id'] !== '' + ) +} + +function isLocalIdentity(value: unknown): value is ProjectBinding['localIdentity'] { + return ( + isRecord(value) && + isProjectIdentityKind(value['kind']) && + typeof value['key'] === 'string' && + value['key'] !== '' && + typeof value['displayName'] === 'string' && + value['displayName'] !== '' + ) +} + +function isHubProject(value: unknown): value is HubProject { + if ( + !isRecord(value) || + typeof value['id'] !== 'string' || + typeof value['slug'] !== 'string' || + typeof value['name'] !== 'string' || + !isHubProjectOwner(value['owner']) || + typeof value['can_manage'] !== 'boolean' + ) { + return false + } + return ( + (value['description'] === null || typeof value['description'] === 'string') && + (value['github_url'] === null || typeof value['github_url'] === 'string') + ) +} + +function isHubProjectOwner(value: unknown): value is HubProject['owner'] { + return ( + isRecord(value) && + (value['kind'] === 'user' || value['kind'] === 'team') && + typeof value['id'] === 'string' && + value['id'] !== '' && + (value['handle'] === null || typeof value['handle'] === 'string') && + (value['name'] === null || typeof value['name'] === 'string') + ) +} + +const PROJECT_IDENTITY_KINDS = new Set([ + 'git_remote', + 'git_common_dir', + 'manifest_path', + 'synthetic', + 'path', + 'loose', + 'spool_internal', +]) + +function isProjectIdentityKind(value: unknown): value is ProjectIdentityKind { + return typeof value === 'string' && PROJECT_IDENTITY_KINDS.has(value as ProjectIdentityKind) +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNodeError(value: unknown): value is NodeJS.ErrnoException { + return value instanceof Error +} diff --git a/apps/cli/src/hub/project-resolution.test.ts b/apps/cli/src/hub/project-resolution.test.ts new file mode 100644 index 00000000..3a45d91c --- /dev/null +++ b/apps/cli/src/hub/project-resolution.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it, vi } from 'vite-plus/test' + +import { createTextUi } from '../ui.js' +import type { HubProject } from './client.js' +import { resolveHubProject } from './project-resolution.js' + +const project: HubProject = { + id: 'project_spool0001', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user', id: 'user_1', handle: 'evan', name: 'Evan' }, + can_manage: true, +} + +const localIdentity = { + kind: 'git_remote' as const, + key: 'github.com/paperboytm/spool', + displayName: 'spool', +} + +describe('resolveHubProject', () => { + it('fails closed without a binding or explicit Project in a non-TTY', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'spool-project-resolution-')) + const client = { + listProjects: async () => ({ actor: { id: 'user_1' }, projects: [project] }), + createProject: vi.fn(), + } + try { + await expect( + resolveHubProject({ + client, + ui: createTextUi(), + hubUrl: 'https://hub.test', + localIdentity, + tenant: { kind: 'personal' }, + homeDir, + }), + ).rejects.toThrow(/--yes` never chooses a Project/) + } finally { + rmSync(homeDir, { recursive: true, force: true }) + } + }) + + it('preserves the hosted Project when re-sharing', async () => { + const other = { ...project, id: 'project_other0001', slug: 'other', name: 'Other' } + const client = { + listProjects: async () => ({ + actor: { id: 'user_1' }, + projects: [project, other], + }), + createProject: vi.fn(), + } + await expect( + resolveHubProject({ + client, + ui: createTextUi(), + hubUrl: 'https://hub.test', + localIdentity, + tenant: { kind: 'personal' }, + projectRef: other.id, + existingProject: project, + }), + ).rejects.toThrow(/Re-sharing preserves its remote Project/) + }) +}) diff --git a/apps/cli/src/hub/project-resolution.ts b/apps/cli/src/hub/project-resolution.ts new file mode 100644 index 00000000..a7c02b24 --- /dev/null +++ b/apps/cli/src/hub/project-resolution.ts @@ -0,0 +1,241 @@ +import { createHash } from 'node:crypto' + +import type { ProjectIdentity } from '@spool-lab/core' + +import type { CliUi } from '../ui.js' +import { type HubClient, type HubProject, type HubProjectsResponse } from './client.js' +import type { HubCredentialOptions } from './credentials.js' +import { + findProjectBinding, + loadProjectBindings, + type ProjectBindingTenant, + upsertProjectBinding, +} from './project-bindings.js' + +const CREATE_PROJECT = '__create_project__' + +export interface ResolvedHubProject { + actorId: string + tenant: ProjectBindingTenant + localIdentity: ProjectIdentity + project: HubProject + /** New/explicit selections are persisted only after the enclosing mutation succeeds. */ + shouldPersistBinding: boolean + hubUrl: string +} + +export interface ResolveHubProjectOptions extends HubCredentialOptions { + client: Pick + ui: CliUi + hubUrl: string + localIdentity: ProjectIdentity + tenant: { kind: 'personal' } | { kind: 'team'; id: string } + projectRef?: string + createProjectName?: string + /** A hosted Session's current Project is authoritative on re-share. */ + existingProject?: HubProject | null + listResponse?: HubProjectsResponse +} + +export async function resolveHubProject( + options: ResolveHubProjectOptions, +): Promise { + if (options.projectRef !== undefined && options.createProjectName !== undefined) { + throw new Error('`--project` and `--create-project` cannot be used together.') + } + + const response = options.listResponse ?? (await options.client.listProjects()) + const tenant: ProjectBindingTenant = + options.tenant.kind === 'team' + ? { kind: 'team', id: options.tenant.id } + : { kind: 'user', id: response.actor.id } + const eligible = response.projects.filter( + (project) => project.owner.kind === tenant.kind && project.owner.id === tenant.id, + ) + + if (options.existingProject) { + const explicit = + options.projectRef === undefined + ? undefined + : findProjectByReference(response.projects, options.projectRef) + if (options.projectRef !== undefined && explicit === undefined) { + throw unknownProjectError(options.projectRef, eligible) + } + if ( + options.createProjectName !== undefined || + (explicit && explicit.id !== options.existingProject.id) + ) { + throw new Error( + `Session is already in Project ${projectLabel(options.existingProject)}. ` + + 'Re-sharing preserves its remote Project; move it first with `spool projects move --project `.', + ) + } + return { + actorId: response.actor.id, + tenant, + localIdentity: options.localIdentity, + project: options.existingProject, + shouldPersistBinding: false, + hubUrl: options.hubUrl, + } + } + + if (options.projectRef !== undefined) { + const project = findProjectByReference(eligible, options.projectRef) + if (!project) throw unknownProjectError(options.projectRef, eligible) + return resolved(options, response.actor.id, tenant, project, true) + } + + if (options.createProjectName !== undefined) { + const project = await createProject( + options, + response.actor.id, + tenant, + options.createProjectName, + ) + return resolved(options, response.actor.id, tenant, project, true) + } + + const binding = findProjectBinding(loadProjectBindings(options), { + hubUrl: options.hubUrl, + actorId: response.actor.id, + tenant, + localIdentity: options.localIdentity, + }) + if (binding) { + const live = eligible.find((project) => project.id === binding.project.id) + if (live) return resolved(options, response.actor.id, tenant, live, false) + options.ui.warn( + `The saved Project binding for ${options.localIdentity.displayName} no longer exists or is not writable.`, + ) + } + + if (!options.ui.interactive) { + throw new Error( + `No Hub Project is bound to local Project "${options.localIdentity.displayName}". ` + + 'Pass `--project ` or `--create-project `; `--yes` never chooses a Project.', + ) + } + + const selected = await options.ui.select({ + message: `Which Hub Project should "${options.localIdentity.displayName}" publish to?`, + choices: [ + ...eligible.map((project) => ({ + value: project.id, + label: project.name, + hint: projectLabel(project), + })), + { + value: CREATE_PROJECT, + label: `Create Project “${options.localIdentity.displayName}”`, + hint: tenant.kind === 'team' ? 'owned by this Team' : 'owned by you', + }, + ], + initialValue: eligible[0]?.id ?? CREATE_PROJECT, + }) + if (selected === null) { + options.ui.cancel('No Project selected.') + return null + } + const project = + selected === CREATE_PROJECT + ? await createProject(options, response.actor.id, tenant, options.localIdentity.displayName) + : eligible.find((entry) => entry.id === selected) + if (!project) throw new Error('The selected Hub Project is no longer available.') + return resolved(options, response.actor.id, tenant, project, true) +} + +export function persistResolvedProject( + resolvedProject: ResolvedHubProject, + options: HubCredentialOptions = {}, +): void { + if (!resolvedProject.shouldPersistBinding) return + upsertProjectBinding( + { + hubUrl: resolvedProject.hubUrl, + actorId: resolvedProject.actorId, + tenant: resolvedProject.tenant, + localIdentity: { + kind: resolvedProject.localIdentity.kind, + key: resolvedProject.localIdentity.key, + displayName: resolvedProject.localIdentity.displayName, + }, + project: resolvedProject.project, + }, + options, + ) +} + +export function findProjectByReference( + projects: readonly HubProject[], + reference: string, +): HubProject | undefined { + const wanted = reference.trim() + return projects.find((project) => { + const owner = project.owner.handle ?? project.owner.id + return project.id === wanted || `${owner}/${project.slug}` === wanted + }) +} + +export function projectLabel(project: HubProject): string { + return `${project.owner.handle ?? project.owner.id}/${project.slug}` +} + +function resolved( + options: ResolveHubProjectOptions, + actorId: string, + tenant: ProjectBindingTenant, + project: HubProject, + shouldPersistBinding: boolean, +): ResolvedHubProject { + return { + actorId, + tenant, + localIdentity: options.localIdentity, + project, + shouldPersistBinding, + hubUrl: options.hubUrl, + } +} + +async function createProject( + options: ResolveHubProjectOptions, + actorId: string, + tenant: ProjectBindingTenant, + name: string, +): Promise { + const trimmed = name.trim() + if (!trimmed) throw new Error('`--create-project` requires a non-empty Project name.') + const key = createIdempotencyKey(options.hubUrl, actorId, tenant, options.localIdentity, trimmed) + return options.client.createProject({ name: trimmed, owner: tenant }, key) +} + +function createIdempotencyKey( + hubUrl: string, + actorId: string, + tenant: ProjectBindingTenant, + identity: ProjectIdentity, + name: string, +): string { + const digest = createHash('sha256') + .update( + JSON.stringify({ + hubUrl, + actorId, + tenant, + identity: { kind: identity.kind, key: identity.key }, + name, + }), + ) + .digest('hex') + return `spool-project-${digest}` +} + +function unknownProjectError(reference: string, projects: readonly HubProject[]): Error { + const choices = projects.map(projectLabel) + return new Error( + choices.length === 0 + ? `No writable Hub Project matches "${reference}" in the selected tenant.` + : `No Hub Project matches "${reference}". Available Projects: ${choices.join(', ')}`, + ) +} diff --git a/apps/cli/src/hub/publish.ts b/apps/cli/src/hub/publish.ts index a56679ee..126fa13a 100644 --- a/apps/cli/src/hub/publish.ts +++ b/apps/cli/src/hub/publish.ts @@ -16,6 +16,10 @@ interface PublishPreparedShareBaseOptions { onUploadProgress?: (uploaded: number, total: number) => void /** Fail if durable Team ownership changed after the caller reviewed it. */ expectedTeamId?: string | null + /** Every hosted Session belongs to one explicit Hub Project. */ + projectId: string + /** Fail if an existing Session moved Projects after the caller read it. */ + expectedProjectId?: string | null } type PublishPreparedShareTarget = @@ -59,6 +63,8 @@ export async function publishPreparedShare( : { visibility: options.visibility } const ownershipExpectation = options.expectedTeamId === undefined ? {} : { expectedTeamId: options.expectedTeamId } + const projectExpectation = + options.expectedProjectId === undefined ? {} : { expectedProjectId: options.expectedProjectId } const head: HubSessionWriteRequest = { root: prepared.root, count: prepared.count, @@ -69,8 +75,10 @@ export async function publishPreparedShare( lineageJson: prepared.lineageJson, viewOid: prepared.viewOid, spoolFileOid: spoolFile?.oid ?? null, + projectId: options.projectId, ...target, ...ownershipExpectation, + ...projectExpectation, } const { missing } = await client.pushSession(prepared.sid, head) diff --git a/apps/cli/src/hub/share-pipeline.test.ts b/apps/cli/src/hub/share-pipeline.test.ts index 2203f8cc..a16e8c50 100644 --- a/apps/cli/src/hub/share-pipeline.test.ts +++ b/apps/cli/src/hub/share-pipeline.test.ts @@ -316,6 +316,71 @@ describe('materialize → share lineage round-trip', () => { expect(JSON.parse(reshared.lineageJson as string)).toEqual(birth) }) + it('rewrites only Claude identity tokens and preserves every other provider byte', () => { + const source = + '{ "type": "assistant", "sessionId": "old", "metrics": { "score": 1.2300 }, "nested": { "sessionId": "quoted" } }' + const materialized = materializeClaudeSession({ + records: [{ i: 0, data: source }], + sessionId: 'new', + workspaceRoot: '/workspace', + homeDir: '/home/resumer', + birth, + cardJson: null, + now: new Date('2026-07-16T12:34:56.789Z'), + }) + + expect(materialized.lines[0]).toBe( + '{ "type": "assistant", "sessionId": "new", "metrics": { "score": 1.2300 }, "nested": { "sessionId": "quoted" } }', + ) + }) + + it('rewrites only Codex session_meta identity tokens and preserves number lexemes', () => { + const source = + '{ "type": "session_meta", "payload": { "id": "old", "session_id": "old", "sample": 1e3, "nested": { "id": "keep" } } }' + const materialized = materializeCodexSession({ + records: [{ i: 0, data: source }], + sessionId: 'new', + workspaceRoot: '/workspace', + homeDir: '/home/resumer', + birth, + cardJson: null, + now: new Date('2026-07-16T12:34:56.789Z'), + }) + + expect(materialized.lines[0]).toBe( + '{ "type": "session_meta", "payload": { "id": "new", "session_id": "new", "sample": 1e3, "nested": { "id": "keep" } } }', + ) + }) + + it('rejects ambiguous duplicate identity keys instead of rewriting the wrong one', () => { + expect(() => + materializeClaudeSession({ + records: [{ i: 0, data: '{"sessionId":"one","sessionId":"two"}' }], + sessionId: 'new', + workspaceRoot: '/workspace', + homeDir: '/home/resumer', + birth, + cardJson: null, + }), + ).toThrow(/exactly one string at sessionId/) + }) + + it.each([ + '{"type":"session_meta","payload":{"cwd":"/workspace"}}', + '{"type":"session_meta","payload":{"id":42}}', + ])('rejects a Codex session_meta without one string payload.id', (data) => { + expect(() => + materializeCodexSession({ + records: [{ i: 0, data }], + sessionId: 'new', + workspaceRoot: '/workspace', + homeDir: '/home/resumer', + birth, + cardJson: null, + }), + ).toThrow(/exactly one string at payload.id/) + }) + it('sharing a materialized session extracts its lineage', async () => { const prepared = await prepareShare({ provider: 'claude', diff --git a/apps/cli/src/hub/share-pipeline.ts b/apps/cli/src/hub/share-pipeline.ts index 21ae4bf6..1e1ce641 100644 --- a/apps/cli/src/hub/share-pipeline.ts +++ b/apps/cli/src/hub/share-pipeline.ts @@ -1,6 +1,8 @@ import { + backupSessionRecord, canonicalizeRecord, deriveView, + sessionRecordData, sequenceRoot, splitRecords, type CanonicalRecord, @@ -10,9 +12,9 @@ import { import { extractBirthPayload } from './birth.js' -// Pure share preparation: provider JSONL in, everything the 3-step hub -// handshake needs out. No store.db in this PR — records are canonicalized -// at share time, straight from the provider file. +// Pure share preparation: provider JSONL in, everything the 3-step Hub +// handshake needs out. Provider records remain direct JSON while local path +// string tokens are made portable at share time, straight from the file. export interface PreparedShare { sid: string @@ -73,7 +75,7 @@ export async function prepareShare(opts: { for (let index = 0; index < shared.length; index += 1) { try { records.push( - await canonicalizeRecord(shared[index] as string, { + await backupSessionRecord(shared[index] as string, { workspaceRoot: opts.workspaceRoot, homeDir: opts.homeDir, }), @@ -91,7 +93,7 @@ export async function prepareShare(opts: { // and any future re-derivation agree on bytes. const canonicalView = await canonicalizeRecord(JSON.stringify(view)) - const birth = extractBirthPayload(records.map((record) => record.data)) + const birth = extractBirthPayload(records.map((record) => sessionRecordData(record))) return { sid: `${opts.provider}_${opts.sessionUuid}`, diff --git a/apps/cli/src/hub/team-resolution.ts b/apps/cli/src/hub/team-resolution.ts new file mode 100644 index 00000000..207e8312 --- /dev/null +++ b/apps/cli/src/hub/team-resolution.ts @@ -0,0 +1,34 @@ +import type { HubTeam } from './client.js' + +export class AmbiguousTeamReferenceError extends Error { + constructor(reference: string, teams: readonly HubTeam[]) { + const choices = teams.map((team) => `${team.handle ? `@${team.handle} · ` : ''}${team.id}`) + super( + `More than one Team is named "${reference}". ` + + `Pass a Team handle or id: ${choices.join(', ')}.`, + ) + this.name = 'AmbiguousTeamReferenceError' + } +} + +/** + * Resolve a stable Team id/handle first. A mutable display name remains a + * convenience only when it identifies exactly one confirmed membership. + */ +export function resolveTeamReference( + teams: readonly HubTeam[], + reference: string, +): HubTeam | undefined { + const wanted = reference.trim() + const handle = wanted.startsWith('@') ? wanted.slice(1) : wanted + const stable = teams.find( + (team) => + team.id === wanted || + (typeof team.handle === 'string' && team.handle.toLowerCase() === handle.toLowerCase()), + ) + if (stable) return stable + + const named = teams.filter((team) => team.name === wanted) + if (named.length > 1) throw new AmbiguousTeamReferenceError(wanted, named) + return named[0] +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d916b061..c2e93c63 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -9,6 +9,7 @@ import { handleDefaultCommand } from './commands/default.js' import { doctorCommand } from './commands/doctor.js' import { loginCommand } from './commands/login.js' import { logoutCommand } from './commands/logout.js' +import { projectsCommand } from './commands/projects.js' import { resumeCommand } from './commands/resume.js' import { sessionsCommand } from './commands/sessions.js' import { shareCommand } from './commands/share.js' @@ -32,12 +33,14 @@ program }) // The everyday set stays small: configure trust once (login, subscribe), -// keep the daemon running, and handle exceptions explicitly (share, -// visibility, withdraw, resume). Browsing lives under `spool sessions`. +// bind local work to Teams and Projects, keep the daemon running, and handle +// exceptions explicitly (share, visibility, withdraw, resume). Browsing lives +// under `spool sessions`. program.addCommand(subscribeCommand) program.addCommand(unsubscribeCommand) program.addCommand(subscriptionsCommand) program.addCommand(teamsCommand) +program.addCommand(projectsCommand) program.addCommand(daemonCommand) program.addCommand(shareCommand) program.addCommand(visibilityCommand) diff --git a/apps/cli/src/subscriptions.test.ts b/apps/cli/src/subscriptions.test.ts index f0ea9de5..0a071dc5 100644 --- a/apps/cli/src/subscriptions.test.ts +++ b/apps/cli/src/subscriptions.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,6 +43,7 @@ describe('subscriptions store', () => { const savedPath = saveSubscriptions([subscription], { homeDir: home }) expect(savedPath).toBe(join(home, '.spool', 'subscriptions.json')) expect(subscriptionsPath({ homeDir: home })).toBe(savedPath) + expect(statSync(savedPath).mode & 0o777).toBe(0o600) expect(loadSubscriptions({ homeDir: home })).toEqual([subscription]) }) @@ -78,6 +79,53 @@ describe('subscriptions store', () => { expect(() => loadSubscriptions({ homeDir: home })).toThrow(/entry 0 has no path/) }) + it('stores Project bindings as v3 and fails closed for legacy v2 bindings', () => { + const home = tempDir('spool-subs-') + const project = { + hubUrl: 'https://hub.test', + actorId: 'user_1', + tenant: { kind: 'user' as const, id: 'user_1' }, + localIdentity: { + kind: 'git_remote' as const, + key: 'github.com/acme/spool', + displayName: 'spool', + }, + remote: { + id: 'project_1', + slug: 'spool', + name: 'Spool', + description: null, + github_url: null, + owner: { kind: 'user' as const, id: 'user_1', handle: 'evan', name: 'Evan' }, + can_manage: true, + }, + } + const entry = { + path: '/repos/spool', + visibility: 'public' as const, + project, + addedAt: '2026-07-26T00:00:00.000Z', + } + const path = saveSubscriptions([entry], { homeDir: home }) + expect(JSON.parse(readFileSync(path, 'utf8')).version).toBe(3) + expect(loadSubscriptions({ homeDir: home })).toEqual([entry]) + + writeFileSync( + path, + JSON.stringify({ + version: 2, + subscriptions: [{ ...entry, project: { actorId: 'user_1', ...project } }], + }), + ) + expect(loadSubscriptions({ homeDir: home })).toEqual([ + { + path: entry.path, + visibility: entry.visibility, + addedAt: entry.addedAt, + }, + ]) + }) + it('canonicalizes relative inputs and rejects files', () => { const dir = tempDir('spool-subs-target-') const child = join(dir, 'project') diff --git a/apps/cli/src/subscriptions.ts b/apps/cli/src/subscriptions.ts index c30c4bcb..c928b548 100644 --- a/apps/cli/src/subscriptions.ts +++ b/apps/cli/src/subscriptions.ts @@ -1,8 +1,12 @@ -import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' -import type { HubCredentialOptions } from './hub/credentials.js' +import type { ProjectIdentityKind } from '@spool-lab/core' + +import type { HubProject } from './hub/client.js' +import { normalizeHubUrl, type HubCredentialOptions } from './hub/credentials.js' +import type { ProjectBindingTenant } from './hub/project-bindings.js' // A subscription marks a project directory whose Sessions — including those // recorded from its git worktrees — publish automatically on every sync. The @@ -20,11 +24,23 @@ export interface Subscription { teamId?: string /** Display-only; the id is authoritative. */ teamName?: string + /** Absent only on legacy v1/v2 entries, which must not auto-publish. */ + project?: { + hubUrl: string + actorId: string + tenant: ProjectBindingTenant + localIdentity: { + kind: ProjectIdentityKind + key: string + displayName: string + } + remote: HubProject + } addedAt: string } interface SubscriptionsFile { - version: 1 + version: 3 subscriptions: Subscription[] } @@ -53,7 +69,18 @@ export function loadSubscriptions(options: HubCredentialOptions = {}): Subscript if (!isRecord(parsed) || !Array.isArray(parsed['subscriptions'])) { throw new Error(`Invalid subscriptions file at ${path}: expected { subscriptions: [] }`) } - return parsed['subscriptions'].map((value, index) => parseSubscription(value, index, path)) + if ( + parsed['version'] !== undefined && + parsed['version'] !== 1 && + parsed['version'] !== 2 && + parsed['version'] !== 3 + ) { + throw new Error(`Invalid subscriptions file at ${path}: unsupported version`) + } + const version = parsed['version'] === 3 ? 3 : parsed['version'] === 2 ? 2 : 1 + return parsed['subscriptions'].map((value, index) => + parseSubscription(value, index, path, version), + ) } export function saveSubscriptions( @@ -61,9 +88,13 @@ export function saveSubscriptions( options: HubCredentialOptions = {}, ): string { const path = subscriptionsPath(options) - const stored: SubscriptionsFile = { version: 1, subscriptions: [...subscriptions] } + const stored: SubscriptionsFile = { version: 3, subscriptions: [...subscriptions] } mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - writeFileSync(path, `${JSON.stringify(stored, null, 2)}\n`, 'utf8') + writeFileSync(path, `${JSON.stringify(stored, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + chmodSync(path, 0o600) return path } @@ -85,7 +116,17 @@ export function addSubscription( const existing = loadSubscriptions(options) const found = existing.find((entry) => entry.path === subscription.path) if (found) { - if (found.visibility === subscription.visibility && found.teamId === subscription.teamId) { + if ( + found.visibility === subscription.visibility && + found.teamId === subscription.teamId && + found.project?.hubUrl === subscription.project?.hubUrl && + found.project?.actorId === subscription.project?.actorId && + found.project?.tenant.kind === subscription.project?.tenant.kind && + found.project?.tenant.id === subscription.project?.tenant.id && + found.project?.remote.id === subscription.project?.remote.id && + found.project?.localIdentity.kind === subscription.project?.localIdentity.kind && + found.project?.localIdentity.key === subscription.project?.localIdentity.key + ) { return { added: false, subscriptions: existing } } const updated = existing.map((entry) => @@ -112,7 +153,12 @@ export function removeSubscription( return { removed: true, subscriptions: remaining } } -function parseSubscription(value: unknown, index: number, path: string): Subscription { +function parseSubscription( + value: unknown, + index: number, + path: string, + version: 1 | 2 | 3, +): Subscription { if (!isRecord(value) || typeof value['path'] !== 'string' || value['path'].trim() === '') { throw new Error(`Invalid subscriptions file at ${path}: entry ${index} has no path`) } @@ -129,15 +175,91 @@ function parseSubscription(value: unknown, index: number, path: string): Subscri const teamName = typeof value['teamName'] === 'string' && value['teamName'] ? value['teamName'] : undefined const addedAt = typeof value['addedAt'] === 'string' ? value['addedAt'] : '' + const project = + version === 3 && value['project'] !== undefined + ? parseSubscriptionProject(value['project'], index, path) + : undefined return { path: value['path'], visibility, ...(visibility === 'team' && teamId !== undefined ? { teamId } : {}), ...(visibility === 'team' && teamName !== undefined ? { teamName } : {}), + ...(project === undefined ? {} : { project }), addedAt, } } +function parseSubscriptionProject( + value: unknown, + index: number, + path: string, +): NonNullable { + if ( + !isRecord(value) || + typeof value['hubUrl'] !== 'string' || + typeof value['actorId'] !== 'string' || + !isProjectBindingTenant(value['tenant']) || + !isRecord(value['localIdentity']) || + !isProjectIdentityKind(value['localIdentity']['kind']) || + typeof value['localIdentity']['key'] !== 'string' || + typeof value['localIdentity']['displayName'] !== 'string' || + !isHubProject(value['remote']) + ) { + throw new Error(`Invalid subscriptions file at ${path}: entry ${index} has no Project binding`) + } + return { + hubUrl: normalizeHubUrl(value['hubUrl']), + actorId: value['actorId'], + tenant: value['tenant'], + localIdentity: { + kind: value['localIdentity']['kind'], + key: value['localIdentity']['key'], + displayName: value['localIdentity']['displayName'], + }, + remote: value['remote'], + } +} + +function isProjectBindingTenant(value: unknown): value is ProjectBindingTenant { + return ( + isRecord(value) && + (value['kind'] === 'user' || value['kind'] === 'team') && + typeof value['id'] === 'string' && + value['id'] !== '' + ) +} + +function isHubProject(value: unknown): value is HubProject { + return ( + isRecord(value) && + typeof value['id'] === 'string' && + typeof value['slug'] === 'string' && + typeof value['name'] === 'string' && + (value['description'] === null || typeof value['description'] === 'string') && + (value['github_url'] === null || typeof value['github_url'] === 'string') && + typeof value['can_manage'] === 'boolean' && + isRecord(value['owner']) && + (value['owner']['kind'] === 'user' || value['owner']['kind'] === 'team') && + typeof value['owner']['id'] === 'string' && + (value['owner']['handle'] === null || typeof value['owner']['handle'] === 'string') && + (value['owner']['name'] === null || typeof value['owner']['name'] === 'string') + ) +} + +const PROJECT_IDENTITY_KINDS = new Set([ + 'git_remote', + 'git_common_dir', + 'manifest_path', + 'synthetic', + 'path', + 'loose', + 'spool_internal', +]) + +function isProjectIdentityKind(value: unknown): value is ProjectIdentityKind { + return typeof value === 'string' && PROJECT_IDENTITY_KINDS.has(value as ProjectIdentityKind) +} + function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim() return trimmed ? trimmed : undefined diff --git a/apps/web/src/components/AccountMenu.tsx b/apps/web/src/components/AccountMenu.tsx index 5bd4a5ad..02962b01 100644 --- a/apps/web/src/components/AccountMenu.tsx +++ b/apps/web/src/components/AccountMenu.tsx @@ -1,5 +1,5 @@ import { Avatar } from '@spool-lab/ui' -import { Library, LogOut, Moon, Sun, UserRound, Users } from 'lucide-react' +import { FolderKanban, Library, LogOut, Moon, Sun, UserRound, Users } from 'lucide-react' import { useEffect, useId, useRef, useState } from 'react' import { signOut } from '../lib/api' @@ -166,6 +166,15 @@ export function AccountMenu({