<!-- source: openspec/current/auth.md -->

---
title: Auth
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Magic-link authentication and middleware-enforced sessions for studio workspace. Built on Supabase Auth with RLS and role enforcement.
---

# Auth — capability spec

> Magic-link authentication + middleware-enforced sessions for the studio workspace. Built on Supabase Auth, integrated via `@rapoport-studio/{config,db}`.

---

## Where auth lives

- Source: `packages/auth/`
- Package name: `@rapoport-studio/auth`
- Version: `0.0.0`, `private: true`
- Entry surface: four subpath exports — `.` → `src/index.ts` (barrel), `./server` → `src/server.ts`, `./client` → `src/client.ts`, `./proxy` → `src/proxy.ts`
- Build: none. Consumers compile `.ts` directly via Next.js / vitest / app build.
- Scripts: `lint` (eslint src), `typecheck` (tsc --noEmit), `test` (vitest run).
- Test runtime: vitest with `environment: "node"`.

---

## Architecture

```
   ┌─ @rapoport-studio/config (env) ──┐
   │     supabase.{url, anonKey}      │
   │     studio.cookieDomain          │  ┌─► apps/studio/middleware.ts
   ▼                                  │  │   (createAuthProxy)
@rapoport-studio/auth ────────────────┼──┼─► apps/studio/.../layout.tsx, page.tsx
   ▲                                  │  │   (getSession, getUser)
   │     Database (type-only)         │  └─► apps/studio/.../(auth)/login/actions.ts,
   │                                  │      apps/studio/.../(auth-walled)/actions.ts,
   │                                  │      apps/studio/.../(auth-walled)/(default)/settings/actions.ts
   └─ @rapoport-studio/db ────────────┘     (signInWithMagicLink, signOut, signOutGlobal)

apps/studio/auth/callback/route.ts does *not* import from this package; it
calls `@supabase/ssr` directly to exchange the code for a session and bind
the resulting cookies onto its own NextResponse.
```

`config` supplies environment-derived values (Supabase URL/keys, cookie domain). `db` supplies the typed `Database` shape used at the type system boundary. `auth` combines them into a working authentication layer.

The package's public surface is split across four subpaths to keep server-only code out of client bundles. `./server` (Supabase Auth flows that depend on `next/headers`) and `./proxy` (Next.js middleware factory) are server-only by construction; `./client` is the cookie-only browser surface (currently a placeholder); the `.` barrel re-exports the safe-to-import-anywhere subset. The subpath name is the server-only-ness signal in code review.

---

## Modules

| Module | Path | Surface (exports) | Internal deps | Used by |
|---|---|---|---|---|
| index | `src/index.ts` | barrel: `AuthUser` type + `getSession`, `getUser`, `signOut`, `signOutGlobal`, `signInWithMagicLink`, `createAuthProxy` | (re-exports from `./types`, `./server`, `./proxy`) | `apps/studio/src/app/(auth-walled)/{layout.tsx,actions.ts}, (auth-walled)/(default)/{page.tsx,settings/{page.tsx,actions.ts,profile-card.tsx}}` |
| types | `src/types.ts` | `AuthUser` | (none) | `src/server.ts:2` |
| supabase | `src/supabase.ts` | `createProxyClient(req, res)`, `createAuthServerClient()` | `@rapoport-studio/config` (`studio`, `supabase`), `@rapoport-studio/db` (`Database` type-only) | `src/server.ts:1`, `src/proxy.ts:3` |
| server | `src/server.ts` | `getSession`, `getUser`, `signInWithMagicLink`, `signOut`, `signOutGlobal` | `./supabase`, `./types` | `apps/studio/src/app/(auth)/login/actions.ts`, `apps/studio/src/app/(auth-walled)/{layout.tsx,actions.ts,(default)/{page.tsx,canvases/page.tsx,settings/{page.tsx,actions.ts}},(canvas)/spec/[id]/{page.tsx,turn/route.ts}}` |
| client | `src/client.ts` | (placeholder; `"use client"` only) | (none) | — |
| proxy | `src/proxy.ts` | `createAuthProxy(config?)` | `./supabase` | `apps/studio/src/middleware.ts:1` |

`apps/web` has **zero direct imports** of `@rapoport-studio/auth`. The marketing site is fully public — matches `platform.md`'s domain split (`rapoport.studio` is public marketing, `app.rapoport.studio` is auth-walled workspace).

---

## Authentication flow

### Magic-link sign-in (token-hash flow)

The flow crosses the `auth` ↔ `apps/studio` boundary at every step. Each step is annotated with the file that owns it.

