From 44220112d99a9a71a4b41ec51f4eb9c1f55b18f8 Mon Sep 17 00:00:00 2001 From: Erik Goins Date: Thu, 15 Oct 2026 18:54:22 -0300 Subject: [PATCH 1/8] docs: Phase 1 plan, decision 017, CI status; add better-auth WIP doc for Phase 1 (identity). Decision 017 records the owner's calls: Better Auth for identity only, GitHub link needed for GitHub tasks only, sign-up policy as an instance setting. Phase 0 docs now say CI passed on PR #1. Adds better-auth 1.7.8 to apps/web. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GnDXTQ6MVbAXETYsq6fvqg --- .env.example | 17 +- apps/web/package.json | 1 + docs/INDEX.md | 7 + docs/STATE.md | 20 +- docs/decisions/005-auth-magic-link.md | 2 + docs/decisions/007-roles.md | 2 +- docs/decisions/017-identity.md | 34 +++ docs/features/scaffold.md | 4 +- docs/spec.md | 5 +- docs/wip/identity.md | 97 +++++++++ pnpm-lock.yaml | 299 +++++++++++++++++++++++++- 11 files changed, 470 insertions(+), 18 deletions(-) create mode 100644 docs/decisions/017-identity.md create mode 100644 docs/wip/identity.md diff --git a/.env.example b/.env.example index 36b03a6..f47ef77 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,19 @@ -# Copy to apps/web/.env.local for Node hosts. Cloudflare uses the D1 binding in wrangler.jsonc. +# Node hosts: copy to apps/web/.env.local. Cloudflare: put secrets in apps/web/.dev.vars locally +# and in `wrangler secret put` for deploys. The database on Cloudflare is the D1 binding in +# wrangler.jsonc. # libsql URL of the SQLite database (Node hosts only). Relative paths resolve from apps/web. DATABASE_URL=file:.data/gitwork.sqlite + +# Better Auth. Generate the secret with: openssl rand -base64 32 +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 + +# Email for sign-in codes: console (dev only) | smtp (Node) | cloudflare (Workers) +EMAIL_TRANSPORT=console +EMAIL_FROM=Gitwork +SMTP_URL= + +# GitHub App OAuth credentials, for linking GitHub accounts. Phase 2's setup wizard replaces these. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/apps/web/package.json b/apps/web/package.json index f3fe430..8403a2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@gitwork/core": "workspace:*", "@gitwork/db": "workspace:*", "@opennextjs/cloudflare": "1.20.11", + "better-auth": "1.7.8", "drizzle-orm": "0.45.4", "next": "16.3.8", "react": "19.3.0", diff --git a/docs/INDEX.md b/docs/INDEX.md index f5cf364..e6946b9 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -25,6 +25,13 @@ | 014 | [Notifications without email in v1](decisions/014-notifications.md) | | 015 | [pnpm monorepo, versioned REST API, working name](decisions/015-monorepo-api.md) | | 016 | [libsql on Node, batch-only atomic writes, build-time driver split](decisions/016-db-driver-and-atomic-writes.md) | +| 017 | [Identity: Better Auth for identity only; GitHub gate; sign-up policy](decisions/017-identity.md) | + +## Work in progress + +| Doc | Description | +|---|---| +| [wip/identity.md](wip/identity.md) | Phase 1: sign-in, accounts, workspaces, roles, invites, GitHub link. | ## Features diff --git a/docs/STATE.md b/docs/STATE.md index 91312cc..492f05d 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -4,7 +4,9 @@ Last updated: 2026-10-15 ## In Progress -None. Next step: Phase 1 — Identity. +| Feature | Step | Blockers | WIP doc | +|---|---|---|---| +| Phase 1 — Identity | Permission core | None | [wip/identity.md](wip/identity.md) | Resolved 2026-10-15: draft items hidden from Read Only by default (006); private convention import moved to `CLAUDE.local.md`; third-party skills kept out of git. @@ -14,20 +16,18 @@ import moved to `CLAUDE.local.md`; third-party skills kept out of git. | Item | Date | Doc | |---|---|---| | Product design and decisions 001–015 | 2026-10-15 | [spec.md](spec.md) | -| Phase 0 — Scaffold (CI written, not yet run: no remote) | 2026-10-15 | [features/scaffold.md](features/scaffold.md) | +| Phase 0 — Scaffold | 2026-10-15 | [features/scaffold.md](features/scaffold.md) | ## Backlog -1. Phase 1 — Identity (accounts, workspaces, roles, invites, magic link, GitHub link) -2. Phase 2 — GitHub App (setup wizard, manifest flow, webhooks, cache, reconcile) -3. Phase 3 — Tasks on web (list, kanban, timeline, attribution, hide filter) -4. Phase 4 — Docs on web (editor, round-trip fidelity, images, PR fallback, conflicts) -5. Phase 5 — Activity (audit log, history, inbox) -6. Phase 6 — Mac app v1 -7. Phase 7 — Ship (Cloudflare button, docker-compose, self-host guide) +1. Phase 2 — GitHub App (setup wizard, manifest flow, webhooks, cache, reconcile) +2. Phase 3 — Tasks on web (list, kanban, timeline, attribution, hide filter) +3. Phase 4 — Docs on web (editor, round-trip fidelity, images, PR fallback, conflicts) +4. Phase 5 — Activity (audit log, history, inbox) +5. Phase 6 — Mac app v1 +6. Phase 7 — Ship (Cloudflare button, docker-compose, self-host guide) Chores: -- Create the public GitHub repo and confirm CI passes there. - Upgrade Next 16.3.8 → 16.4.x with OpenNext ≥1.20.12 once it passes the pnpm age policy. Later: Mac timeline (1.1), email notifications, live co-editing, API tokens, billing / Agency plan. diff --git a/docs/decisions/005-auth-magic-link.md b/docs/decisions/005-auth-magic-link.md index bb27881..ecc0fa1 100644 --- a/docs/decisions/005-auth-magic-link.md +++ b/docs/decisions/005-auth-magic-link.md @@ -11,6 +11,8 @@ sign-in. - Sign-in email carries a magic link **and** a 6-digit code. The link opens the Mac app through a custom URL scheme. The code works when the email is open on another device. - Users can link a GitHub account. Owners and Admins must (007). +- **Superseded in part by 017:** creating an Account follows an instance sign-up setting; joining a + workspace stays invite-only. - Invite-only: an Owner or Admin enters an email and a role for one workspace. The invite expires after 7 days. No auto-join by email domain in v1. diff --git a/docs/decisions/007-roles.md b/docs/decisions/007-roles.md index 6759083..d8a25c5 100644 --- a/docs/decisions/007-roles.md +++ b/docs/decisions/007-roles.md @@ -11,7 +11,7 @@ Roles are Owner, Admin, Member, Read Only, set per workspace. No custom roles in matrix lives in `docs/spec.md` → Roles. Key rules: - Owner and Admin need a linked GitHub account, because they install the App and pick org repos - and boards. + and boards. **Narrowed by 017:** only those GitHub tasks need the link. - Member and Read Only do not need GitHub. - Read Only can comment and file requests only when the workspace setting allows it (off by default). diff --git a/docs/decisions/017-identity.md b/docs/decisions/017-identity.md new file mode 100644 index 0000000..21d3339 --- /dev/null +++ b/docs/decisions/017-identity.md @@ -0,0 +1,34 @@ +# 017 — Identity model, GitHub gate, sign-up policy + +Date: 2026-10-15 · Status: Accepted · Narrows 007 · Supersedes part of 005 + +## Context +Phase 1 builds sign-in, accounts, workspaces, roles, and invites. Three questions came up: what +Better Auth should own, which Owner/Admin capabilities need a linked GitHub account, and who may +create a new Account. + +## Decision +- **Better Auth owns identity only:** users, sessions, verification codes, linked GitHub identities. + Gitwork owns accounts, account owners, workspaces, memberships, and invites in its own tables. + Better Auth's `organization` plugin is not used. Better Auth's "account" model is stored as + `auth_identities` so it never collides with Gitwork's Account. +- **GitHub gate (owner decision):** a linked GitHub account is needed only for GitHub tasks — + installing the App and setting a workspace's repos, board, and fields. Owners and Admins can + invite, manage members, and change other settings without GitHub. The gated set is one constant, + `GITHUB_CAPABILITIES`, in `packages/core`. +- **Sign-up policy (owner decision):** an instance setting, `open` or `invite-only`. Default `open` + until the Phase 2 setup wizard exists; then self-host defaults to `invite-only` and the hosted + version stays `open`. A person with a valid pending invite can always sign up. Joining a + workspace always needs an invite. + +## Alternatives considered +- **Better Auth `organization` plugin:** has roles and invites, but no Account level above + workspaces and no GitHub gate. +- **Gate all Owner/Admin power on GitHub:** a new Owner could not invite anyone before linking. +- **Always open / always invite-only sign-up:** the first suits hosting but not private installs; + the second leaves the hosted version no way to onboard customers. + +## Consequences +- Every permission check calls `packages/core`; the matrix tests cover the gate. +- The sign-up check is a hook before user creation, not Better Auth's global `disableSignUp`, which + would lock out invitees. diff --git a/docs/features/scaffold.md b/docs/features/scaffold.md index df44534..c30be34 100644 --- a/docs/features/scaffold.md +++ b/docs/features/scaffold.md @@ -43,8 +43,8 @@ them with `pnpm db:migrate` (runs before `pnpm dev`). Cloudflare applies the sam It calls `scripts/scan-worker-bundle.sh`, which fails on libsql code or a `.node` file in the Worker bundle. Each check was proven to fail: a wrong-target build (24 libsql hits), probe directories with libsql code and with a `.node` file, and a held port. -- CI (`.github/workflows/ci.yml`) runs all of the above. **Not yet run on GitHub: the repo has no - remote.** The full workflow sequence passes in a fresh local clone (2026-10-15). +- CI (`.github/workflows/ci.yml`) runs all of the above. It passed on PR #1 + (FlywheelStudio/gitwork, 2026-10-15). ## Gotchas - **`cacheComponents` hangs every request under workerd** (its `setTimeout` dependency). Keep it off. diff --git a/docs/spec.md b/docs/spec.md index 21f2540..0c45b25 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -47,6 +47,7 @@ Each has a record in `docs/decisions/`. | 014 | Notifications: in-app inbox and native macOS notifications. No email notifications in v1. | | 015 | pnpm monorepo. Versioned REST API `/api/v1` with an OpenAPI spec. | | 016 | libsql on Node; `batch()` is the only atomic write; build-time driver split. | +| 017 | Better Auth for identity only. GitHub link needed for GitHub tasks only. Sign-up policy is an instance setting. | ## Roles @@ -62,7 +63,9 @@ Each has a record in `docs/decisions/`. | Workspace settings (repos, board, fields, hiding, review mode) | ✓ | ✓ | ✗ | ✗ | | Manage Admins, delete workspace | ✓ | ✗ | ✗ | ✗ | | See full audit log | ✓ | ✓ | ✗ | ✗ | -| **Needs a linked GitHub account** | **Yes** | **Yes** | No | No | +| **Needs a linked GitHub account** | For GitHub tasks* | For GitHub tasks* | No | No | + +\* GitHub tasks: install the App; set the workspace's repos, board, and fields (decision 017). Account Owners get Owner in every workspace the account owns. One login works across accounts. diff --git a/docs/wip/identity.md b/docs/wip/identity.md new file mode 100644 index 0000000..c455535 --- /dev/null +++ b/docs/wip/identity.md @@ -0,0 +1,97 @@ +# WIP — Phase 1: Identity + +Branch: `feature/identity` + +## Goal +People can sign in by email code or link, create an Account and its first workspace, invite others +with a role, and link a GitHub account. Every cell of the role matrix (spec → Roles) has a test. + +## Approach + +**Auth (Better Auth, identity only).** Better Auth owns users, sessions, verification records, and +linked OAuth identities. Gitwork owns accounts, workspaces, memberships, and invites (decision 017). +- Sign-in: Better Auth `emailOTP` plugin, `storeOTP: "hashed"`. One email carries the 6-digit code + **and** a link that embeds the same code in the URL fragment (`/sign-in/verify#email=…&code=…`). + The page shows "Continue as jane@…" and POSTs the code on click. **Never verify on GET:** email + link scanners fetch every link and would burn the code. The fragment never reaches server logs. + The Mac app (Phase 6) uses the same link with its URL scheme. +- Rate limits for code requests must be stored in the database, not in memory: Worker isolates do + not share memory. +- Sessions: cookie for the web. Bearer plugin for the Mac app (Phase 6). +- GitHub link: Better Auth GitHub provider with the **GitHub App's** client ID and secret + (user-to-server tokens). Link only: the provider can never create a user. The GitHub email may + differ from the sign-in email. Unique index on `(provider_id, account_id)`. Encrypt tokens at rest + if Better Auth supports it. Phase 1 reads them from env; Phase 2's setup wizard moves them to + `instance_settings`. Token refresh is verified in Phase 2, when tokens are first used. +- Better Auth is created per request (D1 bindings are per request). +- Table names: `users`, `sessions`, `auth_identities` (Better Auth's "account" model, renamed so it + never collides with Gitwork's Account), `verifications`. + +**Domain tables.** `accounts`, `account_owners`, `workspaces`, `workspace_members`, `invites`. + +**Permissions (`packages/core`).** Pure functions over plain data: +- `effectiveRole({ memberRole, isAccountOwner })` — Account Owners are Owner in every workspace of + the account. +- `GITHUB_CAPABILITIES` — the only capabilities that need a linked GitHub account: install the App, + set repos, board, and fields. Everything else Owners and Admins can do without GitHub (decision + 017, owner decision 2026-10-15). +- `can(role, capability, settings)` — the matrix, table-driven. +- `invitableRoles(role)` — Owner: all roles; Admin: Member and Read Only. + +**Atomic writes.** Account + owner + workspace creation and invite acceptance are single +`db.batch()` calls (decision 016). A batch is all-or-nothing on *errors*, not conditional, so every +guard lives inside the statement it protects: +- Invite acceptance inserts the membership with `INSERT … SELECT … FROM invites WHERE token_hash = ? + AND email = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?`. The role comes + from the invite row, never the request. Then `UPDATE invites SET accepted_at` with the same guard. +- "The last Owner cannot be removed" is a guard in the `DELETE … WHERE`. +- Emails are stored and compared in lowercase. + +**Invites.** Token is random (32 bytes); only its SHA-256 hash is stored. Expires after 7 days. +Accepting requires being signed in as the invited email. + +**Email adapter (decision 004).** `EmailSender` interface: +- `console` (dev only): logs the message. `outbox` (tests only, Node only): appends JSON lines to a + file. In production with no adapter configured, sending fails; it never falls back to logging. +- `smtp` (Node only, nodemailer) — kept out of the Worker bundle by the same build-time split. +- `cloudflare` (Workers): `send_email` binding named `EMAIL`. + +**Sign-up policy.** Instance setting `signups`: `open` or `invite-only` (owner decision 2026-10-15). +Default `open` until the Phase 2 setup wizard exists; then self-host defaults to `invite-only`. +A user-create hook allows a new user when sign-ups are open **or** a valid pending invite exists for +that email. `disableSignUp` is not used: it is global and would lock out invitees. + +**CSRF.** Cookie-authenticated POST/PATCH/DELETE on `/api/v1` require a same-origin `Origin` or +`Sec-Fetch-Site`. Bearer-token requests are exempt. + +**API.** `/api/v1/me`, `/api/v1/accounts`, `/api/v1/workspaces`, `/api/v1/workspaces/:id/members`, +`/api/v1/workspaces/:id/invites`, `/api/v1/invites/:token/accept`. The web UI calls the same +service functions in `apps/web/src/server/services/`. + +**UI.** Sign-in (email → code), onboarding (create Account + first workspace), workspace members +page (list, invite, change role, remove), invite acceptance, settings → link GitHub. + +## Build order +1. Permission core (pure, fast tests). +2. Spike: Better Auth sign-in through `auth.api` on libsql **and** D1. Check the Drizzle adapter for + `transaction()` use; check rate-limit storage. +3. Domain schema + services + guards, tested on both drivers. +4. API routes, then UI, then E2E. + +## Tests +- Role matrix: every role × capability, plus the no-GitHub downgrade and the Read Only comment + setting (unit). +- Services on both drivers: account creation is atomic; invite accept is idempotent, rejects + expired/revoked/wrong-email (asserting no membership row exists); role changes respect + `invitableRoles`; last Owner cannot be removed. +- Sign-in on both drivers; rate limit trips; sign-up hook allows invitees under `invite-only`. +- API: cross-site cookie write gets 403. +- API: success, unauthenticated (401), forbidden (403), validation (400) per route. +- E2E: sign in with code from the outbox → create account → invite → second user accepts. + +## Open questions +None. + +## Build log +- 2026-10-15: Owner decisions: GitHub gate covers GitHub tasks only; sign-up policy is an instance + setting. Better Auth 1.7.8 (1.7.9+ under the age policy). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94f9b04..a9d89d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,9 +190,12 @@ importers: '@opennextjs/cloudflare': specifier: 1.20.11 version: 1.20.11(next@16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@10.2.2)(wrangler@4.151.0(@cloudflare/workers-types@5.20261013.1)(@types/node@20.19.43)) + better-auth: + specifier: 1.7.8 + version: 1.7.8(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6))(next@16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.4(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(tsx@4.23.15)(yaml@2.9.1))) drizzle-orm: specifier: 0.45.4 - version: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0) + version: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6) next: specifier: 16.3.8 version: 16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -252,7 +255,7 @@ importers: version: 0.18.0 drizzle-orm: specifier: 0.45.4 - version: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0) + version: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6) devDependencies: '@cloudflare/workers-types': specifier: 5.20261013.1 @@ -587,6 +590,82 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.7.8': + resolution: {integrity: sha512-gH5CIquPBAMws4/gGKmzU0bevTU+ryQNvhvdc0inj4tW2lIKo4xB2onnAtzaEtvujkePpI2WJCeBtgYUfYUlHg==} + peerDependencies: + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.2 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.8': + resolution: {integrity: sha512-TnyY6sNyMmbz+9jO12Bpkk4fSxbItiIUgVHu4XwCIce3dEbpEGmIS7PiFj0LvnbyuKgCA74n0NP7zVMiz96y4A==} + peerDependencies: + '@better-auth/core': ^1.7.8 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.8': + resolution: {integrity: sha512-utkCJCjn6g5kUWK0Odtmt6wyMpqXPjRQrcqUCTuF7MBxxsIEi3HNSmB039WrMsMlI0ao/pK6kBI91CWTly/o5A==} + peerDependencies: + '@better-auth/core': ^1.7.8 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.8': + resolution: {integrity: sha512-GUeTmn0oa2yAk8lVTbtWQRMzK6/LTsHqDbBoYMP0bg7TgXRKX6PexVq5r2r2msHUmZbuOGsu5MUGzs5yo4AYJg==} + peerDependencies: + '@better-auth/core': ^1.7.8 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + + '@better-auth/mongo-adapter@1.7.8': + resolution: {integrity: sha512-MPeGnFS9RUtexZLzg4ImYL4KSDvRpPCfUJ8Z005nEY+BExFDU4tRm9gpIkV3L03xFYssslO7vRyZv7E9oiStIQ==} + peerDependencies: + '@better-auth/core': ^1.7.8 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.8': + resolution: {integrity: sha512-fxaCphY1y33edDrzYeQkn6k2cErWCzjB+vQsUwenL85rMwThzu/geQZa7i7YbZsclFYdzbcOHcHkkWtSXnM+gQ==} + peerDependencies: + '@better-auth/core': ^1.7.8 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.8': + resolution: {integrity: sha512-iRaBMyn/9sYeymcGn2UGeTWAcwFPxPiqAA8nBTY4GOp8Q/Z/3TL59CBMBj2OUaaN10sJQeh5NQ1CehFxbCDZvA==} + peerDependencies: + '@better-auth/core': ^1.7.8 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -1494,6 +1573,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@node-minify/core@9.0.2': resolution: {integrity: sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw==} engines: {node: '>=18.0.0'} @@ -1539,6 +1626,10 @@ packages: rclone.js: optional: true + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.154.0': resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} @@ -1821,6 +1912,9 @@ packages: '@speed-highlight/core@1.2.24': resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -2284,6 +2378,73 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-auth@1.7.8: + resolution: {integrity: sha512-koHXpJG0B4v6wlqE0ULh/7VTtzehbFvHN2x23M5m+titbDjlBszdWzslbU+5poAWVGw2HYsnZEceBAPeFt/QDg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 || ^3.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.2: + resolution: {integrity: sha512-lOmFCTG1KcE60YWxoff2Wx0Fv7v7drP/fQTDYnhJUCdkA7V6QWXdLPUiMpOQCq7Fd0Ts3AkrRwD23xoiootlGA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -2459,6 +2620,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3193,6 +3357,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-base64@3.9.4: resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==} @@ -3237,6 +3404,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3502,6 +3673,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.5.5: + resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3762,6 +3937,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3807,6 +3985,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@3.1.3: + resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5003,6 +5184,59 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.2(zod@4.6.7) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.7 + + '@better-auth/drizzle-adapter@1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6))': + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + optionalDependencies: + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6) + + '@better-auth/kysely-adapter@1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(kysely@0.29.6)': + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + optionalDependencies: + kysely: 0.29.6 + + '@better-auth/memory-adapter@1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)': + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + + '@better-auth/mongo-adapter@1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)': + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + + '@better-auth/prisma-adapter@1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)': + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + + '@better-auth/telemetry@1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)': + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1)': @@ -5616,6 +5850,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.8': optional: true + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + '@node-minify/core@9.0.2': dependencies: '@node-minify/utils': 9.0.1 @@ -5686,6 +5924,8 @@ snapshots: - encoding - supports-color + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.154.0': {} '@pkgjs/parseargs@0.11.0': @@ -5953,6 +6193,8 @@ snapshots: '@speed-highlight/core@1.2.24': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -6385,6 +6627,44 @@ snapshots: baseline-browser-mapping@2.11.28: {} + better-auth@1.7.8(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6))(next@16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.4(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(tsx@4.23.15)(yaml@2.9.1))): + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6)) + '@better-auth/kysely-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/mongo-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/prisma-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/telemetry': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.2(zod@4.6.7) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.7 + optionalDependencies: + drizzle-kit: 0.31.11 + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6) + next: 16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + vitest: 5.0.4(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(tsx@4.23.15)(yaml@2.9.1)) + transitivePeerDependencies: + - '@opentelemetry/api' + + better-call@1.4.2(zod@4.6.7): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.3 + optionalDependencies: + zod: 4.6.7 + blake3-wasm@2.1.5: {} body-parser@2.3.0(supports-color@10.2.2): @@ -6569,6 +6849,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -6588,10 +6870,11 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.15 - drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0): + drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6): optionalDependencies: '@cloudflare/workers-types': 5.20261013.1 '@libsql/client': 0.18.0 + kysely: 0.29.6 dunder-proto@1.0.1: dependencies: @@ -7460,6 +7743,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + js-base64@3.9.4: {} js-tokens@4.0.0: {} @@ -7495,6 +7780,8 @@ snapshots: kleur@4.1.5: {} + kysely@0.29.6: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -7722,6 +8009,8 @@ snapshots: nanoid@3.3.20: {} + nanostores@1.5.5: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -8002,6 +8291,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.14 '@rolldown/binding-win32-x64-msvc': 1.2.14 + rou3@0.9.2: {} + router@2.2.0(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -8070,6 +8361,8 @@ snapshots: server-only@0.0.1: {} + set-cookie-parser@3.1.3: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 From 3ce976d472ebe45c84d389547824be70b621bb33 Mon Sep 17 00:00:00 2001 From: Erik Goins Date: Thu, 15 Oct 2026 18:55:55 -0300 Subject: [PATCH 2/8] feat: role matrix and GitHub gate in packages/core authorize()/can() implement the spec's role matrix, the two Read Only switches, and the GitHub gate from decision 017 (GitHub tasks only). invitableRoles() and canManageMember() bound invites and role changes. 78 tests check every role x capability cell against a hand-written copy of the spec; flipping one cell turns the suite red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GnDXTQ6MVbAXETYsq6fvqg --- packages/core/src/index.ts | 1 + packages/core/src/permissions.test.ts | 141 ++++++++++++++++++++++++++ packages/core/src/permissions.ts | 122 ++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 packages/core/src/permissions.test.ts create mode 100644 packages/core/src/permissions.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f803a96..28931ec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,2 @@ export { APP_NAME, botAttribution } from "./brand"; +export * from "./permissions"; diff --git a/packages/core/src/permissions.test.ts b/packages/core/src/permissions.test.ts new file mode 100644 index 0000000..534da27 --- /dev/null +++ b/packages/core/src/permissions.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + CAPABILITIES, + type Capability, + GITHUB_CAPABILITIES, + ROLES, + type Role, + authorize, + can, + canManageMember, + effectiveRole, + invitableRoles, +} from "./permissions"; + +// The matrix from docs/spec.md → Roles, written out by hand. It must not be derived from the code. +// o = owner, a = admin, m = member, r = read_only. "s" = Read Only only with the setting on. +const SPEC: Record = { + "workspace.view": "oamr", + "items.view_hidden": "oam", + "drafts.view": "oams", + "comments.create": "oams", + "tasks.write": "oam", + "docs.write": "oam", + "docs.delete": "oa", + "members.invite": "oa", + "members.manage": "oa", + "workspace.settings": "oa", + "workspace.github_settings": "oa", + "github.install_app": "oa", + "admins.manage": "o", + "workspace.delete": "o", + "audit.view_full": "oa", +}; +const LETTER: Record = { owner: "o", admin: "a", member: "m", read_only: "r" }; +const SETTING_FOR: Partial> = { + "comments.create": "readOnlyCanComment", + "drafts.view": "showDraftsToReadOnly", +}; +const OFF = { readOnlyCanComment: false, showDraftsToReadOnly: false }; +const ON = { readOnlyCanComment: true, showDraftsToReadOnly: true }; + +describe("role matrix", () => { + it("covers every capability", () => { + expect(Object.keys(SPEC).sort()).toEqual([...CAPABILITIES].sort()); + }); + + for (const capability of CAPABILITIES) { + for (const role of ROLES) { + const expected = SPEC[capability].includes(LETTER[role]); + it(`${role} ${expected ? "can" : "cannot"} ${capability} (GitHub linked, settings off)`, () => { + expect(can({ role, hasGitHub: true }, capability, OFF)).toBe(expected); + }); + } + } + + for (const [capability, setting] of Object.entries(SETTING_FOR) as [Capability, string][]) { + it(`read_only can ${capability} only when ${setting} is on`, () => { + expect(can({ role: "read_only", hasGitHub: false }, capability, OFF)).toBe(false); + expect(can({ role: "read_only", hasGitHub: false }, capability, { ...OFF, [setting]: true })).toBe(true); + }); + } + + it("settings never widen anything else for read_only", () => { + for (const capability of CAPABILITIES) { + if (capability in SETTING_FOR) continue; + expect(can({ role: "read_only", hasGitHub: true }, capability, ON)).toBe(SPEC[capability].includes("r")); + } + }); +}); + +describe("GitHub gate (decision 017)", () => { + it("gates exactly the GitHub tasks", () => { + expect([...GITHUB_CAPABILITIES].sort()).toEqual(["github.install_app", "workspace.github_settings"]); + }); + + for (const role of ["owner", "admin"] as const) { + it(`${role} without GitHub cannot do GitHub tasks, and gets the link reason`, () => { + for (const capability of GITHUB_CAPABILITIES) { + expect(authorize({ role, hasGitHub: false }, capability)).toEqual({ ok: false, reason: "github_link_required" }); + } + }); + + it(`${role} without GitHub keeps every other capability`, () => { + for (const capability of CAPABILITIES) { + if (GITHUB_CAPABILITIES.has(capability)) continue; + expect(can({ role, hasGitHub: false }, capability, OFF)).toBe(SPEC[capability].includes(LETTER[role])); + } + }); + } + + it("reports role, not GitHub, when the role itself is not allowed", () => { + expect(authorize({ role: "member", hasGitHub: false }, "github.install_app")).toEqual({ ok: false, reason: "role" }); + }); +}); + +describe("effectiveRole", () => { + it("makes Account Owners Owner in every workspace", () => { + expect(effectiveRole({ memberRole: null, isAccountOwner: true })).toBe("owner"); + expect(effectiveRole({ memberRole: "read_only", isAccountOwner: true })).toBe("owner"); + }); + + it("uses the membership role otherwise, and null means no access", () => { + expect(effectiveRole({ memberRole: "member", isAccountOwner: false })).toBe("member"); + expect(effectiveRole({ memberRole: null, isAccountOwner: false })).toBeNull(); + }); +}); + +describe("invitableRoles", () => { + it("lets Owners grant any role and Admins up to Member", () => { + expect([...invitableRoles("owner")].sort()).toEqual([...ROLES].sort()); + expect([...invitableRoles("admin")].sort()).toEqual(["member", "read_only"]); + expect(invitableRoles("member")).toEqual([]); + expect(invitableRoles("read_only")).toEqual([]); + }); +}); + +describe("canManageMember", () => { + const owner = { role: "owner" as const, hasGitHub: false }; + const admin = { role: "admin" as const, hasGitHub: false }; + const member = { role: "member" as const, hasGitHub: true }; + + it("lets an Admin move people between Member and Read Only, and remove them", () => { + expect(canManageMember(admin, "member", "read_only")).toBe(true); + expect(canManageMember(admin, "read_only", null)).toBe(true); + }); + + it("stops an Admin from promoting to Admin or touching Admins and Owners", () => { + expect(canManageMember(admin, "member", "admin")).toBe(false); + expect(canManageMember(admin, "admin", "member")).toBe(false); + expect(canManageMember(admin, "owner", null)).toBe(false); + }); + + it("lets an Owner manage anyone", () => { + expect(canManageMember(owner, "admin", "member")).toBe(true); + expect(canManageMember(owner, "owner", null)).toBe(true); + }); + + it("stops Members", () => { + expect(canManageMember(member, "read_only", null)).toBe(false); + }); +}); diff --git a/packages/core/src/permissions.ts b/packages/core/src/permissions.ts new file mode 100644 index 0000000..0d11aa5 --- /dev/null +++ b/packages/core/src/permissions.ts @@ -0,0 +1,122 @@ +/** + * The role matrix (docs/spec.md → Roles; decisions 007, 012, 017). + * Pure functions over plain data. Every server read and write calls these. + */ + +export const ROLES = ["owner", "admin", "member", "read_only"] as const; +export type Role = (typeof ROLES)[number]; + +export const CAPABILITIES = [ + "workspace.view", // tasks and docs, minus hidden and draft items for Read Only + "items.view_hidden", // issues with the hide label, files under the hide folder + "drafts.view", // Projects v2 draft items (no repo, no labels) + "comments.create", // comments and requests + "tasks.write", // create, edit, move + "docs.write", // create, edit, rename, move + "docs.delete", + "members.invite", + "members.manage", // change roles, remove + "workspace.settings", // hiding, review mode, Read Only switches + "workspace.github_settings", // repos, board, fields + "github.install_app", + "admins.manage", + "workspace.delete", + "audit.view_full", +] as const; +export type Capability = (typeof CAPABILITIES)[number]; + +/** The only capabilities that need a linked GitHub account (decision 017). */ +export const GITHUB_CAPABILITIES: ReadonlySet = new Set([ + "workspace.github_settings", + "github.install_app", +]); + +/** Per-workspace switches that widen what Read Only users get. Both default to false. */ +export interface PermissionSettings { + readOnlyCanComment: boolean; + showDraftsToReadOnly: boolean; +} + +export const DEFAULT_PERMISSION_SETTINGS: PermissionSettings = { + readOnlyCanComment: false, + showDraftsToReadOnly: false, +}; + +export interface Actor { + role: Role; + hasGitHub: boolean; +} + +type Grant = boolean | keyof PermissionSettings; + +// Columns: owner, admin, member, read_only. A string grants the capability when that setting is on. +const MATRIX: Record = { + "workspace.view": [true, true, true, true], + "items.view_hidden": [true, true, true, false], + "drafts.view": [true, true, true, "showDraftsToReadOnly"], + "comments.create": [true, true, true, "readOnlyCanComment"], + "tasks.write": [true, true, true, false], + "docs.write": [true, true, true, false], + "docs.delete": [true, true, false, false], + "members.invite": [true, true, false, false], + "members.manage": [true, true, false, false], + "workspace.settings": [true, true, false, false], + "workspace.github_settings": [true, true, false, false], + "github.install_app": [true, true, false, false], + "admins.manage": [true, false, false, false], + "workspace.delete": [true, false, false, false], + "audit.view_full": [true, true, false, false], +}; + +export type Decision = { ok: true } | { ok: false; reason: "role" | "github_link_required" }; + +export function authorize( + actor: Actor, + capability: Capability, + settings: PermissionSettings = DEFAULT_PERMISSION_SETTINGS, +): Decision { + const grant = MATRIX[capability][ROLES.indexOf(actor.role)]; + const allowed = typeof grant === "string" ? settings[grant] : grant === true; + if (!allowed) return { ok: false, reason: "role" }; + if (GITHUB_CAPABILITIES.has(capability) && !actor.hasGitHub) { + return { ok: false, reason: "github_link_required" }; + } + return { ok: true }; +} + +export function can(actor: Actor, capability: Capability, settings?: PermissionSettings): boolean { + return authorize(actor, capability, settings).ok; +} + +/** + * A user's role in one workspace. Account Owners are Owner in every workspace of the account. + * Returns null when the user has no access. + */ +export function effectiveRole(input: { memberRole: Role | null; isAccountOwner: boolean }): Role | null { + if (input.isAccountOwner) return "owner"; + return input.memberRole; +} + +/** Roles an actor may grant by invite or role change. Owner: any role. Admin: up to Member. */ +export function invitableRoles(role: Role): readonly Role[] { + switch (role) { + case "owner": + return ROLES; + case "admin": + return ["member", "read_only"]; + default: + return []; + } +} + +/** + * Whether `actor` may change a member from `currentRole` to `newRole`, or remove them + * (`newRole` = null). Both roles must be within what the actor may grant. + * The "last Owner" rule is a database guard, not checked here. + */ +export function canManageMember(actor: Actor, currentRole: Role, newRole: Role | null): boolean { + if (!can(actor, "members.manage")) return false; + const grantable = invitableRoles(actor.role); + if (!grantable.includes(currentRole)) return false; + return newRole === null || grantable.includes(newRole); +} From cce5aa189716c31f509137ff806f713dcebe3a6c Mon Sep 17 00:00:00 2001 From: Erik Goins Date: Thu, 15 Oct 2026 18:59:40 -0300 Subject: [PATCH 3/8] feat: email-code sign-in on D1 and libsql with Better Auth Identity and domain schema (migration 0001) with CHECKs for roles and lowercase invite emails. New @gitwork/server package: Better Auth factory (email OTP, hashed codes, database rate limits, encrypted OAuth tokens, GitHub link-only), email adapters, and the sign-up rule from decision 017. Sign-in tests run through the HTTP handler on both drivers; weakening rate-limit storage or code hashing turns them red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GnDXTQ6MVbAXETYsq6fvqg --- docs/STATE.md | 2 +- docs/wip/identity.md | 10 + packages/db/migrations/0001_identity.sql | 123 +++ .../db/migrations/meta/0001_snapshot.json | 888 ++++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/package.json | 3 +- packages/db/src/schema.ts | 184 +++- packages/db/src/testing.ts | 81 ++ packages/db/test/contract.ts | 18 + packages/db/test/d1.test.ts | 28 +- packages/db/test/migrations.ts | 20 - packages/db/test/sqlite.test.ts | 9 +- packages/server/package.json | 29 + packages/server/src/auth.ts | 101 ++ packages/server/src/email-node.ts | 23 + packages/server/src/email.ts | 56 ++ packages/server/src/index.ts | 13 + packages/server/src/instance-settings.ts | 17 + packages/server/src/sign-in-email.ts | 28 + packages/server/src/signup.ts | 21 + packages/server/test/auth.test.ts | 104 ++ packages/server/test/helpers.ts | 56 ++ packages/server/tsconfig.json | 8 + pnpm-lock.yaml | 104 ++ vitest.config.ts | 1 + 25 files changed, 1882 insertions(+), 52 deletions(-) create mode 100644 packages/db/migrations/0001_identity.sql create mode 100644 packages/db/migrations/meta/0001_snapshot.json create mode 100644 packages/db/src/testing.ts delete mode 100644 packages/db/test/migrations.ts create mode 100644 packages/server/package.json create mode 100644 packages/server/src/auth.ts create mode 100644 packages/server/src/email-node.ts create mode 100644 packages/server/src/email.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/instance-settings.ts create mode 100644 packages/server/src/sign-in-email.ts create mode 100644 packages/server/src/signup.ts create mode 100644 packages/server/test/auth.test.ts create mode 100644 packages/server/test/helpers.ts create mode 100644 packages/server/tsconfig.json diff --git a/docs/STATE.md b/docs/STATE.md index 492f05d..97c9c7d 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -6,7 +6,7 @@ Last updated: 2026-10-15 | Feature | Step | Blockers | WIP doc | |---|---|---|---| -| Phase 1 — Identity | Permission core | None | [wip/identity.md](wip/identity.md) | +| Phase 1 — Identity | Domain services | None | [wip/identity.md](wip/identity.md) | Resolved 2026-10-15: draft items hidden from Read Only by default (006); private convention import moved to `CLAUDE.local.md`; third-party skills kept out of git. diff --git a/docs/wip/identity.md b/docs/wip/identity.md index c455535..154b015 100644 --- a/docs/wip/identity.md +++ b/docs/wip/identity.md @@ -95,3 +95,13 @@ None. ## Build log - 2026-10-15: Owner decisions: GitHub gate covers GitHub tasks only; sign-up policy is an instance setting. Better Auth 1.7.8 (1.7.9+ under the age policy). +- Permission core done: 78 tests over a hand-written copy of the matrix; one flipped cell turns + the suite red. +- Better Auth's Drizzle adapter calls `db.transaction()` only on MySQL paths; SQLite uses single + `DELETE … RETURNING` statements. Sign-in passes on D1 and libsql through `auth.handler`. +- Rate limits use `storage: "database"` (`rate_limits` table). Code storage is hashed. Switching + either back (memory / plain) fails the suite. +- Schema CHECKs: role enum on `workspace_members` and `invites`, lowercase `invites.email`. Drizzle + text enums are TypeScript-only; SQLite needs the CHECK. +- Test helpers moved to `@gitwork/db/testing` (`allTestDrivers()` → libsql + D1). +- New package `@gitwork/server`: auth factory, email adapters, sign-up rule, services. diff --git a/packages/db/migrations/0001_identity.sql b/packages/db/migrations/0001_identity.sql new file mode 100644 index 0000000..f9549b8 --- /dev/null +++ b/packages/db/migrations/0001_identity.sql @@ -0,0 +1,123 @@ +CREATE TABLE `account_owners` ( + `account_id` text NOT NULL, + `user_id` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + PRIMARY KEY(`account_id`, `user_id`), + FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `account_owners_user_id_idx` ON `account_owners` (`user_id`);--> statement-breakpoint +CREATE TABLE `accounts` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `auth_identities` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `auth_identities_user_id_idx` ON `auth_identities` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `auth_identities_provider_account_uq` ON `auth_identities` (`provider_id`,`account_id`);--> statement-breakpoint +CREATE TABLE `invites` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `email` text NOT NULL, + `role` text NOT NULL, + `token_hash` text NOT NULL, + `invited_by` text, + `expires_at` integer NOT NULL, + `accepted_at` integer, + `accepted_by` text, + `revoked_at` integer, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`accepted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null, + CONSTRAINT "invites_role_ck" CHECK("invites"."role" IN ('owner', 'admin', 'member', 'read_only')), + CONSTRAINT "invites_email_lower_ck" CHECK("invites"."email" = lower("invites"."email")) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `invites_token_hash_unique` ON `invites` (`token_hash`);--> statement-breakpoint +CREATE INDEX `invites_workspace_id_idx` ON `invites` (`workspace_id`);--> statement-breakpoint +CREATE INDEX `invites_email_idx` ON `invites` (`email`);--> statement-breakpoint +CREATE TABLE `rate_limits` ( + `id` text PRIMARY KEY NOT NULL, + `key` text NOT NULL, + `count` integer NOT NULL, + `last_request` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `rate_limits_key_unique` ON `rate_limits` (`key`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint +CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +CREATE TABLE `verifications` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `verifications_identifier_idx` ON `verifications` (`identifier`);--> statement-breakpoint +CREATE TABLE `workspace_members` ( + `workspace_id` text NOT NULL, + `user_id` text NOT NULL, + `role` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + PRIMARY KEY(`workspace_id`, `user_id`), + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "workspace_members_role_ck" CHECK("workspace_members"."role" IN ('owner', 'admin', 'member', 'read_only')) +); +--> statement-breakpoint +CREATE INDEX `workspace_members_user_id_idx` ON `workspace_members` (`user_id`);--> statement-breakpoint +CREATE TABLE `workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `name` text NOT NULL, + `read_only_can_comment` integer DEFAULT false NOT NULL, + `show_drafts_to_read_only` integer DEFAULT false NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `workspaces_account_id_idx` ON `workspaces` (`account_id`); \ No newline at end of file diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..a56300d --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,888 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "af484be6-7c7e-4cab-8495-0673ea65f618", + "prevId": "0318e0de-1e62-4122-8dd1-754e247b0a81", + "tables": { + "account_owners": { + "name": "account_owners", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "account_owners_user_id_idx": { + "name": "account_owners_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_owners_account_id_accounts_id_fk": { + "name": "account_owners_account_id_accounts_id_fk", + "tableFrom": "account_owners", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_owners_user_id_users_id_fk": { + "name": "account_owners_user_id_users_id_fk", + "tableFrom": "account_owners", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_owners_account_id_user_id_pk": { + "columns": [ + "account_id", + "user_id" + ], + "name": "account_owners_account_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_identities": { + "name": "auth_identities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "auth_identities_user_id_idx": { + "name": "auth_identities_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "auth_identities_provider_account_uq": { + "name": "auth_identities_provider_account_uq", + "columns": [ + "provider_id", + "account_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "auth_identities_user_id_users_id_fk": { + "name": "auth_identities_user_id_users_id_fk", + "tableFrom": "auth_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invites": { + "name": "invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "invites_workspace_id_idx": { + "name": "invites_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "invites_email_idx": { + "name": "invites_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invites_workspace_id_workspaces_id_fk": { + "name": "invites_workspace_id_workspaces_id_fk", + "tableFrom": "invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invites_invited_by_users_id_fk": { + "name": "invites_invited_by_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invites_accepted_by_users_id_fk": { + "name": "invites_accepted_by_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "invites_role_ck": { + "name": "invites_role_ck", + "value": "\"invites\".\"role\" IN ('owner', 'admin', 'member', 'read_only')" + }, + "invites_email_lower_ck": { + "name": "invites_email_lower_ck", + "value": "\"invites\".\"email\" = lower(\"invites\".\"email\")" + } + } + }, + "rate_limits": { + "name": "rate_limits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rate_limits_key_unique": { + "name": "rate_limits_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_members": { + "name": "workspace_members", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_workspace_id_user_id_pk": { + "columns": [ + "workspace_id", + "user_id" + ], + "name": "workspace_members_workspace_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "workspace_members_role_ck": { + "name": "workspace_members_role_ck", + "value": "\"workspace_members\".\"role\" IN ('owner', 'admin', 'member', 'read_only')" + } + } + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_only_can_comment": { + "name": "read_only_can_comment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_drafts_to_read_only": { + "name": "show_drafts_to_read_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "workspaces_account_id_idx": { + "name": "workspaces_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_account_id_accounts_id_fk": { + "name": "workspaces_account_id_accounts_id_fk", + "tableFrom": "workspaces", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 80a9d40..693b166 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1792099866164, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1792101428785, + "tag": "0001_identity", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 61b5c43..b03c212 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -8,7 +8,8 @@ ".": "./src/index.ts", "./d1": "./src/d1.ts", "./node": "./src/node.ts", - "./migrate": "./src/migrate.ts" + "./migrate": "./src/migrate.ts", + "./testing": "./src/testing.ts" }, "scripts": { "generate": "drizzle-kit generate", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 016708a..1ba2f9c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,11 +1,187 @@ import { sql } from "drizzle-orm"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { check, index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +const createdAt = () => + integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`); +const updatedAt = () => + integer("updated_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`); /** Instance-wide key/value settings. The setup wizard writes these (decision 001). */ export const instanceSettings = sqliteTable("instance_settings", { key: text("key").primaryKey(), value: text("value").notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }) - .notNull() - .default(sql`(unixepoch() * 1000)`), + updatedAt: updatedAt(), +}); + +// --------------------------------------------------------------------------------------------- +// Identity — owned by Better Auth (decision 017). JS keys must match Better Auth's field names. +// --------------------------------------------------------------------------------------------- + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), + image: text("image"), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const sessions = sqliteTable( + "sessions", + { + id: text("id").primaryKey(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + token: text("token").notNull().unique(), + createdAt: createdAt(), + updatedAt: updatedAt(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (t) => [index("sessions_user_id_idx").on(t.userId)], +); + +/** Better Auth's "account" model: a linked sign-in identity such as GitHub. Not a Gitwork Account. */ +export const authIdentities = sqliteTable( + "auth_identities", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp_ms" }), + refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp_ms" }), + scope: text("scope"), + password: text("password"), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + index("auth_identities_user_id_idx").on(t.userId), + // One GitHub identity can back only one user. + uniqueIndex("auth_identities_provider_account_uq").on(t.providerId, t.accountId), + ], +); + +export const verifications = sqliteTable( + "verifications", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [index("verifications_identifier_idx").on(t.identifier)], +); + +/** Rate-limit counters. In the database because Worker isolates do not share memory. */ +export const rateLimits = sqliteTable("rate_limits", { + id: text("id").primaryKey(), + key: text("key").notNull().unique(), + count: integer("count").notNull(), + lastRequest: integer("last_request").notNull(), +}); + +// --------------------------------------------------------------------------------------------- +// Domain — owned by Gitwork (decisions 006, 007, 017). +// --------------------------------------------------------------------------------------------- + +export const ROLE_VALUES = ["owner", "admin", "member", "read_only"] as const; +const ROLE_CHECK = sql.raw(ROLE_VALUES.map((r) => `'${r}'`).join(", ")); + +/** A person or an agency. Owns workspaces. */ +export const accounts = sqliteTable("accounts", { + id: text("id").primaryKey(), + name: text("name").notNull(), + createdAt: createdAt(), }); + +/** Account Owners are Owner in every workspace of the account. */ +export const accountOwners = sqliteTable( + "account_owners", + { + accountId: text("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: createdAt(), + }, + (t) => [primaryKey({ columns: [t.accountId, t.userId] }), index("account_owners_user_id_idx").on(t.userId)], +); + +/** One project: one Projects v2 board and a list of repos (linked in Phase 3). */ +export const workspaces = sqliteTable( + "workspaces", + { + id: text("id").primaryKey(), + accountId: text("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + name: text("name").notNull(), + readOnlyCanComment: integer("read_only_can_comment", { mode: "boolean" }).notNull().default(false), + showDraftsToReadOnly: integer("show_drafts_to_read_only", { mode: "boolean" }).notNull().default(false), + createdAt: createdAt(), + }, + (t) => [index("workspaces_account_id_idx").on(t.accountId)], +); + +export const workspaceMembers = sqliteTable( + "workspace_members", + { + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + role: text("role", { enum: ROLE_VALUES }).notNull(), + createdAt: createdAt(), + }, + (t) => [ + primaryKey({ columns: [t.workspaceId, t.userId] }), + index("workspace_members_user_id_idx").on(t.userId), + check("workspace_members_role_ck", sql`${t.role} IN (${ROLE_CHECK})`), + ], +); + +/** Pending invites. Only the SHA-256 of the token is stored. Emails are lowercase. */ +export const invites = sqliteTable( + "invites", + { + id: text("id").primaryKey(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role", { enum: ROLE_VALUES }).notNull(), + tokenHash: text("token_hash").notNull().unique(), + invitedBy: text("invited_by").references(() => users.id, { onDelete: "set null" }), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + acceptedAt: integer("accepted_at", { mode: "timestamp_ms" }), + acceptedBy: text("accepted_by").references(() => users.id, { onDelete: "set null" }), + revokedAt: integer("revoked_at", { mode: "timestamp_ms" }), + createdAt: createdAt(), + }, + (t) => [ + index("invites_workspace_id_idx").on(t.workspaceId), + index("invites_email_idx").on(t.email), + check("invites_role_ck", sql`${t.role} IN (${ROLE_CHECK})`), + check("invites_email_lower_ck", sql`${t.email} = lower(${t.email})`), + ], +); diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts new file mode 100644 index 0000000..5a9a6cf --- /dev/null +++ b/packages/db/src/testing.ts @@ -0,0 +1,81 @@ +/** + * Test-only helpers: open a fresh, migrated database on each driver. + * Import from `@gitwork/db/testing` in tests only — it loads wrangler. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getPlatformProxy } from "wrangler"; +import { openD1 } from "./d1"; +import { MIGRATIONS_DIR, migrateSqlite } from "./migrate"; +import { openSqlite } from "./node"; +import type { Database } from "./types"; + +export interface TestDriver { + name: "sqlite" | "d1"; + /** A fresh, fully migrated, empty database. */ + fresh(): Promise; + dispose(): Promise; +} + +export function sqliteTestDriver(): TestDriver { + return { + name: "sqlite", + async fresh() { + const database = openSqlite(":memory:"); + await migrateSqlite(database); + return database; + }, + async dispose() {}, + }; +} + +type Env = { DB: D1Database }; + +export async function d1TestDriver(): Promise { + const proxy = await getPlatformProxy({ + configPath: fileURLToPath(new URL("../test/wrangler.test.jsonc", import.meta.url)), + persist: false, + }); + const binding = proxy.env.DB; + return { + name: "d1", + async fresh() { + await resetD1(binding); + return openD1(binding); + }, + dispose: () => proxy.dispose(), + }; +} + +/** Both drivers, for `describe.each`. Call `dispose()` on each in `afterAll`. */ +export async function allTestDrivers(): Promise { + return [sqliteTestDriver(), await d1TestDriver()]; +} + +async function resetD1(binding: D1Database): Promise { + const { results } = await binding + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'") + .all<{ name: string }>(); + const drops = results.map((r) => binding.prepare(`DROP TABLE IF EXISTS "${r.name}"`)); + if (drops.length > 0) { + await binding.batch([binding.prepare("PRAGMA defer_foreign_keys = ON"), ...drops]); + } + await applyMigrationsToD1(binding); +} + +interface Journal { + entries: { idx: number; tag: string }[]; +} + +/** Applies drizzle-kit migrations to a D1 binding in journal order, as `wrangler d1 migrations` does. */ +export async function applyMigrationsToD1(binding: D1Database): Promise { + const journal = JSON.parse(readFileSync(join(MIGRATIONS_DIR, "meta/_journal.json"), "utf8")) as Journal; + for (const entry of [...journal.entries].sort((a, b) => a.idx - b.idx)) { + const statements = readFileSync(join(MIGRATIONS_DIR, `${entry.tag}.sql`), "utf8") + .split("--> statement-breakpoint") + .map((s) => s.trim()) + .filter(Boolean); + await binding.batch(statements.map((s) => binding.prepare(s))); + } +} diff --git a/packages/db/test/contract.ts b/packages/db/test/contract.ts index f348d0f..3a72030 100644 --- a/packages/db/test/contract.ts +++ b/packages/db/test/contract.ts @@ -37,3 +37,21 @@ export function databaseContract(getDatabase: () => Database) { expect(rows).toHaveLength(0); }); } + +/** Schema constraints that SQLite only enforces when written as CHECKs. */ +export function constraintContract(getDatabase: () => Database) { + it("rejects an unknown role and an uppercase invite email", async () => { + const { db } = getDatabase(); + const { accounts, workspaces, invites } = await import("../src/schema"); + await db.insert(accounts).values({ id: "a1", name: "Acme" }); + await db.insert(workspaces).values({ id: "w1", accountId: "a1", name: "Site" }); + const base = { workspaceId: "w1", expiresAt: new Date(Date.now() + 1000) }; + await expect( + db.insert(invites).values({ ...base, id: "i1", email: "x@y.z", role: "superuser" as "owner", tokenHash: "h1" }), + ).rejects.toThrow(); + await expect( + db.insert(invites).values({ ...base, id: "i2", email: "X@Y.Z", role: "member", tokenHash: "h2" }), + ).rejects.toThrow(); + await db.insert(invites).values({ ...base, id: "i3", email: "x@y.z", role: "member", tokenHash: "h3" }); + }); +} diff --git a/packages/db/test/d1.test.ts b/packages/db/test/d1.test.ts index f8e87d9..11f3c07 100644 --- a/packages/db/test/d1.test.ts +++ b/packages/db/test/d1.test.ts @@ -1,33 +1,18 @@ -import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { getPlatformProxy } from "wrangler"; -import { openD1 } from "../src/d1"; +import { type TestDriver, d1TestDriver } from "../src/testing"; import type { Database } from "../src/types"; -import { databaseContract } from "./contract"; -import { applyMigrationsToD1 } from "./migrations"; - -type Env = { DB: D1Database }; +import { constraintContract, databaseContract } from "./contract"; describe("D1 driver (workerd via wrangler)", () => { - let proxy: Awaited>>; + let driver: TestDriver; let database: Database; beforeAll(async () => { - proxy = await getPlatformProxy({ - configPath: fileURLToPath(new URL("./wrangler.test.jsonc", import.meta.url)), - persist: false, - }); - }); - - afterAll(async () => { - await proxy?.dispose(); + driver = await d1TestDriver(); }); - + afterAll(() => driver?.dispose()); beforeEach(async () => { - const binding = proxy.env.DB; - await binding.exec("DROP TABLE IF EXISTS instance_settings"); - await applyMigrationsToD1(binding); - database = openD1(binding); + database = await driver.fresh(); }); it("runs on a real D1 binding, not a SQLite fallback", async () => { @@ -40,4 +25,5 @@ describe("D1 driver (workerd via wrangler)", () => { }); databaseContract(() => database); + constraintContract(() => database); }); diff --git a/packages/db/test/migrations.ts b/packages/db/test/migrations.ts deleted file mode 100644 index 1ce51c4..0000000 --- a/packages/db/test/migrations.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { MIGRATIONS_DIR } from "../src/migrate"; - -interface Journal { - entries: { idx: number; tag: string }[]; -} - -/** Applies drizzle-kit migrations to a D1 binding, in journal order. Test helper only. */ -export async function applyMigrationsToD1(binding: D1Database): Promise { - const journal = JSON.parse(readFileSync(join(MIGRATIONS_DIR, "meta/_journal.json"), "utf8")) as Journal; - for (const entry of [...journal.entries].sort((a, b) => a.idx - b.idx)) { - const sqlText = readFileSync(join(MIGRATIONS_DIR, `${entry.tag}.sql`), "utf8"); - const statements = sqlText - .split("--> statement-breakpoint") - .map((s) => s.trim()) - .filter(Boolean); - await binding.batch(statements.map((s) => binding.prepare(s))); - } -} diff --git a/packages/db/test/sqlite.test.ts b/packages/db/test/sqlite.test.ts index 091462e..6aeb059 100644 --- a/packages/db/test/sqlite.test.ts +++ b/packages/db/test/sqlite.test.ts @@ -1,15 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { migrateSqlite } from "../src/migrate"; -import { openSqlite } from "../src/node"; +import { sqliteTestDriver } from "../src/testing"; import type { Database } from "../src/types"; -import { databaseContract } from "./contract"; +import { constraintContract, databaseContract } from "./contract"; describe("SQLite file driver (libsql)", () => { let database: Database; beforeEach(async () => { - database = openSqlite(":memory:"); - await migrateSqlite(database); + database = await sqliteTestDriver().fresh(); }); it("reports the sqlite driver", () => { @@ -17,4 +15,5 @@ describe("SQLite file driver (libsql)", () => { }); databaseContract(() => database); + constraintContract(() => database); }); diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..388b554 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@gitwork/server", + "version": "0.0.0", + "private": true, + "license": "AGPL-3.0-only", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./email-node": "./src/email-node.ts" + }, + "scripts": { + "lint": "eslint --config ../../eslint.config.mjs .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@gitwork/core": "workspace:*", + "@gitwork/db": "workspace:*", + "better-auth": "1.7.8", + "drizzle-orm": "0.45.4", + "nodemailer": "10.1.1" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20261013.1", + "@types/node": "^22.20.5", + "@types/nodemailer": "^8.0.2", + "typescript": "5.9.3", + "wrangler": "4.151.0" + } +} diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts new file mode 100644 index 0000000..67b4ed0 --- /dev/null +++ b/packages/server/src/auth.ts @@ -0,0 +1,101 @@ +import { APP_NAME } from "@gitwork/core"; +import { authIdentities, type Database, rateLimits, sessions, users, verifications } from "@gitwork/db"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError } from "better-auth/api"; +import { bearer, emailOTP } from "better-auth/plugins"; +import type { EmailSender } from "./email"; +import { signInEmail } from "./sign-in-email"; +import { mayCreateUser } from "./signup"; + +export interface AuthConfig { + database: Database; + secret: string; + baseURL: string; + email: EmailSender; + /** GitHub App OAuth credentials for linking GitHub accounts. Omit to disable linking. */ + github?: { clientId: string; clientSecret: string }; +} + +/** + * Better Auth owns identity only (decision 017). Created per request: D1 bindings are per request. + * Sign-in is a 6-digit email code; the same email carries a link that embeds the code. + */ +export function createAuth(config: AuthConfig) { + const { db } = config.database; + return betterAuth({ + appName: APP_NAME, + secret: config.secret, + baseURL: config.baseURL, + database: drizzleAdapter(db as never, { + provider: "sqlite", + schema: { + users, + sessions, + auth_identities: authIdentities, + verifications, + rate_limits: rateLimits, + }, + }), + user: { modelName: "users" }, + session: { modelName: "sessions" }, + verification: { modelName: "verifications" }, + account: { + modelName: "auth_identities", + // GitHub tokens can write to client repos. + encryptOAuthTokens: true, + accountLinking: { enabled: true, allowDifferentEmails: true }, + }, + // In the database: Worker isolates do not share memory, so in-memory limits do not hold. + rateLimit: { + enabled: true, + storage: "database", + modelName: "rate_limits", + window: 60, + max: 100, + customRules: { + "/email-otp/send-verification-otp": { window: 300, max: 5 }, + "/sign-in/email-otp": { window: 300, max: 10 }, + }, + }, + advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"] } }, + socialProviders: config.github + ? { + github: { + clientId: config.github.clientId, + clientSecret: config.github.clientSecret, + // Link only. GitHub sign-up would bypass the sign-up policy. + disableSignUp: true, + }, + } + : {}, + databaseHooks: { + user: { + create: { + before: async (user) => { + const email = user.email.toLowerCase(); + if (!(await mayCreateUser(db, email))) { + throw new APIError("FORBIDDEN", { message: "Sign-ups are invite-only on this instance." }); + } + return { data: { ...user, email } }; + }, + }, + }, + }, + plugins: [ + emailOTP({ + otpLength: 6, + expiresIn: 600, + allowedAttempts: 5, + storeOTP: "hashed", + async sendVerificationOTP({ email, otp, type }) { + if (type !== "sign-in") return; + await config.email.send(signInEmail({ baseURL: config.baseURL, email, code: otp })); + }, + }), + bearer(), + ], + }); +} + +export type Auth = ReturnType; diff --git a/packages/server/src/email-node.ts b/packages/server/src/email-node.ts new file mode 100644 index 0000000..fae178f --- /dev/null +++ b/packages/server/src/email-node.ts @@ -0,0 +1,23 @@ +/** Node-only email adapters. Never import from Worker code (decision 016's driver split applies). */ +import { appendFile } from "node:fs/promises"; +import { createTransport } from "nodemailer"; +import type { EmailSender } from "./email"; + +/** SMTP through nodemailer. `url` is an SMTP URL, e.g. smtps://user:pass@smtp.example.com. */ +export function smtpSender(url: string, from: string): EmailSender { + const transport = createTransport(url); + return { + async send(message) { + await transport.sendMail({ from, ...message }); + }, + }; +} + +/** Appends each message as a JSON line. E2E tests read it to get sign-in codes. Tests only. */ +export function outboxSender(path: string): EmailSender { + return { + async send(message) { + await appendFile(path, `${JSON.stringify(message)}\n`); + }, + }; +} diff --git a/packages/server/src/email.ts b/packages/server/src/email.ts new file mode 100644 index 0000000..81d1889 --- /dev/null +++ b/packages/server/src/email.ts @@ -0,0 +1,56 @@ +/** Email adapters (decision 004). Runtime-neutral: no Node or Workers imports here. */ + +export interface EmailMessage { + to: string; + subject: string; + text: string; + html: string; +} + +export interface EmailSender { + send(message: EmailMessage): Promise; +} + +/** Development only. Prints the message, including sign-in codes, to the server log. */ +export function consoleSender(): EmailSender { + return { + async send(message) { + console.info(`[email] to=${message.to} subject=${message.subject}\n${message.text}`); + }, + }; +} + +/** Production with nothing configured: refuse to send rather than leak codes to a log. */ +export function unconfiguredSender(): EmailSender { + return { + async send() { + throw new Error("Email is not configured. Set EMAIL_TRANSPORT (smtp or cloudflare)."); + }, + }; +} + +/** Cloudflare Workers: the `send_email` binding (Cloudflare Email Service). */ +export function cloudflareSender(binding: SendEmail, from: { email: string; name: string }): EmailSender { + return { + async send(message) { + await binding.send({ from, to: message.to, subject: message.subject, text: message.text, html: message.html }); + }, + }; +} + +/** Parses `Name
` or a bare address. */ +export function parseFrom(value: string): { email: string; name: string } { + const match = /^\s*(.*?)\s*<([^>]+)>\s*$/.exec(value); + return match ? { name: match[1] ?? "", email: match[2]! } : { name: "", email: value.trim() }; +} + +/** Records messages in memory. Tests only. */ +export function memorySender(): EmailSender & { messages: EmailMessage[] } { + const messages: EmailMessage[] = []; + return { + messages, + async send(message) { + messages.push(message); + }, + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..2080b81 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,13 @@ +export { type Auth, type AuthConfig, createAuth } from "./auth"; +export { + cloudflareSender, + consoleSender, + type EmailMessage, + type EmailSender, + memorySender, + parseFrom, + unconfiguredSender, +} from "./email"; +export { getSignupPolicy, setSignupPolicy, type SignupPolicy } from "./instance-settings"; +export { signInEmail } from "./sign-in-email"; +export { mayCreateUser } from "./signup"; diff --git a/packages/server/src/instance-settings.ts b/packages/server/src/instance-settings.ts new file mode 100644 index 0000000..340a42c --- /dev/null +++ b/packages/server/src/instance-settings.ts @@ -0,0 +1,17 @@ +import { type Db, instanceSettings } from "@gitwork/db"; +import { eq } from "drizzle-orm"; + +export type SignupPolicy = "open" | "invite-only"; + +/** Who may create a new user (decision 017). Default `open` until the setup wizard exists. */ +export async function getSignupPolicy(db: Db): Promise { + const [row] = await db.select().from(instanceSettings).where(eq(instanceSettings.key, "signups")); + return row?.value === "invite-only" ? "invite-only" : "open"; +} + +export async function setSignupPolicy(db: Db, policy: SignupPolicy): Promise { + await db + .insert(instanceSettings) + .values({ key: "signups", value: policy }) + .onConflictDoUpdate({ target: instanceSettings.key, set: { value: policy, updatedAt: new Date() } }); +} diff --git a/packages/server/src/sign-in-email.ts b/packages/server/src/sign-in-email.ts new file mode 100644 index 0000000..dba1a22 --- /dev/null +++ b/packages/server/src/sign-in-email.ts @@ -0,0 +1,28 @@ +import { APP_NAME } from "@gitwork/core"; +import type { EmailMessage } from "./email"; + +/** + * One email, one secret, two ways in: type the code, or open the link. + * The code rides in the URL fragment, which browsers never send to the server, and the page + * verifies only on a click (POST). Email link scanners fetch links; a GET must never use the code. + */ +export function signInEmail(input: { baseURL: string; email: string; code: string }): EmailMessage { + const fragment = new URLSearchParams({ email: input.email, code: input.code }).toString(); + const link = `${input.baseURL.replace(/\/$/, "")}/sign-in/verify#${fragment}`; + const text = [ + `Your ${APP_NAME} sign-in code is ${input.code}`, + "", + `Or open this link to sign in: ${link}`, + "", + "The code expires in 10 minutes. If you did not ask for it, ignore this email.", + ].join("\n"); + const html = `

Your ${APP_NAME} sign-in code is

+

${input.code}

+

Sign in to ${APP_NAME}

+

The code expires in 10 minutes. If you did not ask for it, ignore this email.

`; + return { to: input.email, subject: `${input.code} is your ${APP_NAME} sign-in code`, text, html }; +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/packages/server/src/signup.ts b/packages/server/src/signup.ts new file mode 100644 index 0000000..86475ac --- /dev/null +++ b/packages/server/src/signup.ts @@ -0,0 +1,21 @@ +import { type Db, invites } from "@gitwork/db"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { getSignupPolicy } from "./instance-settings"; + +/** A new user may be created when sign-ups are open, or when a valid invite exists for the email. */ +export async function mayCreateUser(db: Db, email: string, now = new Date()): Promise { + if ((await getSignupPolicy(db)) === "open") return true; + const [invite] = await db + .select({ id: invites.id }) + .from(invites) + .where( + and( + eq(invites.email, email.toLowerCase()), + isNull(invites.acceptedAt), + isNull(invites.revokedAt), + gt(invites.expiresAt, now), + ), + ) + .limit(1); + return invite !== undefined; +} diff --git a/packages/server/test/auth.test.ts b/packages/server/test/auth.test.ts new file mode 100644 index 0000000..df99432 --- /dev/null +++ b/packages/server/test/auth.test.ts @@ -0,0 +1,104 @@ +import { type Database, invites, rateLimits, verifications, workspaces, accounts } from "@gitwork/db"; +import { type TestDriver, allTestDrivers } from "@gitwork/db/testing"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { setSignupPolicy } from "../src/instance-settings"; +import { authRequest, cookieFrom, lastCode, testAuth } from "./helpers"; + +const drivers = await allTestDrivers(); +afterAll(() => Promise.all(drivers.map((d) => d.dispose()))); + +describe.each(drivers)("sign-in on $name", (driver: TestDriver) => { + let database: Database; + let ctx: ReturnType; + + beforeEach(async () => { + database = await driver.fresh(); + ctx = testAuth(database); + }); + + it("emails one code and a link that carries the same code in the fragment", async () => { + const res = await authRequest(ctx.auth, "/email-otp/send-verification-otp", { + body: { email: "Jane@Example.com", type: "sign-in" }, + }); + expect(res.status).toBe(200); + const [message] = ctx.email.messages; + const code = lastCode(ctx.email.messages); + expect(message?.to).toBe("jane@example.com"); + expect(message?.text).toContain(`/sign-in/verify#email=jane%40example.com&code=${code}`); + // The code must never appear in a query string (server logs, link scanners). + expect(message?.text).not.toMatch(/verify\?/); + }); + + it("stores the code hashed, never in plain text", async () => { + await authRequest(ctx.auth, "/email-otp/send-verification-otp", { body: { email: "jane@example.com", type: "sign-in" } }); + const code = lastCode(ctx.email.messages); + const rows = await database.db.select().from(verifications); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) expect(row.value).not.toContain(code); + }); + + it("signs in with the code and returns a session for the lowercased email", async () => { + await authRequest(ctx.auth, "/email-otp/send-verification-otp", { body: { email: "Jane@Example.com", type: "sign-in" } }); + const res = await authRequest(ctx.auth, "/sign-in/email-otp", { + body: { email: "Jane@Example.com", otp: lastCode(ctx.email.messages) }, + }); + expect(res.status).toBe(200); + const session = await authRequest(ctx.auth, "/get-session", { cookie: cookieFrom(res) }); + const body = (await session.json()) as { user: { email: string } }; + expect(body.user.email).toBe("jane@example.com"); + }); + + it("rejects a wrong code", async () => { + await authRequest(ctx.auth, "/email-otp/send-verification-otp", { body: { email: "jane@example.com", type: "sign-in" } }); + const code = lastCode(ctx.email.messages); + const wrong = code === "000000" ? "111111" : "000000"; + const res = await authRequest(ctx.auth, "/sign-in/email-otp", { body: { email: "jane@example.com", otp: wrong } }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it("rate-limits code requests per IP, with counters in the database", async () => { + const statuses: number[] = []; + for (let i = 0; i < 6; i++) { + const res = await authRequest(ctx.auth, "/email-otp/send-verification-otp", { + body: { email: `user${i}@example.com`, type: "sign-in" }, + ip: "198.51.100.9", + }); + statuses.push(res.status); + } + expect(statuses.slice(0, 5)).toEqual([200, 200, 200, 200, 200]); + expect(statuses[5]).toBe(429); + expect((await database.db.select().from(rateLimits)).length).toBeGreaterThan(0); + }); + + describe("invite-only sign-ups", () => { + beforeEach(async () => { + await setSignupPolicy(database.db, "invite-only"); + }); + + it("refuses to create a user with no invite", async () => { + await authRequest(ctx.auth, "/email-otp/send-verification-otp", { body: { email: "stranger@example.com", type: "sign-in" } }); + const res = await authRequest(ctx.auth, "/sign-in/email-otp", { + body: { email: "stranger@example.com", otp: lastCode(ctx.email.messages) }, + }); + expect(res.status).toBe(403); + }); + + it("creates a user who has a valid pending invite", async () => { + await database.db.insert(accounts).values({ id: "a1", name: "Acme" }); + await database.db.insert(workspaces).values({ id: "w1", accountId: "a1", name: "Site" }); + await database.db.insert(invites).values({ + id: "i1", + workspaceId: "w1", + email: "guest@example.com", + role: "read_only", + tokenHash: "hash", + expiresAt: new Date(Date.now() + 60_000), + }); + await authRequest(ctx.auth, "/email-otp/send-verification-otp", { body: { email: "Guest@Example.com", type: "sign-in" } }); + const res = await authRequest(ctx.auth, "/sign-in/email-otp", { + body: { email: "Guest@Example.com", otp: lastCode(ctx.email.messages) }, + }); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts new file mode 100644 index 0000000..edbb779 --- /dev/null +++ b/packages/server/test/helpers.ts @@ -0,0 +1,56 @@ +import type { Database } from "@gitwork/db"; +import { createAuth } from "../src/auth"; +import { memorySender } from "../src/email"; + +export const BASE_URL = "http://localhost:3000"; + +export function testAuth(database: Database) { + const email = memorySender(); + const auth = createAuth({ database, secret: "test-secret-test-secret-test-secret-32", baseURL: BASE_URL, email }); + return { auth, email }; +} + +/** Calls the auth HTTP handler the way a browser on BASE_URL would. */ +export function authRequest( + auth: ReturnType, + path: string, + init: { body?: unknown; cookie?: string; ip?: string; method?: string } = {}, +): Promise { + const headers = new Headers({ origin: BASE_URL, "x-forwarded-for": init.ip ?? "203.0.113.7" }); + if (init.body !== undefined) headers.set("content-type", "application/json"); + if (init.cookie) headers.set("cookie", init.cookie); + return auth.handler( + new Request(`${BASE_URL}/api/auth${path}`, { + method: init.method ?? (init.body === undefined ? "GET" : "POST"), + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }), + ); +} + +/** The code from the last sign-in email. */ +export function lastCode(messages: { text: string }[]): string { + const match = /sign-in code is (\d{6})/.exec(messages.at(-1)?.text ?? ""); + if (!match) throw new Error("no sign-in code in the last email"); + return match[1]!; +} + +/** Signs a user in by email code and returns the session cookie. */ +export async function signIn( + auth: ReturnType, + sender: { messages: { text: string }[] }, + address: string, +): Promise { + const sent = await authRequest(auth, "/email-otp/send-verification-otp", { body: { email: address, type: "sign-in" } }); + if (!sent.ok) throw new Error(`send code failed: ${sent.status} ${await sent.text()}`); + const res = await authRequest(auth, "/sign-in/email-otp", { body: { email: address, otp: lastCode(sender.messages) } }); + if (!res.ok) throw new Error(`sign-in failed: ${res.status} ${await res.text()}`); + return cookieFrom(res); +} + +export function cookieFrom(res: Response): string { + return res.headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..ef6f2a2 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM"], + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9d89d5..90ccecf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,40 @@ importers: specifier: 4.151.0 version: 4.151.0(@cloudflare/workers-types@5.20261013.1)(@types/node@22.20.5) + packages/server: + dependencies: + '@gitwork/core': + specifier: workspace:* + version: link:../core + '@gitwork/db': + specifier: workspace:* + version: link:../db + better-auth: + specifier: 1.7.8 + version: 1.7.8(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6))(next@16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@22.20.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.4(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(tsx@4.23.15)(yaml@2.9.1))) + drizzle-orm: + specifier: 0.45.4 + version: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6) + nodemailer: + specifier: 10.1.1 + version: 10.1.1 + devDependencies: + '@cloudflare/workers-types': + specifier: 5.20261013.1 + version: 5.20261013.1 + '@types/node': + specifier: ^22.20.5 + version: 22.20.5 + '@types/nodemailer': + specifier: ^8.0.2 + version: 8.0.2 + typescript: + specifier: 5.9.3 + version: 5.9.3 + wrangler: + specifier: 4.151.0 + version: 4.151.0(@cloudflare/workers-types@5.20261013.1)(@types/node@22.20.5) + packages: '@alloc/quick-lru@5.3.0': @@ -2043,6 +2077,9 @@ packages: '@types/node@22.20.5': resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + '@types/nodemailer@8.0.2': + resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -3732,6 +3769,10 @@ packages: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} + nodemailer@10.1.1: + resolution: {integrity: sha512-pbqZRoeYUO+1E8TJ/11WeY4D2EVujj1hsYjz3qff/vUFDtGnmi7p3iqzTFslYOfQ6PnjVcWxx/CIUSHCSrO/QA==} + engines: {node: '>=20.0.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -6304,6 +6345,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.2': + dependencies: + '@types/node': 22.20.5 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -6656,6 +6701,35 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' + better-auth@1.7.8(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6))(next@16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@22.20.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.4(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(tsx@4.23.15)(yaml@2.9.1))): + dependencies: + '@better-auth/core': 1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6)) + '@better-auth/kysely-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/mongo-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/prisma-adapter': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0) + '@better-auth/telemetry': 1.7.8(@better-auth/core@1.7.8(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.2(zod@4.6.7) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.7 + optionalDependencies: + drizzle-kit: 0.31.11 + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261013.1)(@libsql/client@0.18.0)(kysely@0.29.6) + next: 16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@22.20.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + vitest: 5.0.4(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(tsx@4.23.15)(yaml@2.9.1)) + transitivePeerDependencies: + - '@opentelemetry/api' + better-call@1.4.2(zod@4.6.7): dependencies: '@better-auth/utils': 0.5.0 @@ -8046,6 +8120,34 @@ snapshots: - '@types/node' - babel-plugin-macros + next@16.3.8(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.1)(@types/node@22.20.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@next/env': 16.3.8 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.28 + caniuse-lite: 1.0.30001816 + postcss: 8.5.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 + '@playwright/test': 1.64.1 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.35.5(@types/node@22.20.5) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + optional: true + node-domexception@1.0.0: {} node-exports-info@1.6.2: @@ -8061,6 +8163,8 @@ snapshots: node-releases@2.0.58: {} + nodemailer@10.1.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} diff --git a/vitest.config.ts b/vitest.config.ts index 936a5c4..85bb715 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { projects: [ { test: { name: "core", root: "packages/core", include: ["src/**/*.test.ts"] } }, + { test: { name: "server", root: "packages/server", include: ["test/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 60_000 } }, { test: { name: "db", root: "packages/db", include: ["test/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 60_000 } }, ], }, From 482f4d3bc1eb68203b9184c664bcc6bec360ee82 Mon Sep 17 00:00:00 2001 From: Erik Goins Date: Thu, 15 Oct 2026 19:02:26 -0300 Subject: [PATCH 4/8] feat: account, workspace, member, and invite services Services in @gitwork/server enforce the role matrix through packages/core. Invite acceptance guards the membership INSERT itself (INSERT ... SELECT ... WHERE pending) and takes the role from the invite row; dropping the guard fails the expired/revoked tests on both drivers. Role changes and removals are guarded on the checked role. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GnDXTQ6MVbAXETYsq6fvqg --- docs/STATE.md | 2 +- docs/wip/identity.md | 10 + packages/server/src/crypto.ts | 15 ++ packages/server/src/errors.ts | 17 ++ packages/server/src/index.ts | 21 +++ packages/server/src/services/access.ts | 77 ++++++++ packages/server/src/services/accounts.ts | 90 +++++++++ packages/server/src/services/invites.ts | 184 ++++++++++++++++++ packages/server/src/services/members.ts | 105 +++++++++++ packages/server/test/services.test.ts | 225 +++++++++++++++++++++++ 10 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/crypto.ts create mode 100644 packages/server/src/errors.ts create mode 100644 packages/server/src/services/access.ts create mode 100644 packages/server/src/services/accounts.ts create mode 100644 packages/server/src/services/invites.ts create mode 100644 packages/server/src/services/members.ts create mode 100644 packages/server/test/services.test.ts diff --git a/docs/STATE.md b/docs/STATE.md index 97c9c7d..8b6f97a 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -6,7 +6,7 @@ Last updated: 2026-10-15 | Feature | Step | Blockers | WIP doc | |---|---|---|---| -| Phase 1 — Identity | Domain services | None | [wip/identity.md](wip/identity.md) | +| Phase 1 — Identity | API routes | None | [wip/identity.md](wip/identity.md) | Resolved 2026-10-15: draft items hidden from Read Only by default (006); private convention import moved to `CLAUDE.local.md`; third-party skills kept out of git. diff --git a/docs/wip/identity.md b/docs/wip/identity.md index 154b015..840e14a 100644 --- a/docs/wip/identity.md +++ b/docs/wip/identity.md @@ -105,3 +105,13 @@ None. text enums are TypeScript-only; SQLite needs the CHECK. - Test helpers moved to `@gitwork/db/testing` (`allTestDrivers()` → libsql + D1). - New package `@gitwork/server`: auth factory, email adapters, sign-up rule, services. +- Services done (`@gitwork/server/src/services`): access, accounts, members, invites. 40 service + tests per driver pass. Removing the guard from the membership INSERT fails the expired, revoked, + and wrong-email tests on both drivers. +- No "last Owner" guard per workspace: Account Owners are Owner in every workspace of the account, + so a workspace always has one. The guard belongs on removing Account Owners (not in Phase 1). +- Account creation on `invite-only` instances: allowed for the first Account on the instance, or + for a user who already owns an Account. +- Role changes and removals use `WHERE role = `; a concurrent change returns 409. +- Account Owners cannot be changed or removed per workspace (409 `account_owner`). +- Anyone may leave a workspace. All members, including Read Only, can see the member list. diff --git a/packages/server/src/crypto.ts b/packages/server/src/crypto.ts new file mode 100644 index 0000000..8760185 --- /dev/null +++ b/packages/server/src/crypto.ts @@ -0,0 +1,15 @@ +/** Web Crypto only, so this runs on Node and Workers alike. */ + +export function randomToken(bytes = 32): string { + const buf = crypto.getRandomValues(new Uint8Array(bytes)); + let binary = ""; + for (const b of buf) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export const newId = () => crypto.randomUUID(); diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts new file mode 100644 index 0000000..27e5a05 --- /dev/null +++ b/packages/server/src/errors.ts @@ -0,0 +1,17 @@ +/** A failure a route can turn into an HTTP status. Messages are safe to show to the user. */ +export class ServiceError extends Error { + constructor( + readonly status: 400 | 401 | 403 | 404 | 409 | 410, + readonly code: string, + message: string, + ) { + super(message); + this.name = "ServiceError"; + } +} + +export const notFound = (what = "Not found") => new ServiceError(404, "not_found", what); +export const forbidden = (code = "forbidden", message = "You do not have access to do that.") => + new ServiceError(403, code, message); +export const badRequest = (message: string) => new ServiceError(400, "invalid", message); +export const conflict = (code: string, message: string) => new ServiceError(409, code, message); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2080b81..5fd4b59 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -11,3 +11,24 @@ export { export { getSignupPolicy, setSignupPolicy, type SignupPolicy } from "./instance-settings"; export { signInEmail } from "./sign-in-email"; export { mayCreateUser } from "./signup"; +export { ServiceError } from "./errors"; +export { + type WorkspaceAccess, + getWorkspaceAccess, + hasLinkedGitHub, + requireCapability, + requireWorkspaceAccess, +} from "./services/access"; +export { createAccountWithWorkspace, createWorkspace, listMyWorkspaces, type WorkspaceSummary } from "./services/accounts"; +export { + INVITE_TTL_MS, + type InviteStatus, + acceptInvite, + createInvite, + getInvitePreview, + inviteEmail, + listInvites, + normalizeEmail, + revokeInvite, +} from "./services/invites"; +export { type MemberView, changeMemberRole, listMembers, removeMember } from "./services/members"; diff --git a/packages/server/src/services/access.ts b/packages/server/src/services/access.ts new file mode 100644 index 0000000..2443bc5 --- /dev/null +++ b/packages/server/src/services/access.ts @@ -0,0 +1,77 @@ +import { + type Actor, + type Capability, + type PermissionSettings, + type Role, + authorize, + effectiveRole, +} from "@gitwork/core"; +import { type Db, accountOwners, authIdentities, workspaceMembers, workspaces } from "@gitwork/db"; +import { and, eq } from "drizzle-orm"; +import { forbidden, notFound } from "../errors"; + +export interface WorkspaceAccess { + userId: string; + workspace: { id: string; accountId: string; name: string }; + role: Role; + isAccountOwner: boolean; + actor: Actor; + settings: PermissionSettings; +} + +export async function hasLinkedGitHub(db: Db, userId: string): Promise { + const [row] = await db + .select({ id: authIdentities.id }) + .from(authIdentities) + .where(and(eq(authIdentities.userId, userId), eq(authIdentities.providerId, "github"))) + .limit(1); + return row !== undefined; +} + +/** The user's access to one workspace, or null when they have none. */ +export async function getWorkspaceAccess(db: Db, userId: string, workspaceId: string): Promise { + const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1); + if (!workspace) return null; + const [[member], [owner], hasGitHub] = await Promise.all([ + db + .select({ role: workspaceMembers.role }) + .from(workspaceMembers) + .where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, userId))) + .limit(1), + db + .select({ userId: accountOwners.userId }) + .from(accountOwners) + .where(and(eq(accountOwners.accountId, workspace.accountId), eq(accountOwners.userId, userId))) + .limit(1), + hasLinkedGitHub(db, userId), + ]); + const role = effectiveRole({ memberRole: member?.role ?? null, isAccountOwner: owner !== undefined }); + if (!role) return null; + return { + userId, + workspace: { id: workspace.id, accountId: workspace.accountId, name: workspace.name }, + role, + isAccountOwner: owner !== undefined, + actor: { role, hasGitHub }, + settings: { + readOnlyCanComment: workspace.readOnlyCanComment, + showDraftsToReadOnly: workspace.showDraftsToReadOnly, + }, + }; +} + +/** Loads access or throws 404. No access and no workspace look the same to the caller. */ +export async function requireWorkspaceAccess(db: Db, userId: string, workspaceId: string): Promise { + const access = await getWorkspaceAccess(db, userId, workspaceId); + if (!access) throw notFound("Workspace not found."); + return access; +} + +export function requireCapability(access: WorkspaceAccess, capability: Capability): void { + const decision = authorize(access.actor, capability, access.settings); + if (decision.ok) return; + if (decision.reason === "github_link_required") { + throw forbidden("github_link_required", "Link your GitHub account to do this."); + } + throw forbidden(); +} diff --git a/packages/server/src/services/accounts.ts b/packages/server/src/services/accounts.ts new file mode 100644 index 0000000..408cb04 --- /dev/null +++ b/packages/server/src/services/accounts.ts @@ -0,0 +1,90 @@ +import type { Role } from "@gitwork/core"; +import { type Db, accountOwners, accounts, workspaceMembers, workspaces } from "@gitwork/db"; +import { eq, inArray, or } from "drizzle-orm"; +import { newId } from "../crypto"; +import { badRequest, forbidden } from "../errors"; +import { getSignupPolicy } from "../instance-settings"; + +function cleanName(value: string, what: string): string { + const name = value.trim(); + if (name.length < 1 || name.length > 100) throw badRequest(`${what} must be 1–100 characters.`); + return name; +} + +/** + * Who may create an Account (decision 017). Open instances: anyone signed in. Invite-only + * instances: the first Account on the instance, or someone who already owns an Account. + */ +async function mayCreateAccount(db: Db, userId: string): Promise { + if ((await getSignupPolicy(db)) === "open") return true; + const [anyAccount] = await db.select({ id: accounts.id }).from(accounts).limit(1); + if (!anyAccount) return true; + const [owned] = await db.select().from(accountOwners).where(eq(accountOwners.userId, userId)).limit(1); + return owned !== undefined; +} + +/** Creates an Account, makes the user its Owner, and creates its first workspace — atomically. */ +export async function createAccountWithWorkspace( + db: Db, + input: { userId: string; accountName: string; workspaceName: string }, +): Promise<{ accountId: string; workspaceId: string }> { + const accountName = cleanName(input.accountName, "Account name"); + const workspaceName = cleanName(input.workspaceName, "Workspace name"); + if (!(await mayCreateAccount(db, input.userId))) { + throw forbidden("account_creation_closed", "New accounts are invite-only on this instance."); + } + const accountId = newId(); + const workspaceId = newId(); + await db.batch([ + db.insert(accounts).values({ id: accountId, name: accountName }), + db.insert(accountOwners).values({ accountId, userId: input.userId }), + db.insert(workspaces).values({ id: workspaceId, accountId, name: workspaceName }), + ]); + return { accountId, workspaceId }; +} + +/** Only Account Owners add workspaces to an Account. */ +export async function createWorkspace( + db: Db, + input: { userId: string; accountId: string; name: string }, +): Promise<{ workspaceId: string }> { + const name = cleanName(input.name, "Workspace name"); + const owned = await db.select().from(accountOwners).where(eq(accountOwners.userId, input.userId)); + if (!owned.some((o) => o.accountId === input.accountId)) throw forbidden(); + const workspaceId = newId(); + await db.insert(workspaces).values({ id: workspaceId, accountId: input.accountId, name }); + return { workspaceId }; +} + +export interface WorkspaceSummary { + id: string; + name: string; + accountId: string; + accountName: string; + role: Role; +} + +/** Every workspace the user can open, with their effective role. */ +export async function listMyWorkspaces(db: Db, userId: string): Promise { + const [owned, memberships] = await Promise.all([ + db.select({ accountId: accountOwners.accountId }).from(accountOwners).where(eq(accountOwners.userId, userId)), + db + .select({ workspaceId: workspaceMembers.workspaceId, role: workspaceMembers.role }) + .from(workspaceMembers) + .where(eq(workspaceMembers.userId, userId)), + ]); + const ownedAccounts = new Set(owned.map((o) => o.accountId)); + const memberRole = new Map(memberships.map((m) => [m.workspaceId, m.role])); + const conditions = []; + if (ownedAccounts.size > 0) conditions.push(inArray(workspaces.accountId, [...ownedAccounts])); + if (memberRole.size > 0) conditions.push(inArray(workspaces.id, [...memberRole.keys()])); + if (conditions.length === 0) return []; + const rows = await db + .select({ id: workspaces.id, name: workspaces.name, accountId: workspaces.accountId, accountName: accounts.name }) + .from(workspaces) + .innerJoin(accounts, eq(accounts.id, workspaces.accountId)) + .where(or(...conditions)); + return rows + .map((w) => ({ ...w, role: (ownedAccounts.has(w.accountId) ? "owner" : memberRole.get(w.id)!) as Role })) + .sort((a, b) => a.accountName.localeCompare(b.accountName) || a.name.localeCompare(b.name)); +} diff --git a/packages/server/src/services/invites.ts b/packages/server/src/services/invites.ts new file mode 100644 index 0000000..20581b4 --- /dev/null +++ b/packages/server/src/services/invites.ts @@ -0,0 +1,184 @@ +import { APP_NAME, type Role, invitableRoles } from "@gitwork/core"; +import { type Db, invites, users, workspaceMembers, workspaces } from "@gitwork/db"; +import { and, eq, gt, isNull, sql } from "drizzle-orm"; +import { newId, randomToken, sha256Hex } from "../crypto"; +import type { EmailMessage } from "../email"; +import { ServiceError, badRequest, conflict, forbidden, notFound } from "../errors"; +import { type WorkspaceAccess, requireCapability } from "./access"; + +export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeEmail(value: string): string { + const email = value.trim().toLowerCase(); + if (!EMAIL_RE.test(email) || email.length > 254) throw badRequest("Enter a valid email address."); + return email; +} + +/** Valid = not accepted, not revoked, not expired. Every write that uses an invite repeats this. */ +const pending = (now: Date) => + and(isNull(invites.acceptedAt), isNull(invites.revokedAt), gt(invites.expiresAt, now)); + +/** + * Creates an invite and returns the raw token (only its hash is stored). Re-inviting the same + * email revokes the earlier pending invite in the same batch. + */ +export async function createInvite( + db: Db, + access: WorkspaceAccess, + input: { email: string; role: Role }, + now = new Date(), +): Promise<{ inviteId: string; token: string; email: string; expiresAt: Date }> { + requireCapability(access, "members.invite"); + if (!invitableRoles(access.role).includes(input.role)) throw forbidden("role_too_high", "You cannot invite with that role."); + const email = normalizeEmail(input.email); + const [existing] = await db + .select({ userId: users.id }) + .from(users) + .innerJoin(workspaceMembers, eq(workspaceMembers.userId, users.id)) + .where(and(eq(users.email, email), eq(workspaceMembers.workspaceId, access.workspace.id))) + .limit(1); + if (existing) throw conflict("already_member", "That person is already a member."); + + const token = randomToken(); + const inviteId = newId(); + const expiresAt = new Date(now.getTime() + INVITE_TTL_MS); + await db.batch([ + db + .update(invites) + .set({ revokedAt: now }) + .where(and(eq(invites.workspaceId, access.workspace.id), eq(invites.email, email), pending(now))), + db.insert(invites).values({ + id: inviteId, + workspaceId: access.workspace.id, + email, + role: input.role, + tokenHash: await sha256Hex(token), + invitedBy: access.userId, + expiresAt, + }), + ]); + return { inviteId, token, email, expiresAt }; +} + +export async function listInvites(db: Db, access: WorkspaceAccess, now = new Date()) { + requireCapability(access, "members.invite"); + return db + .select({ id: invites.id, email: invites.email, role: invites.role, expiresAt: invites.expiresAt, createdAt: invites.createdAt }) + .from(invites) + .where(and(eq(invites.workspaceId, access.workspace.id), pending(now))); +} + +export async function revokeInvite(db: Db, access: WorkspaceAccess, inviteId: string, now = new Date()): Promise { + requireCapability(access, "members.invite"); + const [invite] = await db + .select({ role: invites.role }) + .from(invites) + .where(and(eq(invites.id, inviteId), eq(invites.workspaceId, access.workspace.id), pending(now))) + .limit(1); + if (!invite) throw notFound("Invite not found."); + if (!invitableRoles(access.role).includes(invite.role)) throw forbidden(); + await db + .update(invites) + .set({ revokedAt: now }) + .where(and(eq(invites.id, inviteId), pending(now))); +} + +export type InviteStatus = "pending" | "accepted" | "revoked" | "expired"; + +/** What the invite page shows. Anyone holding the token may see it. */ +export async function getInvitePreview(db: Db, token: string, now = new Date()) { + const [row] = await db + .select({ + email: invites.email, + role: invites.role, + expiresAt: invites.expiresAt, + acceptedAt: invites.acceptedAt, + revokedAt: invites.revokedAt, + workspaceId: invites.workspaceId, + workspaceName: workspaces.name, + }) + .from(invites) + .innerJoin(workspaces, eq(workspaces.id, invites.workspaceId)) + .where(eq(invites.tokenHash, await sha256Hex(token))) + .limit(1); + if (!row) throw notFound("This invite link is not valid."); + const status: InviteStatus = row.acceptedAt + ? "accepted" + : row.revokedAt + ? "revoked" + : row.expiresAt <= now + ? "expired" + : "pending"; + return { email: row.email, role: row.role, workspaceId: row.workspaceId, workspaceName: row.workspaceName, status }; +} + +/** + * Accepts an invite for the signed-in user. A batch is all-or-nothing on errors, not conditional, + * so the membership INSERT carries the invite guard itself (INSERT … SELECT … WHERE pending). The + * role comes from the invite row. Idempotent: accepting twice returns the same membership. + */ +export async function acceptInvite( + db: Db, + input: { token: string; userId: string; userEmail: string }, + now = new Date(), +): Promise<{ workspaceId: string }> { + const tokenHash = await sha256Hex(input.token); + const email = input.userEmail.toLowerCase(); + const guard = and(eq(invites.tokenHash, tokenHash), eq(invites.email, email), pending(now)); + await db.batch([ + db + .insert(workspaceMembers) + .select( + db + .select({ + workspaceId: invites.workspaceId, + userId: sql`${input.userId}`.as("user_id"), + role: invites.role, + createdAt: sql`${now.getTime()}`.as("created_at"), + }) + .from(invites) + .where(guard), + ) + .onConflictDoNothing(), + db.update(invites).set({ acceptedAt: now, acceptedBy: input.userId }).where(guard), + ]); + + const [invite] = await db + .select({ + workspaceId: invites.workspaceId, + email: invites.email, + acceptedBy: invites.acceptedBy, + revokedAt: invites.revokedAt, + expiresAt: invites.expiresAt, + }) + .from(invites) + .where(eq(invites.tokenHash, tokenHash)) + .limit(1); + if (!invite) throw notFound("This invite link is not valid."); + if (invite.acceptedBy === input.userId) return { workspaceId: invite.workspaceId }; + if (invite.email !== email) { + throw forbidden("wrong_email", `This invite is for ${invite.email}. Sign in with that email to accept it.`); + } + if (invite.acceptedBy) throw conflict("already_accepted", "This invite was already used."); + if (invite.revokedAt) throw new ServiceError(410, "revoked", "This invite was cancelled."); + throw new ServiceError(410, "expired", "This invite has expired. Ask for a new one."); +} + +export function inviteEmail(input: { + baseURL: string; + to: string; + token: string; + workspaceName: string; + inviterName: string; +}): EmailMessage { + const link = `${input.baseURL.replace(/\/$/, "")}/invite/${input.token}`; + const subject = `${input.inviterName} invited you to ${input.workspaceName} on ${APP_NAME}`; + const text = `${subject}.\n\nOpen this link to accept: ${link}\n\nThe invite expires in 7 days.`; + const html = `

${escapeHtml(subject)}.

Accept the invite

The invite expires in 7 days.

`; + return { to: input.to, subject, text, html }; +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/packages/server/src/services/members.ts b/packages/server/src/services/members.ts new file mode 100644 index 0000000..6b72f1e --- /dev/null +++ b/packages/server/src/services/members.ts @@ -0,0 +1,105 @@ +import { type Role, canManageMember } from "@gitwork/core"; +import { type Db, accountOwners, users, workspaceMembers } from "@gitwork/db"; +import { and, eq } from "drizzle-orm"; +import { badRequest, conflict, forbidden, notFound } from "../errors"; +import { type WorkspaceAccess, requireCapability } from "./access"; + +export interface MemberView { + userId: string; + name: string; + email: string; + role: Role; + /** Owner through the Account; not changeable per workspace. */ + viaAccount: boolean; +} + +/** Everyone with access: explicit members plus the Account's Owners. */ +export async function listMembers(db: Db, access: WorkspaceAccess): Promise { + requireCapability(access, "workspace.view"); + const [members, owners] = await Promise.all([ + db + .select({ userId: users.id, name: users.name, email: users.email, role: workspaceMembers.role }) + .from(workspaceMembers) + .innerJoin(users, eq(users.id, workspaceMembers.userId)) + .where(eq(workspaceMembers.workspaceId, access.workspace.id)), + db + .select({ userId: users.id, name: users.name, email: users.email }) + .from(accountOwners) + .innerJoin(users, eq(users.id, accountOwners.userId)) + .where(eq(accountOwners.accountId, access.workspace.accountId)), + ]); + const byUser = new Map(); + for (const m of members) byUser.set(m.userId, { ...m, viaAccount: false }); + for (const o of owners) byUser.set(o.userId, { ...o, role: "owner", viaAccount: true }); + return [...byUser.values()].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email)); +} + +async function currentMemberRole(db: Db, workspaceId: string, userId: string): Promise { + const [row] = await db + .select({ role: workspaceMembers.role }) + .from(workspaceMembers) + .where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, userId))) + .limit(1); + return row?.role ?? null; +} + +async function isAccountOwner(db: Db, accountId: string, userId: string): Promise { + const [row] = await db + .select() + .from(accountOwners) + .where(and(eq(accountOwners.accountId, accountId), eq(accountOwners.userId, userId))) + .limit(1); + return row !== undefined; +} + +/** + * Changes a member's role. The UPDATE only applies if the role is still the one we checked, so a + * concurrent change cannot slip a promotion past the permission check. + */ +export async function changeMemberRole( + db: Db, + access: WorkspaceAccess, + input: { userId: string; role: Role }, +): Promise { + if (await isAccountOwner(db, access.workspace.accountId, input.userId)) { + throw conflict("account_owner", "Account Owners are Owner in every workspace. Change it at the Account."); + } + const current = await currentMemberRole(db, access.workspace.id, input.userId); + if (!current) throw notFound("Member not found."); + if (input.userId === access.userId) throw badRequest("You cannot change your own role."); + if (!canManageMember(access.actor, current, input.role)) throw forbidden(); + const result = await db + .update(workspaceMembers) + .set({ role: input.role }) + .where( + and( + eq(workspaceMembers.workspaceId, access.workspace.id), + eq(workspaceMembers.userId, input.userId), + eq(workspaceMembers.role, current), + ), + ) + .returning({ userId: workspaceMembers.userId }); + if (result.length === 0) throw conflict("changed", "This member changed. Reload and try again."); +} + +/** Removes a member. Anyone may remove themselves (leave). Same guarded write as role changes. */ +export async function removeMember(db: Db, access: WorkspaceAccess, userId: string): Promise { + if (await isAccountOwner(db, access.workspace.accountId, userId)) { + throw conflict("account_owner", "Account Owners cannot be removed from a workspace."); + } + const current = await currentMemberRole(db, access.workspace.id, userId); + if (!current) throw notFound("Member not found."); + const leaving = userId === access.userId; + if (!leaving && !canManageMember(access.actor, current, null)) throw forbidden(); + const result = await db + .delete(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, access.workspace.id), + eq(workspaceMembers.userId, userId), + eq(workspaceMembers.role, current), + ), + ) + .returning({ userId: workspaceMembers.userId }); + if (result.length === 0) throw conflict("changed", "This member changed. Reload and try again."); +} diff --git a/packages/server/test/services.test.ts b/packages/server/test/services.test.ts new file mode 100644 index 0000000..e980ec2 --- /dev/null +++ b/packages/server/test/services.test.ts @@ -0,0 +1,225 @@ +import { type Database, authIdentities, invites, users, workspaceMembers } from "@gitwork/db"; +import { type TestDriver, allTestDrivers } from "@gitwork/db/testing"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { ServiceError } from "../src/errors"; +import { setSignupPolicy } from "../src/instance-settings"; +import { getWorkspaceAccess, requireWorkspaceAccess } from "../src/services/access"; +import { createAccountWithWorkspace, createWorkspace, listMyWorkspaces } from "../src/services/accounts"; +import { + INVITE_TTL_MS, + acceptInvite, + createInvite, + getInvitePreview, + listInvites, + revokeInvite, +} from "../src/services/invites"; +import { changeMemberRole, listMembers, removeMember } from "../src/services/members"; + +const drivers = await allTestDrivers(); +afterAll(() => Promise.all(drivers.map((d) => d.dispose()))); + +async function rejects(promise: Promise, status: number, code?: string) { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).status).toBe(status); + if (code) expect((error as ServiceError).code).toBe(code); +} + +describe.each(drivers)("services on $name", (driver: TestDriver) => { + let database: Database; + let db: Database["db"]; + let ws: { accountId: string; workspaceId: string }; + + async function addUser(id: string, email = `${id}@example.com`) { + await db.insert(users).values({ id, name: id, email }); + } + async function addMember(userId: string, role: "owner" | "admin" | "member" | "read_only") { + await addUser(userId); + await db.insert(workspaceMembers).values({ workspaceId: ws.workspaceId, userId, role }); + } + const access = (userId: string) => requireWorkspaceAccess(db, userId, ws.workspaceId); + const membership = async (userId: string) => + ( + await db + .select() + .from(workspaceMembers) + .where(and(eq(workspaceMembers.workspaceId, ws.workspaceId), eq(workspaceMembers.userId, userId))) + )[0]; + + beforeEach(async () => { + database = await driver.fresh(); + db = database.db; + await addUser("owner"); + ws = await createAccountWithWorkspace(db, { userId: "owner", accountName: "Acme", workspaceName: "Website" }); + }); + + describe("accounts and access", () => { + it("makes the creator Owner through the Account", async () => { + const a = await access("owner"); + expect(a.role).toBe("owner"); + expect(a.isAccountOwner).toBe(true); + }); + + it("gives no access to a stranger", async () => { + await addUser("stranger"); + expect(await getWorkspaceAccess(db, "stranger", ws.workspaceId)).toBeNull(); + await rejects(access("stranger"), 404); + }); + + it("lists owned and member workspaces with roles, and nothing else", async () => { + await addUser("other"); + const otherWs = await createAccountWithWorkspace(db, { userId: "other", accountName: "Zed", workspaceName: "App" }); + await db.insert(workspaceMembers).values({ workspaceId: otherWs.workspaceId, userId: "owner", role: "read_only" }); + await createAccountWithWorkspace(db, { userId: "other", accountName: "Hidden", workspaceName: "Secret" }); + const list = await listMyWorkspaces(db, "owner"); + expect(list.map((w) => [w.accountName, w.name, w.role])).toEqual([ + ["Acme", "Website", "owner"], + ["Zed", "App", "read_only"], + ]); + }); + + it("lets only Account Owners add workspaces", async () => { + await addMember("admin", "admin"); + await rejects(createWorkspace(db, { userId: "admin", accountId: ws.accountId, name: "New" }), 403); + const { workspaceId } = await createWorkspace(db, { userId: "owner", accountId: ws.accountId, name: "New" }); + expect((await getWorkspaceAccess(db, "owner", workspaceId))?.role).toBe("owner"); + }); + + it("reports a linked GitHub account", async () => { + expect((await access("owner")).actor.hasGitHub).toBe(false); + await db.insert(authIdentities).values({ id: "g1", accountId: "12345", providerId: "github", userId: "owner" }); + expect((await access("owner")).actor.hasGitHub).toBe(true); + }); + + it("lets only existing Account Owners create Accounts on an invite-only instance", async () => { + await setSignupPolicy(db, "invite-only"); + await addUser("newcomer"); + await rejects( + createAccountWithWorkspace(db, { userId: "newcomer", accountName: "N", workspaceName: "W" }), + 403, + "account_creation_closed", + ); + await createAccountWithWorkspace(db, { userId: "owner", accountName: "Second", workspaceName: "W" }); + }); + }); + + describe("invites", () => { + it("lets an Admin invite up to Member, not Admin", async () => { + await addMember("admin", "admin"); + const a = await access("admin"); + await rejects(createInvite(db, a, { email: "x@example.com", role: "admin" }), 403, "role_too_high"); + const invite = await createInvite(db, a, { email: " New@Example.com ", role: "member" }); + expect(invite.email).toBe("new@example.com"); + }); + + it("stops Members and Read Only users from inviting", async () => { + await addMember("member", "member"); + await addMember("client", "read_only"); + await rejects(createInvite(db, await access("member"), { email: "x@example.com", role: "read_only" }), 403); + await rejects(createInvite(db, await access("client"), { email: "x@example.com", role: "read_only" }), 403); + }); + + it("stores only the token hash", async () => { + const { token } = await createInvite(db, await access("owner"), { email: "x@example.com", role: "member" }); + const [row] = await db.select().from(invites); + expect(row?.tokenHash).not.toBe(token); + expect(row?.tokenHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("revokes the earlier pending invite when re-inviting the same email", async () => { + const a = await access("owner"); + const first = await createInvite(db, a, { email: "x@example.com", role: "member" }); + await createInvite(db, a, { email: "x@example.com", role: "read_only" }); + expect((await getInvitePreview(db, first.token)).status).toBe("revoked"); + expect(await listInvites(db, a)).toHaveLength(1); + }); + + it("refuses to invite an existing member", async () => { + await addMember("member", "member"); + await rejects(createInvite(db, await access("owner"), { email: "member@example.com", role: "read_only" }), 409); + }); + + it("accepts with the invite's role, for the invited email only, and is idempotent", async () => { + const { token } = await createInvite(db, await access("owner"), { email: "guest@example.com", role: "read_only" }); + await addUser("intruder"); + await rejects(acceptInvite(db, { token, userId: "intruder", userEmail: "intruder@example.com" }), 403, "wrong_email"); + expect(await membership("intruder")).toBeUndefined(); + + await addUser("guest"); + const first = await acceptInvite(db, { token, userId: "guest", userEmail: "Guest@Example.com" }); + const second = await acceptInvite(db, { token, userId: "guest", userEmail: "guest@example.com" }); + expect(first).toEqual(second); + expect((await membership("guest"))?.role).toBe("read_only"); + expect((await getInvitePreview(db, token)).status).toBe("accepted"); + }); + + it("never creates a membership from an expired invite", async () => { + const created = new Date("2026-01-01T00:00:00Z"); + const { token } = await createInvite(db, await access("owner"), { email: "late@example.com", role: "member" }, created); + await addUser("late"); + const afterExpiry = new Date(created.getTime() + INVITE_TTL_MS + 1); + await rejects(acceptInvite(db, { token, userId: "late", userEmail: "late@example.com" }, afterExpiry), 410, "expired"); + expect(await membership("late")).toBeUndefined(); + }); + + it("never creates a membership from a revoked invite", async () => { + const a = await access("owner"); + const { token, inviteId } = await createInvite(db, a, { email: "gone@example.com", role: "member" }); + await revokeInvite(db, a, inviteId); + await addUser("gone"); + await rejects(acceptInvite(db, { token, userId: "gone", userEmail: "gone@example.com" }), 410, "revoked"); + expect(await membership("gone")).toBeUndefined(); + }); + + it("rejects an unknown token", async () => { + await rejects(acceptInvite(db, { token: "nope", userId: "owner", userEmail: "owner@example.com" }), 404); + }); + }); + + describe("members", () => { + beforeEach(async () => { + await addMember("admin", "admin"); + await addMember("member", "member"); + await addMember("client", "read_only"); + }); + + it("lists members plus Account Owners", async () => { + const list = await listMembers(db, await access("client")); + expect(list.map((m) => [m.userId, m.role, m.viaAccount])).toEqual([ + ["admin", "admin", false], + ["client", "read_only", false], + ["member", "member", false], + ["owner", "owner", true], + ]); + }); + + it("lets an Admin move a Member to Read Only but not promote to Admin", async () => { + const a = await access("admin"); + await changeMemberRole(db, a, { userId: "member", role: "read_only" }); + expect((await membership("member"))?.role).toBe("read_only"); + await rejects(changeMemberRole(db, a, { userId: "client", role: "admin" }), 403); + }); + + it("stops an Admin from changing or removing another Admin", async () => { + await addMember("admin2", "admin"); + const a = await access("admin"); + await rejects(changeMemberRole(db, a, { userId: "admin2", role: "member" }), 403); + await rejects(removeMember(db, a, "admin2"), 403); + }); + + it("protects Account Owners from per-workspace changes", async () => { + await rejects(changeMemberRole(db, await access("owner"), { userId: "owner", role: "member" }), 409, "account_owner"); + await rejects(removeMember(db, await access("owner"), "owner"), 409, "account_owner"); + }); + + it("lets anyone leave, and stops Members removing others", async () => { + await rejects(removeMember(db, await access("member"), "client"), 403); + await removeMember(db, await access("client"), "client"); + expect(await membership("client")).toBeUndefined(); + }); + }); +}); From ab2272ea131b91d9f64c41abb1dbabc4bb4c7931 Mon Sep 17 00:00:00 2001 From: Erik Goins Date: Thu, 15 Oct 2026 19:04:16 -0300 Subject: [PATCH 5/8] feat: /api/v1 router with session, CSRF, and validation A framework-free router in @gitwork/server serves /api/v1: me, accounts, workspaces, members, invites. Cookie-authenticated writes must be same-origin; bearer tokens (Mac app) are exempt. Tests cover 401, 403, 400, 404/405, cross-site writes, bearer auth, and the invite flow over HTTP on both drivers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GnDXTQ6MVbAXETYsq6fvqg --- packages/server/package.json | 3 +- packages/server/src/api/router.ts | 115 ++++++++++++++++++++++ packages/server/src/api/routes.ts | 156 ++++++++++++++++++++++++++++++ packages/server/src/index.ts | 2 + packages/server/test/api.test.ts | 149 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/api/router.ts create mode 100644 packages/server/src/api/routes.ts create mode 100644 packages/server/test/api.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 388b554..f0ef9c6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,7 +17,8 @@ "@gitwork/db": "workspace:*", "better-auth": "1.7.8", "drizzle-orm": "0.45.4", - "nodemailer": "10.1.1" + "nodemailer": "10.1.1", + "zod": "4.6.7" }, "devDependencies": { "@cloudflare/workers-types": "5.20261013.1", diff --git a/packages/server/src/api/router.ts b/packages/server/src/api/router.ts new file mode 100644 index 0000000..c101cdb --- /dev/null +++ b/packages/server/src/api/router.ts @@ -0,0 +1,115 @@ +/** + * The /api/v1 router (decision 015). Framework-free: takes a Request, returns a Response, so the + * same code runs under Next.js on Node and Workers, and in tests without a server. + */ +import type { Database } from "@gitwork/db"; +import { z } from "zod"; +import type { Auth } from "../auth"; +import type { EmailSender } from "../email"; +import { ServiceError } from "../errors"; + +export interface ApiDeps { + database: Database; + auth: Auth; + email: EmailSender; + baseURL: string; +} + +export interface Session { + user: { id: string; email: string; name: string }; +} + +export interface RouteContext

{ + deps: ApiDeps; + request: Request; + params: Record; + session: Session | null; + /** Parsed and validated JSON body. */ + body: unknown; +} + +interface Route { + method: "GET" | "POST" | "PATCH" | "DELETE"; + pattern: string; + auth: boolean; + body?: z.ZodType; + handler: (ctx: RouteContext) => Promise; +} + +export function route(def: { + method: Route["method"]; + pattern: string; + auth?: boolean; + body?: B; + handler: (ctx: RouteContext & { body: B extends z.ZodType ? z.infer : undefined }) => Promise; +}): Route { + return { auth: true, ...def, handler: def.handler as Route["handler"] }; +} + +function match(pattern: string, path: string): Record | null { + const p = pattern.split("/").filter(Boolean); + const s = path.split("/").filter(Boolean); + if (p.length !== s.length) return null; + const params: Record = {}; + for (let i = 0; i < p.length; i++) { + const seg = p[i]!; + if (seg.startsWith(":")) params[seg.slice(1)] = decodeURIComponent(s[i]!); + else if (seg !== s[i]) return null; + } + return params; +} + +const json = (status: number, data: unknown) => Response.json(data, { status }); +const error = (status: number, code: string, message: string) => json(status, { error: { code, message } }); + +/** + * Cookie-authenticated writes must come from our own origin. Next.js route handlers get no CSRF + * protection of their own. Bearer-token clients (the Mac app) are exempt: browsers never attach + * those automatically. + */ +function sameOrigin(request: Request, baseURL: string): boolean { + const site = request.headers.get("sec-fetch-site"); + if (site) return site === "same-origin"; + const origin = request.headers.get("origin"); + return origin !== null && origin === new URL(baseURL).origin; +} + +export function createApiHandler(routes: Route[], prefix = "/api/v1") { + return async function handle(request: Request, deps: ApiDeps): Promise { + const url = new URL(request.url); + const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname; + let allowed = false; + for (const r of routes) { + const params = match(r.pattern, path); + if (!params) continue; + allowed = true; + if (r.method !== request.method) continue; + try { + let session: Session | null = null; + if (r.auth) { + session = (await deps.auth.api.getSession({ headers: request.headers })) as Session | null; + if (!session) return error(401, "unauthenticated", "Sign in first."); + const usesBearer = request.headers.get("authorization")?.startsWith("Bearer ") ?? false; + if (request.method !== "GET" && !usesBearer && !sameOrigin(request, deps.baseURL)) { + return error(403, "cross_site", "Cross-site request refused."); + } + } + let body: unknown; + if (r.body) { + const raw = await request.json().catch(() => undefined); + const parsed = r.body.safeParse(raw); + if (!parsed.success) return error(400, "invalid", parsed.error.issues[0]?.message ?? "Invalid request body."); + body = parsed.data; + } + const result = await r.handler({ deps, request, params, session, body }); + if (result instanceof Response) return result; + return result === undefined ? new Response(null, { status: 204 }) : json(200, result); + } catch (e) { + if (e instanceof ServiceError) return error(e.status, e.code, e.message); + console.error("[api]", e); + return error(500, "internal", "Something went wrong."); + } + } + return allowed ? error(405, "method_not_allowed", "Method not allowed.") : error(404, "not_found", "Not found."); + }; +} diff --git a/packages/server/src/api/routes.ts b/packages/server/src/api/routes.ts new file mode 100644 index 0000000..bb60350 --- /dev/null +++ b/packages/server/src/api/routes.ts @@ -0,0 +1,156 @@ +import { CAPABILITIES, ROLES, can } from "@gitwork/core"; +import { users } from "@gitwork/db"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { requireWorkspaceAccess } from "../services/access"; +import { createAccountWithWorkspace, createWorkspace, listMyWorkspaces } from "../services/accounts"; +import { + acceptInvite, + createInvite, + getInvitePreview, + inviteEmail, + listInvites, + revokeInvite, +} from "../services/invites"; +import { changeMemberRole, listMembers, removeMember } from "../services/members"; +import { createApiHandler, route } from "./router"; + +const name = z.string().trim().min(1, "Name is required.").max(100, "Name is too long."); +const role = z.enum(ROLES); + +export const routes = [ + route({ + method: "GET", + pattern: "/me", + async handler({ deps, session }) { + const user = session!.user; + return { + user: { id: user.id, email: user.email, name: user.name }, + workspaces: await listMyWorkspaces(deps.database.db, user.id), + }; + }, + }), + + route({ + method: "POST", + pattern: "/accounts", + body: z.object({ accountName: name, workspaceName: name }), + async handler({ deps, session, body }) { + return createAccountWithWorkspace(deps.database.db, { userId: session!.user.id, ...body }); + }, + }), + + route({ + method: "POST", + pattern: "/accounts/:accountId/workspaces", + body: z.object({ name }), + async handler({ deps, session, params, body }) { + return createWorkspace(deps.database.db, { userId: session!.user.id, accountId: params.accountId!, name: body.name }); + }, + }), + + route({ + method: "GET", + pattern: "/workspaces/:workspaceId", + async handler({ deps, session, params }) { + const access = await requireWorkspaceAccess(deps.database.db, session!.user.id, params.workspaceId!); + return { + workspace: access.workspace, + role: access.role, + hasGitHub: access.actor.hasGitHub, + capabilities: CAPABILITIES.filter((c) => can(access.actor, c, access.settings)), + }; + }, + }), + + route({ + method: "GET", + pattern: "/workspaces/:workspaceId/members", + async handler({ deps, session, params }) { + const access = await requireWorkspaceAccess(deps.database.db, session!.user.id, params.workspaceId!); + return { members: await listMembers(deps.database.db, access) }; + }, + }), + + route({ + method: "PATCH", + pattern: "/workspaces/:workspaceId/members/:userId", + body: z.object({ role }), + async handler({ deps, session, params, body }) { + const access = await requireWorkspaceAccess(deps.database.db, session!.user.id, params.workspaceId!); + await changeMemberRole(deps.database.db, access, { userId: params.userId!, role: body.role }); + }, + }), + + route({ + method: "DELETE", + pattern: "/workspaces/:workspaceId/members/:userId", + async handler({ deps, session, params }) { + const access = await requireWorkspaceAccess(deps.database.db, session!.user.id, params.workspaceId!); + await removeMember(deps.database.db, access, params.userId!); + }, + }), + + route({ + method: "GET", + pattern: "/workspaces/:workspaceId/invites", + async handler({ deps, session, params }) { + const access = await requireWorkspaceAccess(deps.database.db, session!.user.id, params.workspaceId!); + return { invites: await listInvites(deps.database.db, access) }; + }, + }), + + route({ + method: "POST", + pattern: "/workspaces/:workspaceId/invites", + body: z.object({ email: z.string().min(1, "Email is required."), role }), + async handler({ deps, session, params, body }) { + const { db } = deps.database; + const access = await requireWorkspaceAccess(db, session!.user.id, params.workspaceId!); + const invite = await createInvite(db, access, body); + const [inviter] = await db.select({ name: users.name, email: users.email }).from(users).where(eq(users.id, access.userId)); + await deps.email.send( + inviteEmail({ + baseURL: deps.baseURL, + to: invite.email, + token: invite.token, + workspaceName: access.workspace.name, + inviterName: inviter?.name || inviter?.email || "Someone", + }), + ); + return { id: invite.inviteId, email: invite.email, expiresAt: invite.expiresAt }; + }, + }), + + route({ + method: "DELETE", + pattern: "/workspaces/:workspaceId/invites/:inviteId", + async handler({ deps, session, params }) { + const access = await requireWorkspaceAccess(deps.database.db, session!.user.id, params.workspaceId!); + await revokeInvite(deps.database.db, access, params.inviteId!); + }, + }), + + route({ + method: "GET", + pattern: "/invites/:token", + auth: false, + async handler({ deps, params }) { + return getInvitePreview(deps.database.db, params.token!); + }, + }), + + route({ + method: "POST", + pattern: "/invites/:token/accept", + async handler({ deps, session, params }) { + return acceptInvite(deps.database.db, { + token: params.token!, + userId: session!.user.id, + userEmail: session!.user.email, + }); + }, + }), +]; + +export const handleApiRequest = createApiHandler(routes); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5fd4b59..67a8c03 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -32,3 +32,5 @@ export { revokeInvite, } from "./services/invites"; export { type MemberView, changeMemberRole, listMembers, removeMember } from "./services/members"; +export { type ApiDeps, createApiHandler } from "./api/router"; +export { handleApiRequest } from "./api/routes"; diff --git a/packages/server/test/api.test.ts b/packages/server/test/api.test.ts new file mode 100644 index 0000000..04e55ab --- /dev/null +++ b/packages/server/test/api.test.ts @@ -0,0 +1,149 @@ +import type { Database } from "@gitwork/db"; +import { type TestDriver, allTestDrivers } from "@gitwork/db/testing"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import type { ApiDeps } from "../src/api/router"; +import { handleApiRequest } from "../src/api/routes"; +import { BASE_URL, signIn, testAuth } from "./helpers"; + +const drivers = await allTestDrivers(); +afterAll(() => Promise.all(drivers.map((d) => d.dispose()))); + +describe.each(drivers)("/api/v1 on $name", (driver: TestDriver) => { + let database: Database; + let ctx: ReturnType; + let deps: ApiDeps; + + function api( + method: string, + path: string, + opts: { cookie?: string; body?: unknown; origin?: string | null; bearer?: string } = {}, + ) { + const headers = new Headers(); + if (opts.cookie) headers.set("cookie", opts.cookie); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + if (opts.origin !== null) headers.set("origin", opts.origin ?? BASE_URL); + if (opts.body !== undefined) headers.set("content-type", "application/json"); + return handleApiRequest( + new Request(`${BASE_URL}/api/v1${path}`, { + method, + headers, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }), + deps, + ); + } + + /** Signs in, creates an Account + workspace, returns the owner's cookie and workspace id. */ + async function ownerWithWorkspace() { + const cookie = await signIn(ctx.auth, ctx.email, "owner@example.com"); + const res = await api("POST", "/accounts", { cookie, body: { accountName: "Acme", workspaceName: "Website" } }); + expect(res.status).toBe(200); + const { workspaceId } = (await res.json()) as { workspaceId: string }; + return { cookie, workspaceId }; + } + + beforeEach(async () => { + database = await driver.fresh(); + ctx = testAuth(database); + deps = { database, auth: ctx.auth, email: ctx.email, baseURL: BASE_URL }; + }); + + it("returns 401 without a session", async () => { + expect((await api("GET", "/me")).status).toBe(401); + expect((await api("POST", "/accounts", { body: { accountName: "A", workspaceName: "W" } })).status).toBe(401); + }); + + it("returns 404 for unknown paths and 405 for wrong methods", async () => { + const cookie = await signIn(ctx.auth, ctx.email, "a@example.com"); + expect((await api("GET", "/nope", { cookie })).status).toBe(404); + expect((await api("DELETE", "/me", { cookie })).status).toBe(405); + }); + + it("validates bodies with 400", async () => { + const cookie = await signIn(ctx.auth, ctx.email, "a@example.com"); + const res = await api("POST", "/accounts", { cookie, body: { accountName: "", workspaceName: "W" } }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("invalid"); + expect((await api("POST", "/accounts", { cookie, body: "not json" })).status).toBe(400); + }); + + it("refuses cross-site cookie writes with 403", async () => { + const cookie = await signIn(ctx.auth, ctx.email, "a@example.com"); + const body = { accountName: "A", workspaceName: "W" }; + expect((await api("POST", "/accounts", { cookie, body, origin: "https://evil.example" })).status).toBe(403); + expect((await api("POST", "/accounts", { cookie, body, origin: null })).status).toBe(403); + expect((await api("POST", "/accounts", { cookie, body })).status).toBe(200); + }); + + it("accepts a bearer token without an Origin header", async () => { + await signIn(ctx.auth, ctx.email, "mac@example.com"); + const sent = await ctx.auth.api.signInEmailOTP({ + body: { email: "mac@example.com", otp: await (async () => { + await ctx.auth.api.sendVerificationOTP({ body: { email: "mac@example.com", type: "sign-in" } }); + return /code is (\d{6})/.exec(ctx.email.messages.at(-1)!.text)![1]!; + })() }, + }); + const res = await api("POST", "/accounts", { + bearer: sent.token, + origin: null, + body: { accountName: "Mac", workspaceName: "W" }, + }); + expect(res.status).toBe(200); + }); + + it("shows the workspace with the caller's role and capabilities", async () => { + const { cookie, workspaceId } = await ownerWithWorkspace(); + const res = await api("GET", `/workspaces/${workspaceId}`, { cookie }); + const body = (await res.json()) as { role: string; capabilities: string[]; hasGitHub: boolean }; + expect(body.role).toBe("owner"); + expect(body.hasGitHub).toBe(false); + expect(body.capabilities).toContain("members.invite"); + // GitHub tasks need a linked account (decision 017). + expect(body.capabilities).not.toContain("workspace.github_settings"); + }); + + it("hides a workspace from non-members with 404", async () => { + const { workspaceId } = await ownerWithWorkspace(); + const stranger = await signIn(ctx.auth, ctx.email, "stranger@example.com"); + expect((await api("GET", `/workspaces/${workspaceId}`, { cookie: stranger })).status).toBe(404); + expect((await api("GET", `/workspaces/${workspaceId}/members`, { cookie: stranger })).status).toBe(404); + }); + + it("runs the invite flow end to end and enforces roles", async () => { + const { cookie: owner, workspaceId } = await ownerWithWorkspace(); + + const invited = await api("POST", `/workspaces/${workspaceId}/invites`, { + cookie: owner, + body: { email: "Client@Example.com", role: "read_only" }, + }); + expect(invited.status).toBe(200); + const mail = ctx.email.messages.at(-1)!; + expect(mail.to).toBe("client@example.com"); + const token = /\/invite\/([A-Za-z0-9_-]+)/.exec(mail.text)![1]!; + + const preview = (await (await api("GET", `/invites/${token}`, { origin: null })).json()) as { status: string; role: string }; + expect(preview).toMatchObject({ status: "pending", role: "read_only" }); + + const client = await signIn(ctx.auth, ctx.email, "client@example.com"); + const accepted = await api("POST", `/invites/${token}/accept`, { cookie: client }); + expect(accepted.status).toBe(200); + + const me = (await (await api("GET", "/me", { cookie: client })).json()) as { workspaces: { role: string }[] }; + expect(me.workspaces).toEqual([expect.objectContaining({ role: "read_only" })]); + + // Read Only cannot invite. + const denied = await api("POST", `/workspaces/${workspaceId}/invites`, { + cookie: client, + body: { email: "x@example.com", role: "read_only" }, + }); + expect(denied.status).toBe(403); + + // The Owner can promote the client to Member. + const members = (await (await api("GET", `/workspaces/${workspaceId}/members`, { cookie: owner })).json()) as { + members: { userId: string; email: string }[]; + }; + const clientId = members.members.find((m) => m.email === "client@example.com")!.userId; + expect((await api("PATCH", `/workspaces/${workspaceId}/members/${clientId}`, { cookie: owner, body: { role: "member" } })).status).toBe(204); + expect((await api("PATCH", `/workspaces/${workspaceId}/members/${clientId}`, { cookie: owner, body: { role: "boss" } })).status).toBe(400); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ccecf..3a97bb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: nodemailer: specifier: 10.1.1 version: 10.1.1 + zod: + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@cloudflare/workers-types': specifier: 5.20261013.1 From 3d44271a3b0fa4eac99e462c37ae447dfa587b67 Mon Sep 17 00:00:00 2001 From: Erik Goins Date: Thu, 15 Oct 2026 19:13:04 -0300 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20Phase=201=20web=20app=20=E2=80=94?= =?UTF-8?q?=20sign-in,=20onboarding,=20members,=20invites,=20GitHub=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages for email-code sign-in (code or click-to-verify link), account onboarding, workspace members and invites, invite acceptance, and GitHub linking. /api/auth and /api/v1 mount Better Auth and the @gitwork/server router per request. E2E covers the owner-invites- client flow and the link verify; smoke now signs in inside the Worker bundle. Feature doc replaces the WIP doc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GnDXTQ6MVbAXETYsq6fvqg --- CLAUDE.md | 12 +- apps/web/cloudflare-env.d.ts | 11 +- apps/web/e2e/identity.spec.ts | 70 ++++++++ apps/web/e2e/outbox.ts | 35 ++++ apps/web/e2e/smoke.spec.ts | 5 +- apps/web/next.config.ts | 16 +- apps/web/package.json | 4 +- apps/web/playwright.config.ts | 15 +- apps/web/src/app/api/auth/[...all]/route.ts | 7 + apps/web/src/app/api/v1/[...path]/route.ts | 10 ++ apps/web/src/app/api/v1/health/route.ts | 2 +- .../src/app/invite/[token]/accept-button.tsx | 33 ++++ apps/web/src/app/invite/[token]/page.tsx | 65 ++++++++ apps/web/src/app/onboarding.tsx | 50 ++++++ apps/web/src/app/page.tsx | 42 ++++- apps/web/src/app/settings/link-github.tsx | 21 +++ apps/web/src/app/settings/page.tsx | 35 ++++ apps/web/src/app/sign-in/page.tsx | 17 ++ apps/web/src/app/sign-in/sign-in-form.tsx | 81 +++++++++ apps/web/src/app/sign-in/verify/page.tsx | 13 ++ .../src/app/sign-in/verify/verify-link.tsx | 53 ++++++ .../w/[workspaceId]/members/members-panel.tsx | 154 ++++++++++++++++++ .../src/app/w/[workspaceId]/members/page.tsx | 29 ++++ apps/web/src/app/w/[workspaceId]/page.tsx | 22 +++ .../src/app/w/[workspaceId]/workspace-nav.tsx | 21 +++ apps/web/src/components/header.tsx | 19 +++ apps/web/src/components/sign-out-button.tsx | 20 +++ apps/web/src/components/ui.tsx | 71 ++++++++ apps/web/src/lib/api.ts | 19 +++ apps/web/src/lib/auth-client.ts | 5 + apps/web/src/server/context.ts | 33 ++++ apps/web/src/server/db/runtime.node.ts | 11 -- apps/web/src/server/db/runtime.workerd.ts | 9 - apps/web/src/server/pages.ts | 24 +++ apps/web/src/server/{db => runtime}/index.ts | 2 +- apps/web/src/server/runtime/runtime.node.ts | 29 ++++ .../web/src/server/runtime/runtime.workerd.ts | 23 +++ apps/web/tsconfig.json | 4 +- apps/web/wrangler.jsonc | 7 + docs/INDEX.md | 7 +- docs/STATE.md | 8 +- docs/features/identity.md | 107 ++++++++++++ docs/wip/identity.md | 117 ------------- packages/server/test/github-link.test.ts | 50 ++++++ pnpm-lock.yaml | 6 + scripts/scan-worker-bundle.sh | 7 +- scripts/smoke.sh | 29 +++- 47 files changed, 1250 insertions(+), 180 deletions(-) create mode 100644 apps/web/e2e/identity.spec.ts create mode 100644 apps/web/e2e/outbox.ts create mode 100644 apps/web/src/app/api/auth/[...all]/route.ts create mode 100644 apps/web/src/app/api/v1/[...path]/route.ts create mode 100644 apps/web/src/app/invite/[token]/accept-button.tsx create mode 100644 apps/web/src/app/invite/[token]/page.tsx create mode 100644 apps/web/src/app/onboarding.tsx create mode 100644 apps/web/src/app/settings/link-github.tsx create mode 100644 apps/web/src/app/settings/page.tsx create mode 100644 apps/web/src/app/sign-in/page.tsx create mode 100644 apps/web/src/app/sign-in/sign-in-form.tsx create mode 100644 apps/web/src/app/sign-in/verify/page.tsx create mode 100644 apps/web/src/app/sign-in/verify/verify-link.tsx create mode 100644 apps/web/src/app/w/[workspaceId]/members/members-panel.tsx create mode 100644 apps/web/src/app/w/[workspaceId]/members/page.tsx create mode 100644 apps/web/src/app/w/[workspaceId]/page.tsx create mode 100644 apps/web/src/app/w/[workspaceId]/workspace-nav.tsx create mode 100644 apps/web/src/components/header.tsx create mode 100644 apps/web/src/components/sign-out-button.tsx create mode 100644 apps/web/src/components/ui.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/auth-client.ts create mode 100644 apps/web/src/server/context.ts delete mode 100644 apps/web/src/server/db/runtime.node.ts delete mode 100644 apps/web/src/server/db/runtime.workerd.ts create mode 100644 apps/web/src/server/pages.ts rename apps/web/src/server/{db => runtime}/index.ts (55%) create mode 100644 apps/web/src/server/runtime/runtime.node.ts create mode 100644 apps/web/src/server/runtime/runtime.workerd.ts create mode 100644 docs/features/identity.md delete mode 100644 docs/wip/identity.md create mode 100644 packages/server/test/github-link.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6084838..fe8626c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,15 +36,21 @@ pnpm db:migrate # apply migrations to the local SQLite file ``` apps/web Next.js app, /api/v1 REST API, GitHub webhooks apps/mac Native macOS app (AppKit + TextKit 2) — Phase 6, not created yet -packages/core Permissions, visibility filter, GitHub sync, attribution, markdown round trip -packages/db Drizzle schema + migrations (SQLite dialect), D1 and file drivers +packages/core Pure domain logic: role matrix (permissions.ts); later visibility, attribution, markdown +packages/server DB- and auth-aware code: Better Auth factory, services, /api/v1 router, email +packages/db Drizzle schema + migrations (SQLite dialect), D1 and file drivers, test helpers docs/ INDEX.md → STATE.md → spec.md, decisions/ ``` ## Rules - Every read and write goes through the permission and visibility checks in `packages/core`. Never - return GitHub data to a client without them. + return GitHub data to a client without them. Services load access with + `requireWorkspaceAccess()` and call `requireCapability()`. +- New `/api/v1` routes go in `packages/server/src/api/routes.ts`, with tests in + `packages/server/test/api.test.ts` (success, 401, 403, 400). +- DB tests run on both drivers: `describe.each(await allTestDrivers())` from `@gitwork/db/testing`. +- Pages read the request (`getSession()`/`connection()`) before any config, or `next build` fails. - Webhook handlers verify the signature and are idempotent on the delivery ID. - The markdown editors must leave untouched lines byte-identical. The round-trip fixture suite guards this; never weaken it. diff --git a/apps/web/cloudflare-env.d.ts b/apps/web/cloudflare-env.d.ts index 15de8c9..5db0e43 100644 --- a/apps/web/cloudflare-env.d.ts +++ b/apps/web/cloudflare-env.d.ts @@ -1,9 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: fe8c79edc0ca2e1a55b87aa44ba71f28) +// Generated by Wrangler by running `wrangler types --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: 1cd12bfb9b3a0c857bfacfca3906b39f) // Runtime types generated with workerd@1.20261012.1 2026-10-01 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_CloudflareEnv { DB: D1Database; + EMAIL: SendEmail; ASSETS: Fetcher; + EMAIL_TRANSPORT: "cloudflare"; + EMAIL_FROM: "Gitwork "; WORKER_SELF_REFERENCE: Service; } declare namespace Cloudflare { @@ -13,6 +16,12 @@ declare namespace Cloudflare { interface Env extends __BaseEnv_CloudflareEnv {} } interface CloudflareEnv extends __BaseEnv_CloudflareEnv {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} declare module "*.txt" { const value: string; export default value; diff --git a/apps/web/e2e/identity.spec.ts b/apps/web/e2e/identity.spec.ts new file mode 100644 index 0000000..c5b64de --- /dev/null +++ b/apps/web/e2e/identity.spec.ts @@ -0,0 +1,70 @@ +import { type Page, expect, test } from "@playwright/test"; +import { lastEmail, signInCode } from "./outbox"; + +async function signInWithCode(page: Page, email: string) { + await page.getByLabel("Email").fill(email); + await page.getByRole("button", { name: "Email me a sign-in code" }).click(); + await page.getByLabel("Code").fill(await signInCode(email)); + await page.getByRole("button", { name: "Sign in" }).click(); +} + +test("an owner creates a workspace and a client joins it by invite as Read Only", async ({ browser }) => { + // Owner: sign in, create the account and first workspace, invite the client. + const ownerCtx = await browser.newContext(); + const owner = await ownerCtx.newPage(); + await owner.goto("/"); + await signInWithCode(owner, "owner@example.com"); + await owner.getByLabel("Account name").fill("Acme Agency"); + await owner.getByLabel("First workspace").fill("Client Website"); + await owner.getByRole("button", { name: "Create" }).click(); + await expect(owner.getByRole("heading", { name: "Client Website" })).toBeVisible(); + await expect(owner.getByText("Your role: Owner")).toBeVisible(); + + await owner.getByRole("link", { name: "Members" }).click(); + await owner.getByLabel("Email").fill("client@example.com"); + await owner.getByLabel("Role").selectOption("read_only"); + await owner.getByRole("button", { name: "Send invite" }).click(); + await expect(owner.getByTestId("invite-row")).toContainText("client@example.com"); + + // Client: open the invite link, sign in as the invited email, accept. + const [, invitePath] = await lastEmail("client@example.com", /http:\/\/localhost:\d+(\/invite\/[A-Za-z0-9_-]+)/); + const clientCtx = await browser.newContext(); + const client = await clientCtx.newPage(); + await client.goto(invitePath!); + await expect(client.getByText("You are invited to Client Website as Read Only.")).toBeVisible(); + await client.getByRole("link", { name: "Sign in as client@example.com to accept" }).click(); + await expect(client.getByLabel("Email")).toHaveValue("client@example.com"); + await signInWithCode(client, "client@example.com"); + await client.getByRole("button", { name: "Accept invite" }).click(); + await expect(client.getByRole("heading", { name: "Client Website" })).toBeVisible(); + await expect(client.getByText("Your role: Read Only")).toBeVisible(); + + // Read Only sees the members but cannot invite. + await client.getByRole("link", { name: "Members" }).click(); + await expect(client.getByTestId("member-row")).toHaveCount(2); + await expect(client.getByRole("button", { name: "Send invite" })).toHaveCount(0); + + // The owner now sees the client as a member and no pending invite. + await owner.reload(); + await expect(owner.getByTestId("member-row").filter({ hasText: "client@example.com" })).toContainText("Read Only"); + await expect(owner.getByTestId("invite-row")).toHaveCount(0); + + await ownerCtx.close(); + await clientCtx.close(); +}); + +test("the sign-in link signs in only after a click", async ({ page }) => { + await page.goto("/sign-in"); + await page.getByLabel("Email").fill("linker@example.com"); + await page.getByRole("button", { name: "Email me a sign-in code" }).click(); + const [link] = await lastEmail("linker@example.com", /http:\/\/localhost:\d+\/sign-in\/verify#\S+/); + const url = new URL(link!); + + await page.goto(url.pathname + url.hash); + // Loading the page must not sign in (link scanners load pages). + const session = await page.request.get("/api/auth/get-session"); + expect(await session.json()).toBeNull(); + + await page.getByRole("button", { name: "Continue as linker@example.com" }).click(); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); +}); diff --git a/apps/web/e2e/outbox.ts b/apps/web/e2e/outbox.ts new file mode 100644 index 0000000..55f436a --- /dev/null +++ b/apps/web/e2e/outbox.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { expect } from "@playwright/test"; +import { E2E_OUTBOX } from "../playwright.config"; + +interface Message { + to: string; + subject: string; + text: string; +} + +/** The newest email to `to` whose text matches `pattern`. Polls: sending is async. */ +export async function lastEmail(to: string, pattern: RegExp): Promise { + let found: RegExpExecArray | null = null; + await expect + .poll(() => { + let lines: string[] = []; + try { + lines = readFileSync(E2E_OUTBOX, "utf8").trim().split("\n"); + } catch { + return false; + } + const messages = lines.filter(Boolean).map((l) => JSON.parse(l) as Message); + const match = messages + .filter((m) => m.to === to) + .map((m) => pattern.exec(m.text)) + .filter((m): m is RegExpExecArray => m !== null) + .at(-1); + found = match ?? null; + return found !== null; + }) + .toBe(true); + return found!; +} + +export const signInCode = async (email: string) => (await lastEmail(email, /sign-in code is (\d{6})/))[1]!; diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index 9f27baa..640a92c 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from "@playwright/test"; -test("home page shows the product name", async ({ page }) => { +test("home redirects to sign-in when signed out", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Gitwork"); + await expect(page).toHaveURL(/\/sign-in\?next=%2F$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Sign in to Gitwork"); }); test("health endpoint reports the SQLite driver", async ({ request }) => { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 0a3df06..26476a1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,21 +1,21 @@ import type { NextConfig } from "next"; // Build-time driver split (decision 004). `pnpm cf:build` sets GITWORK_TARGET=cloudflare, so the -// Worker bundle gets only the D1 runtime; every other build gets only the libsql runtime. A runtime -// branch would let the bundler trace the native libsql module into the Worker. -const dbRuntime = +// Worker bundle gets only the D1 runtime; every other build gets only the libsql + nodemailer runtime. A runtime +// branch would let the bundler trace Node-only modules into the Worker. +const runtime = process.env.GITWORK_TARGET === "cloudflare" - ? "./src/server/db/runtime.workerd.ts" - : "./src/server/db/runtime.node.ts"; + ? "./src/server/runtime/runtime.workerd.ts" + : "./src/server/runtime/runtime.node.ts"; const nextConfig: NextConfig = { // cacheComponents stays off: under workerd its setTimeout dependency hangs every request. reactCompiler: true, - transpilePackages: ["@gitwork/core", "@gitwork/db"], - serverExternalPackages: ["@libsql/client", "libsql"], + transpilePackages: ["@gitwork/core", "@gitwork/db", "@gitwork/server"], + serverExternalPackages: ["@libsql/client", "libsql", "nodemailer"], turbopack: { resolveAlias: { - "@gitwork/db-runtime": dbRuntime, + "@gitwork/runtime": runtime, }, rules: { "*.css": { diff --git a/apps/web/package.json b/apps/web/package.json index 8403a2c..6c22a22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,13 +19,15 @@ "dependencies": { "@gitwork/core": "workspace:*", "@gitwork/db": "workspace:*", + "@gitwork/server": "workspace:*", "@opennextjs/cloudflare": "1.20.11", "better-auth": "1.7.8", "drizzle-orm": "0.45.4", "next": "16.3.8", "react": "19.3.0", "react-dom": "19.3.0", - "server-only": "^0.0.1" + "server-only": "^0.0.1", + "zod": "4.6.7" }, "devDependencies": { "@playwright/test": "1.64.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 0322043..1d7d4a3 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,20 +1,29 @@ import { defineConfig, devices } from "@playwright/test"; const PORT = 3124; +export const E2E_OUTBOX = ".data/e2e-outbox.jsonl"; -// E2E against the Node runtime. scripts/smoke.sh covers the Worker runtime. +// E2E against the Node runtime with a fresh database. scripts/smoke.sh covers the Worker runtime. export default defineConfig({ testDir: "./e2e", - fullyParallel: true, + fullyParallel: false, + workers: 1, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? "github" : "list", use: { baseURL: `http://localhost:${PORT}`, trace: "on-first-retry" }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { - command: `pnpm db:migrate && pnpm build && pnpm start --port ${PORT}`, + command: `rm -f .data/e2e.sqlite* ${E2E_OUTBOX} && pnpm db:migrate && pnpm build && pnpm start --port ${PORT}`, url: `http://localhost:${PORT}/api/v1/health`, timeout: 240_000, reuseExistingServer: false, + env: { + DATABASE_URL: "file:.data/e2e.sqlite", + EMAIL_TRANSPORT: "outbox", + EMAIL_OUTBOX_PATH: E2E_OUTBOX, + BETTER_AUTH_SECRET: "e2e-secret-e2e-secret-e2e-secret-e2e", + BETTER_AUTH_URL: `http://localhost:${PORT}`, + }, }, }); diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..1a009ba --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,7 @@ +import { getDeps } from "@/server/context"; + +// Better Auth's endpoints: email codes, sessions, GitHub linking. +const handle = (request: Request) => getDeps().auth.handler(request); + +export const GET = handle; +export const POST = handle; diff --git a/apps/web/src/app/api/v1/[...path]/route.ts b/apps/web/src/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..3843253 --- /dev/null +++ b/apps/web/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,10 @@ +import { handleApiRequest } from "@gitwork/server"; +import { getDeps } from "@/server/context"; + +// Every /api/v1 route lives in @gitwork/server's router (decision 015). +const handle = (request: Request) => handleApiRequest(request, getDeps()); + +export const GET = handle; +export const POST = handle; +export const PATCH = handle; +export const DELETE = handle; diff --git a/apps/web/src/app/api/v1/health/route.ts b/apps/web/src/app/api/v1/health/route.ts index 6110941..eab3271 100644 --- a/apps/web/src/app/api/v1/health/route.ts +++ b/apps/web/src/app/api/v1/health/route.ts @@ -1,7 +1,7 @@ import { APP_NAME } from "@gitwork/core"; import { instanceSettings } from "@gitwork/db"; import { count } from "drizzle-orm"; -import { getDatabase } from "@/server/db"; +import { getDatabase } from "@/server/runtime"; /** Liveness plus a database round trip. Reports which driver served the request. */ export async function GET() { diff --git a/apps/web/src/app/invite/[token]/accept-button.tsx b/apps/web/src/app/invite/[token]/accept-button.tsx new file mode 100644 index 0000000..e5031dc --- /dev/null +++ b/apps/web/src/app/invite/[token]/accept-button.tsx @@ -0,0 +1,33 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, ErrorText } from "@/components/ui"; +import { api } from "@/lib/api"; + +export function AcceptButton({ token }: { token: string }) { + const router = useRouter(); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + return ( +

+ + {error} +
+ ); +} diff --git a/apps/web/src/app/invite/[token]/page.tsx b/apps/web/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..bb2b3bd --- /dev/null +++ b/apps/web/src/app/invite/[token]/page.tsx @@ -0,0 +1,65 @@ +import { APP_NAME } from "@gitwork/core"; +import { getInvitePreview } from "@gitwork/server"; +import Link from "next/link"; +import { connection } from "next/server"; +import { SignOutButton } from "@/components/sign-out-button"; +import { Card, ROLE_LABEL } from "@/components/ui"; +import { getDeps, getSession } from "@/server/context"; +import { messageOf } from "@/server/pages"; +import { AcceptButton } from "./accept-button"; + +const STATUS_TEXT = { + accepted: "This invite was already used.", + revoked: "This invite was cancelled.", + expired: "This invite has expired. Ask for a new one.", +} as const; + +export default async function InvitePage({ params }: PageProps<"/invite/[token]">) { + const { token } = await params; + await connection(); + let invite; + try { + invite = await getInvitePreview(getDeps().database.db, token); + } catch (error) { + return {messageOf(error)}; + } + if (invite.status !== "pending") return {STATUS_TEXT[invite.status]}; + + const session = await getSession(); + const path = `/invite/${token}`; + return ( + +

+ You are invited to {invite.workspaceName} as {ROLE_LABEL[invite.role]}. +

+ {!session ? ( + + Sign in as {invite.email} to accept + + ) : session.user.email !== invite.email ? ( +
+

+ This invite is for {invite.email}, but you are signed in as {session.user.email}. +

+ +
+ ) : ( + + )} +
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ +

Join {APP_NAME}

+
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/onboarding.tsx b/apps/web/src/app/onboarding.tsx new file mode 100644 index 0000000..1c4354c --- /dev/null +++ b/apps/web/src/app/onboarding.tsx @@ -0,0 +1,50 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, Card, ErrorText, Input, Label } from "@/components/ui"; +import { api } from "@/lib/api"; + +export function Onboarding() { + const router = useRouter(); + const [accountName, setAccountName] = useState(""); + const [workspaceName, setWorkspaceName] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + const { workspaceId } = await api<{ workspaceId: string }>("POST", "/accounts", { accountName, workspaceName }); + router.push(`/w/${workspaceId}`); + router.refresh(); + } catch (err) { + setError((err as Error).message); + setBusy(false); + } + } + + return ( + +

Create your account

+

+ An account holds your workspaces. Each workspace is one project. +

+
+
+ + setAccountName(e.target.value)} /> +
+
+ + setWorkspaceName(e.target.value)} /> +
+ {error} + +
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f6e51de..a798648 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,10 +1,40 @@ -import { APP_NAME } from "@gitwork/core"; +import { listMyWorkspaces } from "@gitwork/server"; +import Link from "next/link"; +import { Header } from "@/components/header"; +import { ROLE_LABEL } from "@/components/ui"; +import { getDeps } from "@/server/context"; +import { requireUser } from "@/server/pages"; +import { Onboarding } from "./onboarding"; + +export default async function Home() { + const user = await requireUser("/"); + const workspaces = await listMyWorkspaces(getDeps().database.db, user.id); -export default function Home() { return ( -
-

{APP_NAME}

-

GitHub is the source of truth.

-
+ <> +
+
+ {workspaces.length === 0 ? ( + + ) : ( + <> +

Workspaces

+
    + {workspaces.map((w) => ( +
  • + + + {w.name} + {w.accountName} + + {ROLE_LABEL[w.role]} + +
  • + ))} +
+ + )} +
+ ); } diff --git a/apps/web/src/app/settings/link-github.tsx b/apps/web/src/app/settings/link-github.tsx new file mode 100644 index 0000000..1e3b38f --- /dev/null +++ b/apps/web/src/app/settings/link-github.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useState } from "react"; +import { Button, ErrorText } from "@/components/ui"; +import { authClient } from "@/lib/auth-client"; + +export function LinkGitHubButton() { + const [error, setError] = useState(""); + return ( +
+ + {error} +
+ ); +} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx new file mode 100644 index 0000000..19e96a0 --- /dev/null +++ b/apps/web/src/app/settings/page.tsx @@ -0,0 +1,35 @@ +import { hasLinkedGitHub } from "@gitwork/server"; +import { Header } from "@/components/header"; +import { Card } from "@/components/ui"; +import { getConfig, getDeps } from "@/server/context"; +import { requireUser } from "@/server/pages"; +import { LinkGitHubButton } from "./link-github"; + +export default async function SettingsPage() { + const user = await requireUser("/settings"); + const linked = await hasLinkedGitHub(getDeps().database.db, user.id); + const githubConfigured = getConfig().github !== undefined; + + return ( + <> +
+
+

Settings

+ +

GitHub

+

+ Link GitHub to install the app and connect repos and boards. When linked, your edits show under + your own GitHub name. +

+ {linked ? ( +

GitHub account linked.

+ ) : githubConfigured ? ( + + ) : ( +

GitHub linking is not set up on this instance yet.

+ )} +
+
+ + ); +} diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx new file mode 100644 index 0000000..8e00c02 --- /dev/null +++ b/apps/web/src/app/sign-in/page.tsx @@ -0,0 +1,17 @@ +import { APP_NAME } from "@gitwork/core"; +import { Suspense } from "react"; +import { Card } from "@/components/ui"; +import { SignInForm } from "./sign-in-form"; + +export default function SignInPage() { + return ( +
+ +

Sign in to {APP_NAME}

+ + + +
+
+ ); +} diff --git a/apps/web/src/app/sign-in/sign-in-form.tsx b/apps/web/src/app/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..20b1836 --- /dev/null +++ b/apps/web/src/app/sign-in/sign-in-form.tsx @@ -0,0 +1,81 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { Button, ErrorText, Input, Label } from "@/components/ui"; +import { safeNext } from "@/lib/api"; +import { authClient } from "@/lib/auth-client"; + +export function SignInForm() { + const router = useRouter(); + const params = useSearchParams(); + const next = safeNext(params.get("next")); + const [email, setEmail] = useState(params.get("email") ?? ""); + const [code, setCode] = useState(""); + const [step, setStep] = useState<"email" | "code">("email"); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function sendCode(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + const { error } = await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" }); + setBusy(false); + if (error) return setError(error.message ?? "Could not send the code."); + setStep("code"); + } + + async function verify(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + const { error } = await authClient.signIn.emailOtp({ email, otp: code.trim() }); + setBusy(false); + if (error) return setError(error.message ?? "That code did not work."); + router.push(next); + router.refresh(); + } + + if (step === "email") { + return ( +
+
+ + setEmail(e.target.value)} /> +
+ {error} + +
+ ); + } + + return ( +
+

+ We sent a 6-digit code to {email}. Enter it below, or open the link in the email. +

+
+ + setCode(e.target.value)} + /> +
+ {error} + + +
+ ); +} diff --git a/apps/web/src/app/sign-in/verify/page.tsx b/apps/web/src/app/sign-in/verify/page.tsx new file mode 100644 index 0000000..1df425b --- /dev/null +++ b/apps/web/src/app/sign-in/verify/page.tsx @@ -0,0 +1,13 @@ +import { Card } from "@/components/ui"; +import { VerifyLink } from "./verify-link"; + +export default function VerifyPage() { + return ( +
+ +

Sign in

+ +
+
+ ); +} diff --git a/apps/web/src/app/sign-in/verify/verify-link.tsx b/apps/web/src/app/sign-in/verify/verify-link.tsx new file mode 100644 index 0000000..db3229d --- /dev/null +++ b/apps/web/src/app/sign-in/verify/verify-link.tsx @@ -0,0 +1,53 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button, ErrorText } from "@/components/ui"; +import { authClient } from "@/lib/auth-client"; + +/** + * Opened from the sign-in email. The code is in the URL fragment, which never reaches the server. + * Nothing happens until the person clicks: email link scanners load pages but do not click. + */ +export function VerifyLink() { + const router = useRouter(); + const [link, setLink] = useState<{ email: string; code: string } | null>(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const email = params.get("email"); + const code = params.get("code"); + // Reading the fragment is a one-time sync from the browser URL into state. + // eslint-disable-next-line react-hooks/set-state-in-effect + if (email && code) setLink({ email, code }); + else setError("This sign-in link is incomplete. Request a new code."); + }, []); + + async function continueSignIn() { + if (!link) return; + setBusy(true); + const { error } = await authClient.signIn.emailOtp({ email: link.email, otp: link.code }); + setBusy(false); + if (error) return setError("This link has expired or was already used. Request a new code."); + history.replaceState(null, "", window.location.pathname); + router.push("/"); + router.refresh(); + } + + return ( +
+ {link && ( + + )} + {error} + {error && ( + + Back to sign in + + )} +
+ ); +} diff --git a/apps/web/src/app/w/[workspaceId]/members/members-panel.tsx b/apps/web/src/app/w/[workspaceId]/members/members-panel.tsx new file mode 100644 index 0000000..4acd1a1 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/members/members-panel.tsx @@ -0,0 +1,154 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, Card, ErrorText, Input, Label, ROLE_LABEL, Select } from "@/components/ui"; +import { api } from "@/lib/api"; + +interface Member { + userId: string; + name: string; + email: string; + role: string; + viaAccount: boolean; +} +interface Invite { + id: string; + email: string; + role: string; +} + +export function MembersPanel(props: { + workspaceId: string; + currentUserId: string; + members: Member[]; + invites: Invite[]; + /** Roles this user may grant; empty when they cannot manage members. */ + grantable: string[]; +}) { + const router = useRouter(); + const [error, setError] = useState(""); + const [email, setEmail] = useState(""); + const [role, setRole] = useState(props.grantable.includes("read_only") ? "read_only" : (props.grantable[0] ?? "")); + const base = `/workspaces/${props.workspaceId}`; + + async function run(action: () => Promise) { + setError(""); + try { + await action(); + router.refresh(); + } catch (err) { + setError((err as Error).message); + } + } + + const canManage = props.grantable.length > 0; + + return ( +
+ {error} + + {canManage && ( + +

Invite someone

+
{ + e.preventDefault(); + void run(async () => { + await api("POST", `${base}/invites`, { email, role }); + setEmail(""); + }); + }} + > +
+ + setEmail(e.target.value)} /> +
+
+ + +
+ +
+
+ )} + +
+