1. `[apps/studio]` `src/app/(auth)/login/page.tsx` — server component renders `<LoginForm />` with optional error from `?error=` searchParam (`no_access`, `auth_failed`, `session_expired`).
2. `[apps/studio]` `src/app/(auth)/login/login-form.tsx` — client form submits to the `sendMagicLink` server action.
3. `[apps/studio]` `src/app/(auth)/login/actions.ts:7-30` — `sendMagicLink` reads `NEXT_PUBLIC_SITE_URL` (fallback `http://localhost:3001`) and calls `signInWithMagicLink(email, ${siteUrl}/auth/confirm)`.
4. `[packages/auth]` `src/server.ts:63-72` — `signInWithMagicLink` builds a cookie-bound Supabase server client and calls `supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: redirectUrl } })`. Returns `{ data, error }`.
5. (Out-of-package: Supabase emails the link to the user. The Supabase Dashboard email template uses the `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type={{ .EmailActionType }}&next={{ .RedirectTo }}` URL shape — see § Magic-link cross-device fix below for why.)
6. User clicks the link → lands on `${siteUrl}/auth/confirm?token_hash=...&type=...&next=...`. The link is **device-portable**: opening it in a different browser than the one that requested it succeeds (no PKCE cookie required).
7. `[apps/studio]` `src/app/auth/confirm/route.ts` — GET handler reads `token_hash`, `type`, `next` from `searchParams`. Type-whitelists `type` against `{ magiclink, signup, recovery, invite, email_change }` and short-circuits to `auth_failed` if the value is missing or unknown. Builds a Supabase server client via `@supabase/ssr` (`createServerClient`) with the same cookie-binding pattern as `/auth/callback`, then calls `supabase.auth.verifyOtp({ token_hash, type })`. Session-setting cookies are bound onto a pre-constructed `NextResponse.redirect(...)` so they propagate under OpenNext/Cloudflare (cookies written via `next/headers` do not). The confirm route does **not** import from `@rapoport-studio/auth` — `verifyOtp` is owned by the consumer because the cookie-binding shape is consumer-specific (mirrors `/auth/callback`).
8. `[apps/studio]` `src/app/auth/confirm/route.ts` and `src/app/auth/callback/route.ts` share post-auth dispatch via `src/lib/auth-dispatch.ts` (`dispatchAuthenticatedUser`). On success the dispatcher additionally accepts any pending `canvas_invitees` row matching the user's email (service-role admin client) and looks up the most-recent accepted invitation; redirects to `/spec/<id>` if any, else to `${origin}${next}` (default `/`) when `platform_owner` is true. On error or no access: `${origin}/login?error={auth_failed,no_access}`.
9. `[apps/studio]` `src/middleware.ts` — every subsequent request hits `createAuthProxy({ publicRoutes, loginUrl })`. Each request flows through `[packages/auth]` `src/proxy.ts`:
    - Constructs `createProxyClient(request, response)` (`packages/auth/src/supabase.ts`)
    - Calls `supabase.auth.getUser()` — reads/refreshes the session cookie
    - Unauthenticated and route is non-public → 302 to `/login?next=<path>`
    - Authenticated, route is non-public, **no `platform_owner` flag** in `app_metadata` AND no accepted `canvas_invitees` row → `signOut()` + 302 to `/login?error=no_access` (`proxy.ts:41-58`)
    - Authenticated and on `/login` → 302 to `/` with `?error=*` stripped (loop guard)
10. `[apps/studio]` Server components retrieve session/user directly:
    - `src/app/(auth-walled)/layout.tsx` — `getSession()` for the email; `getUser()` for full `AuthUser` (display name, avatar, `platform_owner`, `app_metadata`).
    - `src/app/(auth-walled)/(default)/page.tsx` — `getUser()` for the workspace landing.
    - `src/app/(auth-walled)/(canvas)/spec/[id]/page.tsx`, `.../turn/route.ts` — `getUser()` for canvas owner/membership checks.

### OAuth sign-in (PKCE flow)

OAuth providers (e.g., Google, GitHub) use the older PKCE handshake against `/auth/callback`. As of 2026-05-10 the studio does not have any OAuth provider live — the route is retained for the connector roadmap (Linear OAuth via `/api/integrations/linear/callback` is a separate per-user binding, not sign-in).

1. Provider redirects user to `${siteUrl}/auth/callback?code=...&next=...`.
2. `[apps/studio]` `src/app/auth/callback/route.ts` — GET handler reads `code` and `next`, builds a Supabase server client via `@supabase/ssr`, and calls `supabase.auth.exchangeCodeForSession(code)`. The PKCE verifier cookie set during the OAuth initiation must be present on the same browser; opening the redirect URL on a different device fails with `bad_code_verifier`.
3. Post-auth dispatch goes through the same `dispatchAuthenticatedUser` helper as the magic-link flow.

### Sign-out

`[apps/studio]` `src/app/(auth-walled)/actions.ts` — `signOutAction` server action calls `signOut()` then `redirect("/login")`. `[packages/auth]` `src/server.ts` (`signOut`) builds a cookie-bound client and calls `supabase.auth.signOut()`, clearing the session cookie. The settings page also exposes `signOutGlobal` via `(auth-walled)/(default)/settings/actions.ts`, which calls `supabase.auth.signOut({ scope: "global" })` to revoke every session for the user across devices.

### Session retrieval

Server components and server actions get the current user via `getSession()` or `getUser()`, both in `src/server.ts`. Each call constructs a fresh server client via `createAuthServerClient()` (`src/supabase.ts:35-51`), which dynamically imports `next/headers` and adapts `cookies()` to Supabase's cookie reader/writer interface. `getSession()` returns the raw Supabase session; `getUser()` calls `supabase.auth.getUser()` and synthesizes an `AuthUser { id, email, display_name, avatar_url, platform_owner, app_metadata }` from the user's `user_metadata` (display name from `full_name` or `name`; avatar from `avatar_url` or `picture`) and `app_metadata` (`platform_owner` boolean flag, plus the raw `app_metadata` blob so callers can read app-scoped flags without coupling this package to consumer-specific enums). There is **no `db.profiles` lookup** — the database has no `public.profiles` table by design (see `db/narrative.md`); user display fields live in `auth.users.user_metadata` / `app_metadata`. Returns `null` on any error or absent user.

The `next/headers` import is **dynamic** (`src/supabase.ts:36`: `await import("next/headers")`) — important because `next/headers` is server-only; static import would break any consumer that statically references the file from a non-Next.js context (e.g., the proxy test that mocks `../supabase`).

---

## Authorization

### Invitation-based access (`platform_owner` + `canvas_invitees`)

> Replaces the `STUDIO_ALLOWED_EMAILS` env-based allowlist. Last touched by change `add-canvas-invitations` (archived 2026-05-02). See `platform.md § Invitation-driven access` for the high-level model.

What actually happens (`packages/auth/src/proxy.ts`):

- `supabase.auth.getUser()` returns the authenticated user; the JWT-decoded `app_metadata` (server-side mirror of `raw_app_meta_data`) is read for `platform_owner`.
- **Short-circuit on `platform_owner === true`** — request continues with no DB roundtrip on the hot path.
- Otherwise, the proxy queries `canvas_invitees` for any row matching `invitee_user_id = auth.uid() AND accepted_at IS NOT NULL AND revoked_at IS NULL` (single roundtrip, indexed on `invitee_user_id`).
- If neither check passes, `supabase.auth.signOut()` + `NextResponse.redirect(/login?error=no_access)`.
- Public routes (`publicRoutes` in `AuthProxyConfig`) skip the entire access check, including the membership query.

Post-Phase-6 the proxy is **product-aware** rather than mechanism-agnostic: the `platform_owner` flag and the `canvas_invitees` table name are baked into `proxy.ts`. This is a deliberate trade-off — the access model is core to the studio product, so the proxy package owns it directly rather than accepting a generic predicate function.

Tests at `__tests__/proxy.test.ts` cover: passes through when user has `platform_owner` flag (no DB query); signs out + redirects with `error=no_access` when user has no `platform_owner` and no invitations; passes through when invitee has accepted a canvas invitation; public routes skip both checks.

### Role-based gating

The package does **not** ship a role-based gate today. There is no `Role` enum, no `Profile` type, no `requireAdmin()` function. An earlier iteration pre-positioned `requireAdmin()` + `Role = "admin" | "user"` as scaffolding; both were removed because no caller materialized and the database has no `profiles` table to back a role column (see `db/narrative.md`). The platform-wide gate is the `platform_owner` boolean on `app_metadata` checked by the proxy; per-canvas authorization is the `canvas_invitees` row check (also in the proxy). Anything more granular would land as a new design (see § Open questions).

---

## Middleware proxy (`createAuthProxy`)

Factory at `src/proxy.ts:11`. Returns an async `proxy(request: NextRequest)` function that apps wire into Next.js middleware.

`AuthProxyConfig` shape (`src/proxy.ts:5-9`):

| Field | Type | Default | Purpose |
|---|---|---|---|
| `publicRoutes` | `string[]` | `["/login", "/auth/callback"]` | Paths that skip the auth check entirely (also skip the membership check) |
| `loginUrl` | `string` | `"/login"` | Where to redirect unauthenticated requests and rejected non-members |

Per-request lifecycle (`proxy.ts:18-63`):

1. Construct `NextResponse.next({ request })` as the default response (cookies bind to it).
2. Call `createProxyClient(request, response)` (`src/supabase.ts:16-33`) — Supabase server client whose cookie adapter reads from `request.cookies` and writes to both `request.cookies` and `response.cookies`.
3. Call `supabase.auth.getUser()` to read/refresh the session.
4. Match `pathname` against `publicRoutes` (exact match or `route + "/"` prefix).
5. Branch on (auth × public × membership) per Authentication flow step 9.

Apps wire it via standard Next.js `middleware.ts` export. `apps/studio/src/middleware.ts:14-23` does this with all three fields populated. The matcher at `apps/studio/src/middleware.ts:25-29` excludes Next.js static assets (`_next/static`, `_next/image`, `favicon.ico`, `sitemap.xml`, `robots.txt`).

---

## Cookie domain handling

The session cookie's `domain` attribute is parameterized via the `studio.cookieDomain` accessor in `@rapoport-studio/config`. The accessor reads `STUDIO_COOKIE_DOMAIN` env var, defaulting to `.rapoport.studio` (`packages/config/src/studio.ts:4`).

This parameterization enables cross-subdomain SSO: a session cookie set with `Domain=.rapoport.studio` is sent on requests to `app.rapoport.studio` and any other subdomain that needs to share auth. Override via `STUDIO_COOKIE_DOMAIN` env var in Infisical or local `.env`.

**Production-only application.** The full `domain:` field is conditional (`src/supabase.ts:11-13`):

```ts
...(process.env.NODE_ENV === "production"
  ? { domain: studio.cookieDomain }
  : {}),
```

In dev/test runs, the cookie has no `Domain=` attribute (browser defaults to the request host). This avoids cross-origin cookie issues in local development where the studio runs on `localhost:3001`.

**Other cookie options** (single source: `src/supabase.ts:6-14`):

| Option | Value |
|---|---|
| `path` | `"/"` |
| `sameSite` | `"lax"` |
| `httpOnly` | `true` |
| `secure` | `true` in production, `false` otherwise |
| `domain` | `studio.cookieDomain` in production only |

**Test coverage.** Indirect:
- `__tests__/proxy.test.ts:26` uses `https://app.rapoport.studio` as the request base.
- `__tests__/smoke.test.ts:19-22` mocks `config.studio` with `cookieDomain: ".rapoport.studio"`, exercising the import surface.