Members

+
    + {props.members.map((m) => { + const self = m.userId === props.currentUserId; + const editable = !m.viaAccount && !self && props.grantable.includes(m.role); + return ( +
  • + + {m.name || m.email} + {m.email} + + + {editable ? ( + + ) : ( + + {ROLE_LABEL[m.role]} + {m.viaAccount && " (account)"} + + )} + {(editable || (self && !m.viaAccount)) && ( + + )} + +
  • + ); + })} +
+
+ + {canManage && props.invites.length > 0 && ( +
+

Pending invites

+
    + {props.invites.map((i) => ( +
  • + + {i.email} · {ROLE_LABEL[i.role]} + + {props.grantable.includes(i.role) && ( + + )} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/w/[workspaceId]/members/page.tsx b/apps/web/src/app/w/[workspaceId]/members/page.tsx new file mode 100644 index 0000000..576c568 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/members/page.tsx @@ -0,0 +1,29 @@ +import { can, invitableRoles } from "@gitwork/core"; +import { listInvites, listMembers } from "@gitwork/server"; +import { Header } from "@/components/header"; +import { requireWorkspacePage } from "@/server/pages"; +import { WorkspaceNav } from "../workspace-nav"; +import { MembersPanel } from "./members-panel"; + +export default async function MembersPage({ params }: PageProps<"/w/[workspaceId]/members">) { + const { workspaceId } = await params; + const { user, access, db } = await requireWorkspacePage(workspaceId, `/w/${workspaceId}/members`); + const canInvite = can(access.actor, "members.invite", access.settings); + const [members, invites] = await Promise.all([listMembers(db, access), canInvite ? listInvites(db, access) : []]); + + return ( + <> +
+
+ + ({ id: i.id, email: i.email, role: i.role }))} + grantable={canInvite ? [...invitableRoles(access.role)] : []} + /> +
+ + ); +} diff --git a/apps/web/src/app/w/[workspaceId]/page.tsx b/apps/web/src/app/w/[workspaceId]/page.tsx new file mode 100644 index 0000000..be53f67 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/page.tsx @@ -0,0 +1,22 @@ +import { Header } from "@/components/header"; +import { Card } from "@/components/ui"; +import { requireWorkspacePage } from "@/server/pages"; +import { WorkspaceNav } from "./workspace-nav"; + +export default async function WorkspacePage({ params }: PageProps<"/w/[workspaceId]">) { + const { workspaceId } = await params; + const { user, access } = await requireWorkspacePage(workspaceId, `/w/${workspaceId}`); + return ( + <> +
+
+ + +

+ Tasks and docs arrive once this workspace is linked to a GitHub board and repos. +

+
+
+ + ); +} diff --git a/apps/web/src/app/w/[workspaceId]/workspace-nav.tsx b/apps/web/src/app/w/[workspaceId]/workspace-nav.tsx new file mode 100644 index 0000000..43ac2a2 --- /dev/null +++ b/apps/web/src/app/w/[workspaceId]/workspace-nav.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; +import { ROLE_LABEL } from "@/components/ui"; + +export function WorkspaceNav({ id, name, role }: { id: string; name: string; role: string }) { + return ( +
+
+

{name}

+

Your role: {ROLE_LABEL[role]}

+
+ +
+ ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx new file mode 100644 index 0000000..a0e7ad7 --- /dev/null +++ b/apps/web/src/components/header.tsx @@ -0,0 +1,19 @@ +import { APP_NAME } from "@gitwork/core"; +import Link from "next/link"; +import { SignOutButton } from "./sign-out-button"; + +export function Header({ email }: { email: string }) { + return ( +
+ + {APP_NAME} + +
+ + {email} + + +
+
+ ); +} diff --git a/apps/web/src/components/sign-out-button.tsx b/apps/web/src/components/sign-out-button.tsx new file mode 100644 index 0000000..4bcd9db --- /dev/null +++ b/apps/web/src/components/sign-out-button.tsx @@ -0,0 +1,20 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "./ui"; + +export function SignOutButton() { + const router = useRouter(); + return ( + + ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..334e8fb --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,71 @@ +import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; + +const cx = (...c: (string | false | undefined)[]) => c.filter(Boolean).join(" "); + +export function Button({ + variant = "primary", + className, + ...props +}: ButtonHTMLAttributes & { variant?: "primary" | "secondary" | "danger" }) { + return ( +