There is no test that directly asserts the `Domain=` attribute on a `Set-Cookie` header. See Open questions.

---

## Conventions

### Server-only via `next/headers` dynamic import

`src/supabase.ts:36` does `const { cookies } = await import("next/headers")` rather than a static top-level import. This keeps the file importable from non-Next.js contexts (e.g., the proxy test mocks `../supabase` and never executes `createAuthServerClient`). All flows in `src/server.ts` go through `createAuthServerClient()`, so they inherit the server-only-via-dynamic-import behavior.

### Type-only `Database` import

Both `src/server.ts:1` and `src/supabase.ts:3` use `import type { Database }` from `@rapoport-studio/db`. No runtime values are imported from `db`. Comparable to `db/modules.md`'s "type-only consumer" note: `auth` reaches `db` only at the type system boundary today; the runtime `db` and `createAdminClient` exports are not consumed.

### Subpath exports for server/proxy isolation

The package exposes four subpaths (`.`, `./server`, `./client`, `./proxy`) instead of a single `.`. Consumers that only need `createAuthProxy` import from `@rapoport-studio/auth/proxy` (e.g., `apps/studio/src/middleware.ts:1`); consumers that only need `signInWithMagicLink` import from `@rapoport-studio/auth/server`. The `.` barrel re-exports both. This is a deliberate signal: the subpath name indicates server-only-ness in code review.

### `cookies()` is awaited

`src/supabase.ts:37` does `const cookieStore = await cookies()`. Next.js 15+ made `cookies()` async; the dynamic import + await pair is the correct pattern.

### Error handling pattern

- `signInWithMagicLink`, `signOut`, `signOutGlobal` return the raw Supabase `{ data, error }` tuple from the underlying `supabase.auth.*` call. Consumers branch on `error`.
- `getSession` returns the session (or `null`) without surfacing the error.
- `getUser` returns `null` on any failure (auth error, missing user, or any synthesis problem).
- No function throws. Callers that expect an authenticated user perform their own null check after `getUser()` and decide whether to redirect, render an empty state, or fail their own action.

### `"use client"` placeholder

`src/client.ts` is currently 5 lines: a `"use client"` directive and a comment ("MVP: No client-side auth state needed. Studio pages are server-rendered; session comes from cookies. Future: useSession() hook, AuthProvider."). The subpath export exists today so consumers can begin to import from `@rapoport-studio/auth/client`; the implementation is deferred to whenever browser-side auth state first becomes necessary.

### File naming

Single file per concern (`types.ts`, `supabase.ts`, `server.ts`, `client.ts`, `proxy.ts`). No subdirectories under `src/` except `__tests__/`.

---

## Dependencies

### Runtime

| Package | Version | Used by |
|---|---|---|
| `@supabase/ssr` | `^0.10.0` | `src/supabase.ts` (`createServerClient`, `CookieOptions`) — Next.js cookie-aware Supabase wrapper |
| `@supabase/supabase-js` | `^2` | declared; transitive companion to `@supabase/ssr` (no direct import in src) |

### Internal

| Package | Range | Used by |
|---|---|---|
| `@rapoport-studio/config` | `workspace:*` | `src/supabase.ts:2` (`studio`, `supabase` accessors) |
| `@rapoport-studio/db` | `workspace:*` | `src/supabase.ts:3`, `src/server.ts:1` (`Database` type-only) |

### Dev

| Package | Version | Used by |
|---|---|---|
| `@types/node` | `^20` | type checking |
| `next` | `16.2.1` | `NextRequest`/`NextResponse`/`next/headers`/`next/server` types |
| `typescript` | `^5` | `tsc --noEmit` |
| `vitest` | `^4` | `__tests__/{smoke,proxy}.test.ts` |

No peer dependencies declared.

---

## Boundaries

Enforced by `eslint.config.mjs:109-124`. Auth may import only `@rapoport-studio/{config, db}`.

- Auth **MAY** import from `@rapoport-studio/{config, db}`
- Auth **MAY NOT** import from `@rapoport-studio/{ui, i18n, forge, canvas, muse}` or any app
- Auth **MAY NOT** contain UI components (those go in `@rapoport-studio/ui`)
- Auth **MAY NOT** contain business logic beyond authentication/authorization (route handlers, intake processing, etc. live in `apps/studio`)
- Verified consumers: `apps/studio` (6 sites + middleware). `apps/web` has zero direct imports.

---

## Test coverage

`packages/auth/src/__tests__/` — 8 tests across 2 files, vitest, `environment: "node"`. Both `next/server` and `@supabase/ssr` are mocked at module scope; `../supabase` is mocked in `proxy.test.ts` so `createAuthServerClient`'s dynamic `next/headers` import never runs.

| Test (file:line) | Asserts |
|---|---|
| `smoke.test.ts:28` | `createAuthProxy` is a function (factory shape) |
| `smoke.test.ts:33` | `createAuthProxy()` returns an async function (the proxy itself) |
| `smoke.test.ts:39` | `../types` module is importable |
| `proxy.test.ts:81` | User with `platform_owner: true` in `app_metadata` passes through with no DB query |
| `proxy.test.ts:120` | User with no `platform_owner` and no `canvas_invitees` rows → signOut + redirect to `/login?error=no_access` |
| `proxy.test.ts:147` | Invitee with accepted `canvas_invitees` row passes through (DB query returns rows) |
| `proxy.test.ts:160` | Public routes skip both `platform_owner` check and `canvas_invitees` query (no signOut, no redirect) |
| `proxy.test.ts` (loop guard) | Authenticated user on `/login` redirects to `/` with `?error=*` stripped (no loop) |

Cookie-domain coverage is indirect (production-shape cookieDomain mock + production-host request base). No direct `Set-Cookie` `Domain=` assertion exists. No integration test against a real Supabase instance. No test of `signInWithMagicLink`, `signOut`, `signOutGlobal`, `getSession`, or `getUser` server flows — all rely on the `@supabase/ssr` mock or the `@supabase/supabase-js` runtime.

---

## Extension

### Adding a new sign-in flow (e.g., OAuth, SSO)

1. Add a new server-side function in `src/server.ts` following the `signInWithMagicLink` pattern: build a server client via `createAuthServerClient()`, call the relevant `supabase.auth.*` method, return `{ data, error }`.
2. Re-export from `src/index.ts` (and consider whether the new flow belongs only on the `./server` subpath or also in the main barrel).
3. Add tests in `src/__tests__/`. Mock `@supabase/ssr` at module scope; assert the call arguments.
4. Update the consumer site in `apps/studio` (server action invoking the new function, plus any required UI route).
5. Update this spec's Authentication flow section with the new flow's trace.

### Adding a new authorization rule (beyond `platform_owner` + `canvas_invitees`)

For role-based: there is no `Role`/`Profile`/`requireAdmin` scaffolding today. Decide whether the rule belongs in the package (generic, role-shaped, would need a backing data source — `auth.users.app_metadata` flags, a new `db` table, or a JWT claim) or in a route/action helper in `apps/studio` (caller-specific). Default is the latter — keep auth's package surface narrow. If the rule lives in auth, add a `Role` union type in `src/types.ts` and a `require<Role>()` function in `src/server.ts` that calls `getUser()` and asserts the role; export from the barrel and the `./server` subpath.

For middleware-level: extend `AuthProxyConfig` in `src/proxy.ts:5-9` and the corresponding branch in the request lifecycle. Tests in `__tests__/proxy.test.ts` should add a fixture for the new case.

For more sophisticated authorization (claim-based, scope-based, runtime database-driven): would need design work — flag in a new spec change.

### Updating cookie behavior

Single source of truth: `src/supabase.ts:6-14` (`COOKIE_OPTIONS`). Changes here apply to both `createProxyClient` and `createAuthServerClient`. After changing, update the Cookie domain handling table in this spec and add or update the relevant test.

### Adding a new public route

In the consumer (`apps/studio/src/middleware.ts:14-22`), append the path to `publicRoutes`. The package itself doesn't need a change — `createAuthProxy` already accepts the list as a config argument.

---

## Open questions

- **Direct `Set-Cookie` `Domain=` assertion in tests.** Cookie-domain coverage today is indirect (production-shape mock + production-host request base). Worth adding a focused test that intercepts the `Set-Cookie` header and asserts the `Domain=` attribute matches `studio.cookieDomain` in a production-shape mock?
- **Multi-tenant auth.** If the studio adopts per-client workspaces, does that surface as a flag in `auth.users.app_metadata`, a separate `memberships` table in `db`, or a per-tenant Supabase project? Not addressed today; the current model is single-tenant with `platform_owner` (boolean) and `canvas_invitees` (per-canvas membership) as the two access primitives.
- **JWT-cached project role projection.** The proxy hot-path adds one DB roundtrip (the `project_members` query in step 3) for every non-`platform_owner` request. A `app_metadata.project_role_cache` JWT projection (mapping project slug → member role) would eliminate this roundtrip. Deferred pending proxy P95 latency measurement on the preview worker. If degradation exceeds 10ms P95 after `redesign-canvas-permission-model` ships, open `add-project-role-jwt-cache` and implement the projection. See `db/entities.md § Project members + role hierarchy` for the deferred design note.
- **Session refresh strategy on long-lived sessions.** Cookie TTL is governed by Supabase; the package doesn't currently configure refresh behavior or surface it to consumers. If long-running studio sessions become a friction point, this needs an option.
- **OAuth providers.** Per-user **integration** OAuth (Linear) is shipped — see `archive/2026-05-10-add-linear-oauth-connection/` and `## Per-user OAuth connections` in `platform.md`. **Sign-in OAuth** (Google Workspace login for the studio team) remains open; would extend `signInWith*` surface and require a callback handler change. No proposal yet.
- **`apps/web` consuming auth.** Marketing site has zero auth imports. Should `/contact` form gate-check against spam (e.g., require sign-in for repeat submissions) or stay fully public? Today it's fully public.
- **DPIA when first EU resident enterprise client lands.** Current processing is single-founder + invited clients, below DPIA threshold under GDPR Art. 35. When the first EU-resident enterprise client engages, a formal DPIA is required. Open until that threshold is crossed — flag in the engagement intake checklist at that point.
- **ANCD notification of new processor pending.** Moldova Law 133/2011 Art. 22 requires notification to ANCD when a new data processor is engaged. Any new sub-processor (e.g., new Cloudflare product, new Anthropic service tier) requires a notification update. Track per-processor addition; see `archive/2026-05-13-validate-personal-data-security/` GDPR+MD-133 compliance matrix § Gap rows.

---

## Related capabilities

- `studio-workspace/README.md` — consumer (describes the workspace product)
- `platform.md` — overall context
- `config.md` — env source for `studio.cookieDomain`
- `db/README.md` — `Database` type used at the type-only boundary (`@supabase/ssr` clients are typed against it). Note `db/narrative.md` documents that no `public.profiles` table exists by design — display fields live in `auth.users.user_metadata`.

---

## Community member tier (applied 2026-05-06 from change `add-studio-self-serve-signup`)

Adds a third access tier alongside `platform_owner` and `canvas_invitees`: **`community_member`**. Self-registered users with bounded access to a fixed allowlist of `/community*` routes; not internal staff, not invited to a specific canvas.

### Mechanism — hybrid (flag + table)

| Concern | Source of truth |
|---|---|
| Hot-path access decision (proxy) | `auth.users.app_metadata.community_member: boolean` |
| Profile data (joined date, discipline, opt-ins) | `public.community_members` row keyed on `user_id = auth.uid()` |

The boolean is the proxy's source of truth — same JWT-decoded shape as `platform_owner`, no DB roundtrip on the hot path. The DB row carries everything else and is materialized by trigger so drift is structurally impossible (modulo manual admin DB writes). See `db/entities.md § Community member tier` for the table schema and triggers.

The hybrid pattern resolves the historical "Role layer beyond `platform_owner`" open question (now removed from § Open questions).

### Sync triggers — DB-side, not application-layer

Two triggers on `auth.users` keep the JWT flag and the DB row in sync atomically:

1. **`bootstrap_community_member`** — `BEFORE INSERT`. Reads `NEW.raw_user_meta_data ->> 'signup_intent'`; if `'community'`, mutates `NEW.raw_app_meta_data` to add `community_member: true`. Mirrors `bootstrap_platform_owner`. Critical property: the mutation happens *before* commit, so the JWT signed at `exchangeCodeForSession` already carries the flag — no first-request staleness, proxy hot-path passes from the very first request.
2. **`sync_community_member_row`** — `AFTER INSERT` and `AFTER UPDATE OF raw_app_meta_data`. If the flag is present, `INSERT INTO public.community_members (user_id) ON CONFLICT DO NOTHING`. Materializes the profile row whenever the flag flips on (signup, or service-role admin promotion later).

Both triggers are `SECURITY DEFINER` with `search_path` locked; `REVOKE ALL ... FROM PUBLIC` on each function. Migration: `supabase/migrations/20260506080000_add_community_member_tier.sql`.

The application-side `/auth/callback` does **not** mutate `app_metadata` or write to `community_members` — both are handled by the triggers above. The application contract for signup is one line: `signInWithMagicLink(email, "${siteUrl}/auth/callback?next=/community", { data: { signup_intent: "community" } })`.

### Magic-link signup flow (`/community/signup`)

Extends the magic-link flow from § Authentication flow with a community signup branch:

1. `[apps/studio]` `src/app/(public)/community/signup/page.tsx` — public route, magic-link form. Listed in `publicRoutes` in `apps/studio/src/middleware.ts`.
2. `[apps/studio]` `src/app/(public)/community/signup/actions.ts` — `sendCommunityMagicLink` server action. Calls `signInWithMagicLink(email, "${siteUrl}/auth/callback?next=/community", { data: { signup_intent: "community" } })`.
3. `[apps/studio]` `src/app/auth/callback/route.ts` — gains a community-member dispatch branch. After existing `canvas_invitees` and `platform_owner` checks, when the JWT carries `community_member: true` and `next` is a `/community*` path, redirect to `${origin}${next}` (default `/community`). The route does **not** write metadata or DB rows — both are owned by the DB triggers above.

### Proxy extension — `communityRoutes`

`AuthProxyConfig` (`packages/auth/src/proxy.ts`) gains a third optional field:

| Field | Type | Default | Purpose |
|---|---|---|---|
| `publicRoutes` | `string[]` | `["/login", "/auth/callback"]` | Paths that skip the auth check entirely |
| `loginUrl` | `string` | `"/login"` | Where to redirect unauthenticated requests and rejected non-members |
| `communityRoutes` | `string[]?` | `undefined` | Paths that pass when `app_metadata.community_member === true` |

Per-request lifecycle gains a third pass-through condition between the existing `platform_owner` short-circuit and the `canvas_invitees` query:

1. Short-circuit on `platform_owner === true` → pass.
2. **(new)** If `community_member === true` AND `pathname` matches an entry in `communityRoutes` → pass with no DB roundtrip.
3. Otherwise query `canvas_invitees` for an accepted, non-revoked row.
4. If none of the three pass, `signOut()` + redirect to `loginUrl?error=no_access`.

The default `communityRoutes` value passed by `apps/studio/src/middleware.ts` is `["/community", "/community/profile", "/community/apply"]`. A community member who hits any other route is treated as no-access — same as today's non-`platform_owner` non-invitee user.

The community route set is bounded on purpose: keeping the allowlist explicit (rather than gating by path prefix) means new community surfaces require an audit at the middleware level before they ship.

### Orthogonality

`community_member` is independent from the other two tiers. A user can simultaneously hold `platform_owner: true` (internal staff who happens to also be a community member) or have a `canvas_invitees` row (community member invited to a specific client canvas). The proxy evaluates the three checks in `platform_owner → community_member → canvas_invitees` order; the first pass wins.

### Tests

`packages/auth/src/__tests__/proxy.test.ts` adds four cases for the new branch:

| Case | Result |
|---|---|
| `community_member: true` on `/community` | pass (no DB query) |
| `community_member: true` on `/spec/<id>` (no invite) | signOut + redirect to `/login?error=no_access` |
| `community_member: false` on `/community` | signOut + redirect to `/login?error=no_access` |
| Public route still skips both checks | pass (no signOut, no redirect) |

---

## Magic-link cross-device fix (applied 2026-05-10 from change `fix-magic-link-cross-device`)

**Why the route split.** Supabase's PKCE flow (`exchangeCodeForSession`) requires the `code-verifier` cookie set during link request to be present at link click. Magic links are routinely opened in a different browser than the one that requested them — phone email clients, in-app webviews, copy-paste to a different device. PKCE has no answer for that, and the user lands on `/login?error=auth_failed`. The token-hash flow (`verifyOtp({ token_hash, type })`) is stateless server-side: any browser holding the link can complete sign-in.

**Email template contract.** Supabase Dashboard email templates (Magic Link, Confirm signup, Invite user) emit the `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type={{ .EmailActionType }}&next={{ .RedirectTo }}` URL shape. The flip is a manual Dashboard step, not in-repo configuration. Re-initialising the Supabase project requires re-applying the templates — see `archive/2026-05-10-fix-magic-link-cross-device/tasks.md § 4` for the runbook.

**Shared dispatch helper.** Both `/auth/confirm` (magic-link) and `/auth/callback` (OAuth) call `dispatchAuthenticatedUser` from `apps/studio/src/lib/auth-dispatch.ts` after the session is established. The dispatcher owns post-auth invitation acceptance, `canvas_invitees` lookup, and the redirect target resolution. This avoids duplicating the dispatch shape across two routes.

**Closed open question.** Cross-browser magic-link sign-in is no longer in `§ Open questions` — `/auth/confirm` resolves it definitively. The remaining open item is sign-in OAuth (Google/GitHub) which uses the PKCE path retained at `/auth/callback`.

**Authoritative source.** `archive/2026-05-10-fix-magic-link-cross-device/{proposal,design,tasks}.md`.

## Specialist access tier (applied 2026-05-13 from change `add-specialist-onboarding`)

A `specialist` is a `community_member` with an additional row in the `specialists` Supabase table. Promotion does not change the `community_member` flag — it adds state.

### Tier model

| Tier | Mechanism | Access shape |
|---|---|---|
| `platform_owner` | `app_metadata.platform_owner = true` | Full studio access; no DB roundtrip in proxy |
| `community_member` | `app_metadata.community_member = true` | Community surface (`/community/*`); cannot enter canvas without invite |
| Specialist | `community_member = true` **+** row in `public.specialists` | Same routes as community_member; additional profile surface at `/profile/specialist`; invitation modal shows `certification_level` badge when invited to a canvas |
| Canvas invitee | `canvas_invitees` row with `accepted_at IS NOT NULL` and `revoked_at IS NULL` | Per-canvas access; evaluated last by proxy |

### Specialist identity in practice

- The proxy does **not** add a `specialist` fast-path. Specialist route access flows through the `community_member` branch; the specialist-specific routes (`/profile/specialist`, inbox surfaces) enforce the `specialists` row presence at the server-action layer, not at the proxy.
- `nda_status`, `certification_level`, and state-machine transitions live in `public.specialists` and `public.specialist_applications`. RLS: specialist reads/writes own `specialists` row; `platform_owner` reads all rows; no public read.
- Canvas invitation gate: `inviteToCanvasAction` (per `add-canvas-invitations`) is unchanged. The invitation modal performs a debounced email lookup and surfaces the invitee's `certification_level` badge when a `specialists` row is found — UX-only, no hard gate.

Authoritative schema: `openspec/current/db/entities.md § Specialist tables`.

## Project member tier (applied 2026-05-15 from change `redesign-canvas-permission-model`)

Project-level membership is the primary canvas-access mechanism replacing per-canvas invitations as the hot path for team members. Canvas invitees (direct per-canvas invitations) remain as a preserved legacy path (Branch 3 in the proxy and RLS).

### Tier model (complete, post-Phase-6)

| Tier | Mechanism | Proxy step | Access shape |
|---|---|---|---|
| `platform_owner` | `app_metadata.platform_owner = true` | 1 — short-circuit; no DB query | Full studio access |
| `community_member` (community routes) | `app_metadata.community_member = true` | 2 — no DB query on matching routes | Community surface (`/community/*`); cannot enter canvas without project membership or direct invite |
| Project member | `project_members` row with `accepted_at IS NOT NULL AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())` | 3 — single indexed DB query | Per-project canvas access, cascading to all project canvases (minus canvas-level exclusions via `canvas_excluded_members`) |
| Canvas invitee | `canvas_invitees` row with `accepted_at IS NOT NULL AND revoked_at IS NULL` | 4 — preserved legacy path | Per-canvas access for users not in `project_members`; orphan canvases without a project link rely exclusively on this path |

The proxy evaluates in order; the first passing check wins. A project member does not incur the canvas_invitees query (step 4) — it is skipped.

### Six-role hierarchy

The `project_members.role` column accepts one of six values:

| Role | Relative power | Notes |
|---|---|---|
| `owner` | Highest | Transfer ownership; promote to `co_owner`; manage `project_connections`. One per project (enforced via RLS WITH CHECK). |
| `co_owner` | Near-owner | Cannot transfer ownership, promote anyone to `co_owner`, or manage `project_connections` (P0.1 Strict). All other owner powers. |
| `collaborator` | Standard team | Can invite `collaborator`, `viewer`, `client`. Full read+write on canvas content and OpenSpec files. |
| `external_collaborator` | Hired specialist | Like `collaborator` but cannot read `openspec/changes/**` / `openspec/current/**` / `_methodology/**` (RLS hard-wall). Cannot invite. `expires_at` used for engagement duration. |
| `viewer` | Read-only | Reads all canvas content. Cannot write to chat, Mirror, or OpenSpec. |
| `client` | Per-canvas chat + Mirror read | No project-cascade access — must be directly invited via `canvas_invitees`. Reads only Mirror and chat. |

The same six roles are accepted in `canvas_invitees.role` (extended by migration `20260515120002_extend_canvas_invitees_role.sql`).

### Proxy hot-path step ordering

```
1. Short-circuit on platform_owner === true → pass (no DB roundtrip).
2. If community_member === true AND pathname matches communityRoutes → pass (no DB roundtrip).
3. Query project_members WHERE user_id = auth.uid()
     AND accepted_at IS NOT NULL
     AND revoked_at IS NULL
     AND (expires_at IS NULL OR expires_at > now())
   → pass if any row found.
4. Query canvas_invitees WHERE invitee_user_id = auth.uid()
     AND accepted_at IS NOT NULL
     AND revoked_at IS NULL
   → pass if any row found.
5. None pass → signOut() + redirect to loginUrl?error=no_access.
```

Step 3 is a single-row indexed lookup on `project_members(user_id)`. The query is `platform_owner`-elided (step 1 short-circuits). For single-tenant Pavel use, step 1 always fires — no DB roundtrip on any request.

### Phase 0 decisions (P0.1–P0.4, all resolved on defaults)

| Decision | Choice | Impact |
|---|---|---|
| P0.1 — Co-owner semantics | **A (Strict)** | `co_owner` sole-lacks: ownership transfer, co_owner promotion, `project_connections` management |
| P0.2 — Membership scope inheritance | **A (Default-cascade with exclusion)** | Project membership cascades to all project canvases; `canvas_excluded_members` table for per-canvas privacy overrides |
| P0.3 — Mirror-write for `external_collaborator` | **A (Yes, equal to `collaborator`)** | `external_collaborator` may call `write_mirror_field`; `client` and `viewer` remain refused |
| P0.4 — `community_member` ↔ `external_collaborator` lifecycle | **A (Invitation-as-acceptance)** | Magic-link acceptance stamps `project_members.accepted_at`; no separate inbox acknowledgement step |

Full design rationale: `openspec/archive/2026-05-15-redesign-canvas-permission-model/design.md § 2`.

### Tables

- `public.project_members` — project-level membership with six-role CHECK. Schema in `db/entities.md § Project members + role hierarchy`.
- `public.canvas_excluded_members` — per-canvas exclusion overrides for project members. Schema in same section.
- `canvas_invitees.role` — extended from `('collaborator','client')` to the full six-role enum + `expires_at` column. Migration `20260515120002_extend_canvas_invitees_role.sql`.

### Tests (proxy)

`packages/auth/src/__tests__/proxy.test.ts` adds four cases for the project member branch:

| Case | Result |
|---|---|
| `project_members` row with `accepted_at NOT NULL` and no expiry | pass (step 3) |
| `project_members` row with `expires_at < now()` | signOut + redirect (step 3 misses, step 4 also misses) |
| `project_members` row with `accepted_at NULL` (pending invite) | signOut + redirect (step 3 misses) |
| `project_members` row with `revoked_at NOT NULL` | signOut + redirect (step 3 misses) |

## Webhook signature verification (applied 2026-05-22 from change `wire-spec-enforcement-gates`, RAP-1167)

`config.md` declares the webhook shared secrets (`GITHUB_APP_WEBHOOK_SECRET`, `LINEAR_WEBHOOK_SECRET`); this section is the request-authenticity contract that governs how every inbound webhook handler uses them. The contract is enforced by a single shared helper, `apps/studio/src/lib/verify-webhook-signature.ts`, which both webhook receivers call rather than re-implementing HMAC.

Every inbound webhook handler MUST verify the request signature before trusting the payload:

1. Read the **raw request body as text** before any JSON parse.
2. Resolve the per-integration shared secret from the Worker env (`GITHUB_APP_WEBHOOK_SECRET`, `LINEAR_WEBHOOK_SECRET`, etc.). If the secret is unconfigured, respond `500` and log — never fall through to processing an unverified payload.
3. Compute `HMAC-SHA256(secret, rawBody)` via `crypto.subtle` (Workers-compatible; no Node `crypto` module).
4. Compare the computed digest against the provider-supplied signature header using a **constant-time comparison** (length-checked XOR accumulator) — never `===` on the hex strings.
5. On mismatch, respond `401` **before** parsing the body. Log the rejection with request metadata (IP, user-agent) but never the body.
6. Only after a successful compare may the handler `JSON.parse` the body and act on it. A malformed body after a valid signature is `400`.

Provider header names: GitHub → `X-Hub-Signature-256` (value prefixed `sha256=`); Linear → `Linear-Signature` (raw hex, no prefix).

The shared helper exports `timingSafeEqualHex`, `computeHmacSha256Hex`, and `verifyWebhookSignature`. The GitHub install webhook (`apps/studio/src/app/api/studio/github/install/webhook/route.ts`) is wired onto it. No Linear webhook *receiver* exists today (only the OAuth connect/callback pair) — this contract governs one if added; see `openspec/archive/2026-05-22-wire-spec-enforcement-gates/design.md § Phase 2`.
