diff --git a/.changeset/gateway-account-roles.md b/.changeset/gateway-account-roles.md new file mode 100644 index 0000000..642bbd3 --- /dev/null +++ b/.changeset/gateway-account-roles.md @@ -0,0 +1,20 @@ +--- +"@etus/bhono": minor +--- + +feat(auth): consume gateway per-account roles (@etus/auth 0.9.1) + +The template now reads the gateway's per-account roles (viewer < editor < manager < +admin, Auth0-Organizations model) for authorization, alongside its own local +workspaces (hybrid model — member management stays local): + +- Bumps `@etus/auth` 0.8.0 → 0.9.1. +- `ACCOUNT_ROLE_MAP` (`src/server/auth/matrix.ts`) + `gatewayAuthority.accountRoleMap` + map gateway account roles → local permissions (unioned across the user's accounts). +- `GET /api/me` exposes the user's gateway accounts + super-admin flag (safe empty + shape when gatewayAuthority is off). +- `useGatewayAccounts()` client hook with `hasAccountRole(slug, role)` for UI gating. +- `auth.requireGatewayAccountRole(slug, role)` is available for precise per-account + server-side gating. + +See the "Papéis por-conta do gateway" section in `docs/SETUP-GUIDE.md`. diff --git a/.claude/settings.json b/.claude/settings.json index 66b564c..72f8594 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -17,13 +17,9 @@ "includeCoAuthoredBy": false, "permissions": { "allow": [ - "Edit:*", - "Write:*", - "MultiEdit:*", - "NotebookEdit:*", - "Bash:*", - "WebSearch:*", - "WebFetch:*", + "Edit", + "Write", + "NotebookEdit", "Bash(grep:*)", "Bash(diff:*)", "Bash(echo:*)", diff --git a/.gitignore b/.gitignore index 4e007e4..1f723f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist/ .wrangler/ .dev.vars docs/openapi.json +seed.sql # Test outputs .test-output/ diff --git a/.dev.vars.example b/config/.dev.vars.example similarity index 62% rename from .dev.vars.example rename to config/.dev.vars.example index 613651b..e4c5398 100644 --- a/.dev.vars.example +++ b/config/.dev.vars.example @@ -1,4 +1,8 @@ -# Local Worker variables used by `pnpm dev`. +# Local Worker variables used by `pnpm dev`. Copy this to `config/.dev.vars`. +# +# This file MUST live next to the Wrangler config (config/wrangler.json): wrangler +# and @cloudflare/vite-plugin only read `.dev.vars` from the config file's directory, +# so a `.dev.vars` at the repo root is ignored by `pnpm dev`. ENVIRONMENT=development APP_URL=http://localhost:8787 @@ -19,6 +23,12 @@ ETUS_ADMIN_EMAILS=admin@yourdomain.com # ETUS_RESOURCE_ID=yourapp.example.com # ETUS_INTEGRATION_KEY=ag_app_yourapp_xxx.yyy +# DEV-ONLY gateway mock — serve the local multi-tenant scenario fixture +# (src/server/dev/gateway-scenario.ts) from /api/me so you can click through the +# Workspaces UI and per-account gateway roles without a live gateway. Log in via +# /auth/test-login as a scenario user (e.g. multi@example.com). Ignored in production. +# ETUS_GATEWAY_MOCK=1 + # SendGrid SENDGRID_API_KEY=your-sendgrid-api-key SENDGRID_FROM_EMAIL=noreply@yourdomain.com diff --git a/config/wrangler.json b/config/wrangler.json index 44973cb..57dace2 100644 --- a/config/wrangler.json +++ b/config/wrangler.json @@ -33,7 +33,15 @@ "binding": "ASSETS", "run_worker_first": [ "/api/*", - "/auth/*" + "/auth/*", + "/accounts", + "/accounts/*", + "/audit", + "/audit/*", + "/invitations", + "/invitations/*", + "/health", + "/health/*" ] }, "vars": { diff --git a/docs/SETUP-GUIDE.md b/docs/SETUP-GUIDE.md index f2817b6..497f166 100644 --- a/docs/SETUP-GUIDE.md +++ b/docs/SETUP-GUIDE.md @@ -200,6 +200,90 @@ bloqueia; uma indisponibilidade transitória serve o cache (não derruba o app). > (v0.6.0+ usa D1 via `createSqlSessionStore`); pode ser removido se nenhum outro > código depender dele. +#### Papéis por-conta do gateway (@etus/auth v0.9.1) + +Além dos **scopes** (`SCOPE_MAP`), o gateway resolve um **papel por conta** do +usuário (modelo Auth0 Organizations: `viewer < editor < manager < admin`, da +migration 0070 do gateway). Este boilerplate **lê** esses papéis para autorização +(camada org-level, ao lado dos workspaces locais) — a gestão de membros continua +**local** (modelo híbrido). + +- **`ACCOUNT_ROLE_MAP`** (`src/server/auth/matrix.ts`) — paralelo ao `SCOPE_MAP`, + mapeia cada papel-por-conta do gateway → permissões locais (entradas do + `PERMISSION_CATALOG`). Ligado via `gatewayAuthority.accountRoleMap` em + `setup.ts`. O `@etus/auth` **une** essas permissões entre **todas** as contas do + usuário (super-admin conta como `admin` em todas) e injeta em `authPermissions`. + É um grant **coarse, org-level** — ajuste por produto. +- **Gating preciso por-conta** — para "manager nesta conta específica", use o guard + do pacote `auth.requireGatewayAccountRole(slug, role)` (não o mapa global). Um + super-admin sempre passa; lança `NotAccountMemberError`/`AccountRoleRequiredError`. +- **Contexto do usuário** — `GET /api/me` (`src/server/routes/me/index.ts`) devolve + `{ accounts: [{id,slug,name,role}], superAdmin }` resolvido pelo gateway. Vazio / + `false` quando `ETUS_GATEWAY_AUTHORITY` está off (shape sempre seguro). +- **No client** — o hook `useGatewayAccounts()` + (`src/client/hooks/use-gateway-accounts.ts`) lê o `/api/me` e expõe `accounts`, + `superAdmin` e `hasAccountRole(slug, role)` para gatear a UI: + +```tsx +const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts() + +// badge de papel por conta do gateway: +{accounts.map((a) => {a.name}: {a.role})} + +// gating de UI — NÃO é fronteira de segurança (o guard do server é a autoridade): +{hasAccountRole('unum', 'manager') && } +``` + +> ⚠️ **Regras de segurança do `ACCOUNT_ROLE_MAP`** (é grant **org-level**: o pacote +> une as permissões entre **todas** as contas do usuário, sem escopo por-conta): +> - **Nunca** use `'*'` ou wildcard de namespace (`resources:*`) — um usuário que +> seja `admin` em **qualquer** conta (até uma não-relacionada) passaria esse guard +> no app inteiro. Mantenha valores **bounded e não-destrutivos** (sem `:delete`, +> `billing:manage`). Há um teste que falha se um wildcard entrar. +> - Para autz por conta/workspace **específico** (ex.: "admin DESTA conta pode +> deletá-la"), use `auth.requireGatewayAccountRole(slug, role)` no server e +> `hasAccountRole(slug, role)` no client — **não** este mapa. +> - As permissões aqui são **unidas** com as do papel local (aditivas): remover um +> usuário de uma conta **local** NÃO revoga o que o gateway concede. O gateway é a +> autoridade do que ele resolve; com gateway-authority ligado, a gestão de membros +> local não é um kill-switch de autorização. + +#### Validar a UI multi-tenant localmente (gateway mock) + +Os papéis por-conta do gateway vêm do gateway via HTTP — local não há gateway. Para +**validar a UI** (e escrever testes) sem um gateway ao vivo, há um **mock de dev**: + +- **`src/server/dev/gateway-scenario.ts`** — cenário multi-tenant fixo, por e-mail + (alinhado ao `seed.ts`). O `/api/me`, quando o mock está ligado, resolve as contas + do gateway do usuário logado a partir desse fixture. Gated **duas vezes**: + `ENVIRONMENT !== 'production'` **e** `ETUS_GATEWAY_MOCK` truthy — nunca em produção. +- **Página `Workspaces`** (`src/client/routes/_authenticated/workspaces.tsx`, no nav) + — renderiza `useGatewayAccounts()`: banner de super-admin, um card por conta com o + badge de papel (viewer/editor/manager/admin) e o que ele concede; empty state. + +Usuários do cenário (logue via `/auth/test-login`): `superadmin@example.com` +(super-admin), `admin@example.com` (admin em Acme), `multi@example.com` (**admin em +Initech + viewer em Acme** — o caso de over-grant cross-account que o `ACCOUNT_ROLE_MAP` +conservador protege), `viewer@example.com` (read-only). + +```bash +# 1. (opcional) popular o banco local com o cenário +pnpm db:reset:local + +# 2. Ligar o mock. IMPORTANTE: o @cloudflare/vite-plugin lê o .dev.vars ao lado do +# wrangler.json (config/.dev.vars), NÃO o da raiz. Ponha a flag lá: +echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars # (config/.dev.vars é gitignored) + +# 3. Subir e logar como um usuário do cenário, depois abrir /workspaces +pnpm dev +# → POST /auth/test-login {"email":"multi@example.com"} → abra http://localhost:8787/workspaces +``` + +> O mock é **só para dev/teste**. Em produção (`ETUS_GATEWAY_AUTHORITY=true`) o +> `/api/me` resolve do gateway real; o mock é ignorado. Cobertura: unit +> (`tests/unit/server/dev/`), integração (`tests/integration/api/me.test.ts` dirige o +> mock pela wiring real via `buildApp`) e E2E (`tests/e2e/workspaces.spec.ts`). + --- ### 6. Banco de Dados com Hash Diferente diff --git a/package.json b/package.json index 28772ab..6a8caae 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "sync:template:check": "./scripts/sync-template.sh && git diff --exit-code packages/bhono-app/templates/" }, "dependencies": { - "@etus/auth": "^0.8.0", + "@etus/auth": "^0.9.1", "@etus/seven-react": "0.1.0-beta.3", "@etus/tokens": "0.4.0-beta.2", "@etus/ui": "0.4.0-beta.2", diff --git a/packages/bhono-app/templates/base/.claude/settings.json b/packages/bhono-app/templates/base/.claude/settings.json index 66b564c..72f8594 100644 --- a/packages/bhono-app/templates/base/.claude/settings.json +++ b/packages/bhono-app/templates/base/.claude/settings.json @@ -17,13 +17,9 @@ "includeCoAuthoredBy": false, "permissions": { "allow": [ - "Edit:*", - "Write:*", - "MultiEdit:*", - "NotebookEdit:*", - "Bash:*", - "WebSearch:*", - "WebFetch:*", + "Edit", + "Write", + "NotebookEdit", "Bash(grep:*)", "Bash(diff:*)", "Bash(echo:*)", diff --git a/packages/bhono-app/templates/base/.npmrc b/packages/bhono-app/templates/base/.npmrc index 9a5e138..6df6def 100644 --- a/packages/bhono-app/templates/base/.npmrc +++ b/packages/bhono-app/templates/base/.npmrc @@ -1,2 +1,7 @@ @etus:registry=https://registry.npmjs.org/ //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} +# Resolve internal workspace deps (e.g. create-bhono -> @etus/bhono) to the local +# package instead of the registry. Without this, a release bump points the dep at +# a version that isn't published yet, so `pnpm install` can't resolve it and every +# release stalls on the lockfile (ERR_PNPM_NO_MATCHING_VERSION / OUTDATED_LOCKFILE). +link-workspace-packages=true diff --git a/packages/bhono-app/templates/base/_gitignore b/packages/bhono-app/templates/base/_gitignore index 4e007e4..1f723f6 100644 --- a/packages/bhono-app/templates/base/_gitignore +++ b/packages/bhono-app/templates/base/_gitignore @@ -8,6 +8,7 @@ dist/ .wrangler/ .dev.vars docs/openapi.json +seed.sql # Test outputs .test-output/ diff --git a/packages/bhono-app/templates/base/.dev.vars.example b/packages/bhono-app/templates/base/config/.dev.vars.example similarity index 62% rename from packages/bhono-app/templates/base/.dev.vars.example rename to packages/bhono-app/templates/base/config/.dev.vars.example index 613651b..e4c5398 100644 --- a/packages/bhono-app/templates/base/.dev.vars.example +++ b/packages/bhono-app/templates/base/config/.dev.vars.example @@ -1,4 +1,8 @@ -# Local Worker variables used by `pnpm dev`. +# Local Worker variables used by `pnpm dev`. Copy this to `config/.dev.vars`. +# +# This file MUST live next to the Wrangler config (config/wrangler.json): wrangler +# and @cloudflare/vite-plugin only read `.dev.vars` from the config file's directory, +# so a `.dev.vars` at the repo root is ignored by `pnpm dev`. ENVIRONMENT=development APP_URL=http://localhost:8787 @@ -19,6 +23,12 @@ ETUS_ADMIN_EMAILS=admin@yourdomain.com # ETUS_RESOURCE_ID=yourapp.example.com # ETUS_INTEGRATION_KEY=ag_app_yourapp_xxx.yyy +# DEV-ONLY gateway mock — serve the local multi-tenant scenario fixture +# (src/server/dev/gateway-scenario.ts) from /api/me so you can click through the +# Workspaces UI and per-account gateway roles without a live gateway. Log in via +# /auth/test-login as a scenario user (e.g. multi@example.com). Ignored in production. +# ETUS_GATEWAY_MOCK=1 + # SendGrid SENDGRID_API_KEY=your-sendgrid-api-key SENDGRID_FROM_EMAIL=noreply@yourdomain.com diff --git a/packages/bhono-app/templates/base/config/wrangler.json b/packages/bhono-app/templates/base/config/wrangler.json index b037669..bdf86ad 100644 --- a/packages/bhono-app/templates/base/config/wrangler.json +++ b/packages/bhono-app/templates/base/config/wrangler.json @@ -35,7 +35,15 @@ "binding": "ASSETS", "run_worker_first": [ "/api/*", - "/auth/*" + "/auth/*", + "/accounts", + "/accounts/*", + "/audit", + "/audit/*", + "/invitations", + "/invitations/*", + "/health", + "/health/*" ] }, "vars": { diff --git a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md index f2817b6..497f166 100644 --- a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md +++ b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md @@ -200,6 +200,90 @@ bloqueia; uma indisponibilidade transitória serve o cache (não derruba o app). > (v0.6.0+ usa D1 via `createSqlSessionStore`); pode ser removido se nenhum outro > código depender dele. +#### Papéis por-conta do gateway (@etus/auth v0.9.1) + +Além dos **scopes** (`SCOPE_MAP`), o gateway resolve um **papel por conta** do +usuário (modelo Auth0 Organizations: `viewer < editor < manager < admin`, da +migration 0070 do gateway). Este boilerplate **lê** esses papéis para autorização +(camada org-level, ao lado dos workspaces locais) — a gestão de membros continua +**local** (modelo híbrido). + +- **`ACCOUNT_ROLE_MAP`** (`src/server/auth/matrix.ts`) — paralelo ao `SCOPE_MAP`, + mapeia cada papel-por-conta do gateway → permissões locais (entradas do + `PERMISSION_CATALOG`). Ligado via `gatewayAuthority.accountRoleMap` em + `setup.ts`. O `@etus/auth` **une** essas permissões entre **todas** as contas do + usuário (super-admin conta como `admin` em todas) e injeta em `authPermissions`. + É um grant **coarse, org-level** — ajuste por produto. +- **Gating preciso por-conta** — para "manager nesta conta específica", use o guard + do pacote `auth.requireGatewayAccountRole(slug, role)` (não o mapa global). Um + super-admin sempre passa; lança `NotAccountMemberError`/`AccountRoleRequiredError`. +- **Contexto do usuário** — `GET /api/me` (`src/server/routes/me/index.ts`) devolve + `{ accounts: [{id,slug,name,role}], superAdmin }` resolvido pelo gateway. Vazio / + `false` quando `ETUS_GATEWAY_AUTHORITY` está off (shape sempre seguro). +- **No client** — o hook `useGatewayAccounts()` + (`src/client/hooks/use-gateway-accounts.ts`) lê o `/api/me` e expõe `accounts`, + `superAdmin` e `hasAccountRole(slug, role)` para gatear a UI: + +```tsx +const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts() + +// badge de papel por conta do gateway: +{accounts.map((a) => {a.name}: {a.role})} + +// gating de UI — NÃO é fronteira de segurança (o guard do server é a autoridade): +{hasAccountRole('unum', 'manager') && } +``` + +> ⚠️ **Regras de segurança do `ACCOUNT_ROLE_MAP`** (é grant **org-level**: o pacote +> une as permissões entre **todas** as contas do usuário, sem escopo por-conta): +> - **Nunca** use `'*'` ou wildcard de namespace (`resources:*`) — um usuário que +> seja `admin` em **qualquer** conta (até uma não-relacionada) passaria esse guard +> no app inteiro. Mantenha valores **bounded e não-destrutivos** (sem `:delete`, +> `billing:manage`). Há um teste que falha se um wildcard entrar. +> - Para autz por conta/workspace **específico** (ex.: "admin DESTA conta pode +> deletá-la"), use `auth.requireGatewayAccountRole(slug, role)` no server e +> `hasAccountRole(slug, role)` no client — **não** este mapa. +> - As permissões aqui são **unidas** com as do papel local (aditivas): remover um +> usuário de uma conta **local** NÃO revoga o que o gateway concede. O gateway é a +> autoridade do que ele resolve; com gateway-authority ligado, a gestão de membros +> local não é um kill-switch de autorização. + +#### Validar a UI multi-tenant localmente (gateway mock) + +Os papéis por-conta do gateway vêm do gateway via HTTP — local não há gateway. Para +**validar a UI** (e escrever testes) sem um gateway ao vivo, há um **mock de dev**: + +- **`src/server/dev/gateway-scenario.ts`** — cenário multi-tenant fixo, por e-mail + (alinhado ao `seed.ts`). O `/api/me`, quando o mock está ligado, resolve as contas + do gateway do usuário logado a partir desse fixture. Gated **duas vezes**: + `ENVIRONMENT !== 'production'` **e** `ETUS_GATEWAY_MOCK` truthy — nunca em produção. +- **Página `Workspaces`** (`src/client/routes/_authenticated/workspaces.tsx`, no nav) + — renderiza `useGatewayAccounts()`: banner de super-admin, um card por conta com o + badge de papel (viewer/editor/manager/admin) e o que ele concede; empty state. + +Usuários do cenário (logue via `/auth/test-login`): `superadmin@example.com` +(super-admin), `admin@example.com` (admin em Acme), `multi@example.com` (**admin em +Initech + viewer em Acme** — o caso de over-grant cross-account que o `ACCOUNT_ROLE_MAP` +conservador protege), `viewer@example.com` (read-only). + +```bash +# 1. (opcional) popular o banco local com o cenário +pnpm db:reset:local + +# 2. Ligar o mock. IMPORTANTE: o @cloudflare/vite-plugin lê o .dev.vars ao lado do +# wrangler.json (config/.dev.vars), NÃO o da raiz. Ponha a flag lá: +echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars # (config/.dev.vars é gitignored) + +# 3. Subir e logar como um usuário do cenário, depois abrir /workspaces +pnpm dev +# → POST /auth/test-login {"email":"multi@example.com"} → abra http://localhost:8787/workspaces +``` + +> O mock é **só para dev/teste**. Em produção (`ETUS_GATEWAY_AUTHORITY=true`) o +> `/api/me` resolve do gateway real; o mock é ignorado. Cobertura: unit +> (`tests/unit/server/dev/`), integração (`tests/integration/api/me.test.ts` dirige o +> mock pela wiring real via `buildApp`) e E2E (`tests/e2e/workspaces.spec.ts`). + --- ### 6. Banco de Dados com Hash Diferente diff --git a/packages/bhono-app/templates/base/package.json b/packages/bhono-app/templates/base/package.json index 5a77985..7d606b2 100644 --- a/packages/bhono-app/templates/base/package.json +++ b/packages/bhono-app/templates/base/package.json @@ -44,7 +44,7 @@ "sync:template:check": "./scripts/sync-template.sh && git diff --exit-code packages/bhono-app/templates/" }, "dependencies": { - "@etus/auth": "^0.8.0", + "@etus/auth": "^0.9.1", "@etus/seven-react": "0.1.0-beta.3", "@etus/tokens": "0.4.0-beta.2", "@etus/ui": "0.4.0-beta.2", diff --git a/packages/bhono-app/templates/base/src/client/components/sidebar.tsx b/packages/bhono-app/templates/base/src/client/components/sidebar.tsx index 35c9cd6..02cbb2f 100644 --- a/packages/bhono-app/templates/base/src/client/components/sidebar.tsx +++ b/packages/bhono-app/templates/base/src/client/components/sidebar.tsx @@ -14,6 +14,7 @@ interface SidebarProps { const mainNavItems = [ { to: '/dashboard', label: 'Dashboard', icon: Icons.dashboard }, { to: '/team', label: 'Team', icon: Icons.users }, + { to: '/workspaces', label: 'Workspaces', icon: Icons.layers }, { to: '/integrations', label: 'Integrations', icon: Icons.blocks }, ] diff --git a/packages/bhono-app/templates/base/src/client/hooks/use-gateway-accounts.ts b/packages/bhono-app/templates/base/src/client/hooks/use-gateway-accounts.ts new file mode 100644 index 0000000..560f499 --- /dev/null +++ b/packages/bhono-app/templates/base/src/client/hooks/use-gateway-accounts.ts @@ -0,0 +1,64 @@ +// src/client/hooks/use-gateway-accounts.ts +// +// Reads the user's GATEWAY account context from `GET /api/me` (gateway-as-authority, +// @etus/auth v0.9.1): the per-account roles the gateway resolved for the user plus +// the global super-admin flag. This is the org-level dimension the app reads for +// authorization, alongside its own local workspaces. Always resolves to a safe +// shape (empty / false) when gatewayAuthority is disabled. +import { useQuery } from '@tanstack/react-query' + +export type AccountRole = 'viewer' | 'editor' | 'manager' | 'admin' + +export interface GatewayAccount { + id: string + slug: string + name: string + role: AccountRole +} + +interface GatewayAccountContext { + accounts: GatewayAccount[] + superAdmin: boolean +} + +// Cumulative hierarchy: viewer < editor < manager < admin (gateway migration 0070). +const ROLE_RANK: Record = { viewer: 0, editor: 1, manager: 2, admin: 3 } + +async function fetchGatewayAccounts(): Promise { + const res = await fetch('/api/me', { credentials: 'include' }) + if (!res.ok) return { accounts: [], superAdmin: false } + return res.json() +} + +export function useGatewayAccounts() { + const { data, isLoading } = useQuery({ + queryKey: ['gateway', 'accounts'], + queryFn: fetchGatewayAccounts, + retry: false, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 30, + refetchOnMount: false, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }) + + const accounts = data?.accounts ?? [] + const superAdmin = data?.superAdmin ?? false + + return { + accounts, + superAdmin, + isLoading, + /** + * True when the user holds at least `role` on the gateway account `slug` + * (cumulative; a super-admin always passes). Mirrors the server-side + * `auth.requireGatewayAccountRole` / `hasGatewayAccountRole` — use it to gate + * UI, NOT as a security boundary (the server guards remain authoritative). + */ + hasAccountRole(slug: string, role: AccountRole): boolean { + if (superAdmin) return true + const membership = accounts.find((a) => a.slug === slug) + return membership ? ROLE_RANK[membership.role] >= ROLE_RANK[role] : false + }, + } +} diff --git a/packages/bhono-app/templates/base/src/client/routeTree.gen.ts b/packages/bhono-app/templates/base/src/client/routeTree.gen.ts index 35baad4..b0903ab 100644 --- a/packages/bhono-app/templates/base/src/client/routeTree.gen.ts +++ b/packages/bhono-app/templates/base/src/client/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as SplatRouteImport } from './routes/$' import { Route as IndexRouteImport } from './routes/index' import { Route as InviteTokenRouteImport } from './routes/invite.$token' +import { Route as AuthenticatedWorkspacesRouteImport } from './routes/_authenticated/workspaces' import { Route as AuthenticatedTeamRouteImport } from './routes/_authenticated/team' import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings' import { Route as AuthenticatedIntegrationsRouteImport } from './routes/_authenticated/integrations' @@ -44,6 +45,11 @@ const InviteTokenRoute = InviteTokenRouteImport.update({ path: '/invite/$token', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedWorkspacesRoute = AuthenticatedWorkspacesRouteImport.update({ + id: '/workspaces', + path: '/workspaces', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedTeamRoute = AuthenticatedTeamRouteImport.update({ id: '/team', path: '/team', @@ -80,6 +86,7 @@ export interface FileRoutesByFullPath { '/integrations': typeof AuthenticatedIntegrationsRoute '/settings': typeof AuthenticatedSettingsRoute '/team': typeof AuthenticatedTeamRoute + '/workspaces': typeof AuthenticatedWorkspacesRoute '/invite/$token': typeof InviteTokenRoute } export interface FileRoutesByTo { @@ -91,6 +98,7 @@ export interface FileRoutesByTo { '/integrations': typeof AuthenticatedIntegrationsRoute '/settings': typeof AuthenticatedSettingsRoute '/team': typeof AuthenticatedTeamRoute + '/workspaces': typeof AuthenticatedWorkspacesRoute '/invite/$token': typeof InviteTokenRoute } export interface FileRoutesById { @@ -104,6 +112,7 @@ export interface FileRoutesById { '/_authenticated/integrations': typeof AuthenticatedIntegrationsRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute '/_authenticated/team': typeof AuthenticatedTeamRoute + '/_authenticated/workspaces': typeof AuthenticatedWorkspacesRoute '/invite/$token': typeof InviteTokenRoute } export interface FileRouteTypes { @@ -117,6 +126,7 @@ export interface FileRouteTypes { | '/integrations' | '/settings' | '/team' + | '/workspaces' | '/invite/$token' fileRoutesByTo: FileRoutesByTo to: @@ -128,6 +138,7 @@ export interface FileRouteTypes { | '/integrations' | '/settings' | '/team' + | '/workspaces' | '/invite/$token' id: | '__root__' @@ -140,6 +151,7 @@ export interface FileRouteTypes { | '/_authenticated/integrations' | '/_authenticated/settings' | '/_authenticated/team' + | '/_authenticated/workspaces' | '/invite/$token' fileRoutesById: FileRoutesById } @@ -188,6 +200,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InviteTokenRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated/workspaces': { + id: '/_authenticated/workspaces' + path: '/workspaces' + fullPath: '/workspaces' + preLoaderRoute: typeof AuthenticatedWorkspacesRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/team': { id: '/_authenticated/team' path: '/team' @@ -232,6 +251,7 @@ interface AuthenticatedRouteChildren { AuthenticatedIntegrationsRoute: typeof AuthenticatedIntegrationsRoute AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute AuthenticatedTeamRoute: typeof AuthenticatedTeamRoute + AuthenticatedWorkspacesRoute: typeof AuthenticatedWorkspacesRoute } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { @@ -240,6 +260,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedIntegrationsRoute: AuthenticatedIntegrationsRoute, AuthenticatedSettingsRoute: AuthenticatedSettingsRoute, AuthenticatedTeamRoute: AuthenticatedTeamRoute, + AuthenticatedWorkspacesRoute: AuthenticatedWorkspacesRoute, } const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( diff --git a/packages/bhono-app/templates/base/src/client/routes/_authenticated/workspaces.tsx b/packages/bhono-app/templates/base/src/client/routes/_authenticated/workspaces.tsx new file mode 100644 index 0000000..3adfae3 --- /dev/null +++ b/packages/bhono-app/templates/base/src/client/routes/_authenticated/workspaces.tsx @@ -0,0 +1,99 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@etus/seven-react' +import { Icons } from '@/components/icons' +import { useGatewayAccounts, type AccountRole } from '@/hooks/use-gateway-accounts' + +export const Route = createFileRoute('/_authenticated/workspaces')({ + component: WorkspacesPage, +}) + +// UI-only descriptions of what each gateway role grants in THIS app. Mirrors +// ACCOUNT_ROLE_MAP (src/server/auth/matrix.ts) at a human level — it is presentation, +// NOT a security boundary (the server `requirePermission` / `requireGatewayAccountRole` +// guards remain authoritative). +const ROLE_META: Record = { + admin: { color: 'primary', blurb: 'Manage members and account settings, plus create and update resources.' }, + manager: { color: 'info', blurb: 'Invite members and read audit logs, plus create and update resources.' }, + editor: { color: 'success', blurb: 'Create and update resources.' }, + viewer: { color: 'muted', blurb: 'Read-only access to resources.' }, +} + +function WorkspacesPage() { + const { accounts, superAdmin, isLoading } = useGatewayAccounts() + + return ( +
+
+

Workspaces

+

+ The gateway accounts you belong to and the role you hold in each. Roles are + resolved by the gateway (viewer < editor < manager < admin). +

+
+ + {superAdmin && ( + + + +
+ Super admin + + You have admin-level access across every workspace, regardless of the list below. + +
+
+
+ )} + + {isLoading ? ( +
+ + Loading workspaces… +
+ ) : accounts.length === 0 ? ( + + + +

No workspaces yet

+

+ {superAdmin + ? 'You hold no explicit per-workspace role, but super admin grants access everywhere.' + : "You don't belong to any gateway workspace yet. An admin can grant you a role."} +

+
+
+ ) : ( +
+ {accounts.map((account) => { + const meta = ROLE_META[account.role] + return ( + + +
+
+ {account.name} + {account.slug} +
+ + {account.role} + +
+
+ +

{meta.blurb}

+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/packages/bhono-app/templates/base/src/server/auth/matrix.ts b/packages/bhono-app/templates/base/src/server/auth/matrix.ts index e3f2561..eb513c4 100644 --- a/packages/bhono-app/templates/base/src/server/auth/matrix.ts +++ b/packages/bhono-app/templates/base/src/server/auth/matrix.ts @@ -77,3 +77,52 @@ export const SCOPE_MAP: Record = { ], 'bhono:viewer': ['account:read', 'resources:read'], } + +// Gateway ACCOUNT role → local permissions (gateway-as-authority for per-account +// roles, @etus/auth v0.9.1). Parallel to SCOPE_MAP, but keyed by the gateway's +// per-account membership role (viewer < editor < manager < admin, from gateway +// migration 0070) instead of a scope. +// +// ⚠️ CRITICAL — this is a COARSE, ORG-LEVEL grant. The package UNIONS these +// permissions across EVERY gateway account the subject belongs to (and treats a +// super-admin as admin everywhere), with NO per-account scoping at the +// `requirePermission` layer. Consequences for the values below: +// * NEVER put `'*'` or a namespace wildcard (`resources:*`) here: a user who is +// `admin` (or holds that role) on ANY single gateway account — even one +// unrelated to this app — would then pass that wildcard guard for the WHOLE +// app. Keep the values an explicit, bounded, NON-DESTRUCTIVE set (no +// `:delete`, no `billing:manage`). +// * For real per-account authority (e.g. "admin OF THIS workspace can delete +// it"), gate with the precise guard `auth.requireGatewayAccountRole(slug, +// role)` / the client `hasAccountRole(slug, role)` — NOT this map. +// * NOTE (hybrid model): permissions here are UNIONED with local-role perms; +// they are additive, so removing a user from a LOCAL account does NOT revoke +// perms the gateway still grants. The gateway is authoritative for what it +// resolves. +// +// KEYS are the fixed gateway account roles (`@etus/auth`'s `ACCOUNT_ROLES`). +// VALUES must be PERMISSION_CATALOG entries. Tune per product, keeping the rules +// above. Roles not mapped contribute nothing. +export const ACCOUNT_ROLE_MAP: Record = { + viewer: ['account:read', 'resources:read'], + editor: ['account:read', 'resources:read', 'resources:create', 'resources:update'], + manager: [ + 'account:read', + 'resources:read', + 'resources:create', + 'resources:update', + 'members:invite', + 'audit:read', + ], + admin: [ + 'account:read', + 'account:update', + 'resources:read', + 'resources:create', + 'resources:update', + 'members:invite', + 'members:remove', + 'members:role', + 'audit:read', + ], +} diff --git a/packages/bhono-app/templates/base/src/server/auth/setup.ts b/packages/bhono-app/templates/base/src/server/auth/setup.ts index 77918a7..80d50c4 100644 --- a/packages/bhono-app/templates/base/src/server/auth/setup.ts +++ b/packages/bhono-app/templates/base/src/server/auth/setup.ts @@ -32,7 +32,7 @@ import { } from '@etus/auth' import type { Env } from '../env' import { sendInvitationEmail } from '../lib/email' -import { PERMISSIONS_MATRIX, ROLE_HIERARCHY, ROLES, SCOPE_MAP } from './matrix' +import { ACCOUNT_ROLE_MAP, PERMISSIONS_MATRIX, ROLE_HIERARCHY, ROLES, SCOPE_MAP } from './matrix' function parseList(csv: string | undefined): string[] { return (csv ?? '') @@ -101,6 +101,11 @@ function buildAuthConfig(env: Env): AuthConfig { resourceId: env.ETUS_RESOURCE_ID ?? '', integrationKey: (e) => (e as Env).ETUS_INTEGRATION_KEY ?? '', scopeMap: SCOPE_MAP, + // Per-account gateway roles (v0.9.1) → local permissions, unioned across the + // subject's gateway accounts (super-admin counts as admin everywhere). The + // package also exposes `authAccounts`/`authSuperAdmin` and the + // `requireGatewayAccountRole(slug, role)` guard for precise per-account gating. + accountRoleMap: ACCOUNT_ROLE_MAP, ttlSeconds: 300, }, // Required in v0.5.0 when the invitation flow is active — without a diff --git a/packages/bhono-app/templates/base/src/server/db/seed.ts b/packages/bhono-app/templates/base/src/server/db/seed.ts index 2adefdc..8758bae 100644 --- a/packages/bhono-app/templates/base/src/server/db/seed.ts +++ b/packages/bhono-app/templates/base/src/server/db/seed.ts @@ -49,6 +49,11 @@ const userIds = { analytics: uuid(), user1: uuid(), user2: uuid(), + // Scenario-aligned users (emails match src/server/dev/gateway-scenario.ts so the + // same accounts carry gateway per-account roles when ETUS_GATEWAY_MOCK is on). + multi: uuid(), + pending: uuid(), + suspended: uuid(), } // Accounts @@ -64,8 +69,16 @@ const accountsData = [ { id: accountIds.startup, name: 'Tech Startup', slug: 'startup', ownerId: userIds.manager }, ] -// Users -const usersData = [ +// Users. `status` defaults to 'active' in the SQL below; set it explicitly to seed +// pending/suspended accounts for the team-management UI's status states. +const usersData: { + id: string + email: string + name: string + gatewayUserId: string + role: string + status?: 'active' | 'pending' | 'suspended' | 'denied' +}[] = [ { id: userIds.superadmin, email: 'superadmin@example.com', name: 'Super Admin', gatewayUserId: 'gateway-seed-superadmin-001', role: 'owner' }, { id: userIds.admin, email: 'admin@example.com', name: 'Admin User', gatewayUserId: 'gateway-seed-admin-002', role: 'admin' }, { id: userIds.manager, email: 'manager@example.com', name: 'Manager User', gatewayUserId: 'gateway-seed-manager-003', role: 'admin' }, @@ -76,6 +89,12 @@ const usersData = [ { id: userIds.analytics, email: 'analytics@example.com', name: 'Analytics User', gatewayUserId: 'gateway-seed-analytics-008', role: 'member' }, { id: userIds.user1, email: 'user1@example.com', name: 'Test User 1', gatewayUserId: 'gateway-seed-user1-009', role: 'guest' }, { id: userIds.user2, email: 'user2@example.com', name: 'Test User 2', gatewayUserId: 'gateway-seed-user2-010', role: 'guest' }, + // Multi-workspace user: at the gateway, admin on Initech + viewer on Acme (the + // cross-account over-grant case the conservative ACCOUNT_ROLE_MAP guards). + { id: userIds.multi, email: 'multi@example.com', name: 'Multi Workspace', gatewayUserId: 'gateway-seed-multi-011', role: 'member' }, + // Status states for the team-management UI. + { id: userIds.pending, email: 'pending@example.com', name: 'Pending User', gatewayUserId: 'gateway-seed-pending-012', role: 'guest', status: 'pending' }, + { id: userIds.suspended, email: 'suspended@example.com', name: 'Suspended User', gatewayUserId: 'gateway-seed-suspended-013', role: 'member', status: 'suspended' }, ] // Account memberships. @etus/auth account routes currently use membership role @@ -94,6 +113,8 @@ const membershipsData = [ { id: uuid(), userId: userIds.author, accountId: accountIds.acme, role: 'member' }, { id: uuid(), userId: userIds.billing, accountId: accountIds.acme, role: 'member' }, { id: uuid(), userId: userIds.user2, accountId: accountIds.acme, role: 'guest' }, + { id: uuid(), userId: userIds.multi, accountId: accountIds.acme, role: 'member' }, + { id: uuid(), userId: userIds.suspended, accountId: accountIds.acme, role: 'member' }, // Tech Startup { id: uuid(), userId: userIds.manager, accountId: accountIds.startup, role: 'admin' }, { id: uuid(), userId: userIds.analytics, accountId: accountIds.startup, role: 'member' }, @@ -164,7 +185,7 @@ function generateSQL(): string { // Users lines.push('-- Users') for (const user of usersData) { - lines.push(`INSERT INTO auth_users (id, gateway_user_id, email, name, picture, role, status, invited_by, created_at, last_login_at) VALUES (${toSqlValue(user.id)}, ${toSqlValue(user.gatewayUserId)}, ${toSqlValue(user.email)}, ${toSqlValue(user.name)}, NULL, ${toSqlValue(user.role)}, 'active', NULL, ${toSqlValue(timestamp)}, ${toSqlValue(timestamp)});`) + lines.push(`INSERT INTO auth_users (id, gateway_user_id, email, name, picture, role, status, invited_by, created_at, last_login_at) VALUES (${toSqlValue(user.id)}, ${toSqlValue(user.gatewayUserId)}, ${toSqlValue(user.email)}, ${toSqlValue(user.name)}, NULL, ${toSqlValue(user.role)}, ${toSqlValue(user.status ?? 'active')}, NULL, ${toSqlValue(timestamp)}, ${toSqlValue(timestamp)});`) } lines.push('') @@ -219,7 +240,8 @@ function printSummary(): void { console.log('\n👤 Users:') for (const user of usersData) { - console.log(` • ${user.email} - ${user.name} (${user.role})`) + const status = user.status && user.status !== 'active' ? `, ${user.status}` : '' + console.log(` • ${user.email} - ${user.name} (${user.role}${status})`) } console.log('\n🔗 Account Memberships:') @@ -250,6 +272,11 @@ function printSummary(): void { console.log('\n✅ Run the following command to seed the local database:') console.log(' pnpm db:seed:local') console.log(' # or: wrangler --config config/wrangler.json d1 execute --local --file=seed.sql\n') + + console.log('💡 To see GATEWAY per-account roles (viewer/editor/manager/admin) in the UI,') + console.log(' set ETUS_GATEWAY_MOCK=1 in .dev.vars, then log in via /auth/test-login as a') + console.log(' scenario user (e.g. multi@example.com — admin on Initech + viewer on Acme).') + console.log(' See src/server/dev/gateway-scenario.ts.\n') } // ============================================================================ diff --git a/packages/bhono-app/templates/base/src/server/dev/gateway-scenario.ts b/packages/bhono-app/templates/base/src/server/dev/gateway-scenario.ts new file mode 100644 index 0000000..5edae96 --- /dev/null +++ b/packages/bhono-app/templates/base/src/server/dev/gateway-scenario.ts @@ -0,0 +1,87 @@ +// src/server/dev/gateway-scenario.ts +// +// DEV-ONLY mock of the gateway's per-account role resolution (gateway-as-authority, +// @etus/auth v0.9.1). In production the per-account roles are resolved by the gateway +// over HTTP and read via `auth.getGatewayAccounts(c)`. Locally there is no gateway, so +// this fixture lets you validate the multi-tenant UI and write tests against a known +// scenario WITHOUT a live gateway. +// +// Gated TWICE: it only ever activates when ENVIRONMENT !== 'production' AND +// ETUS_GATEWAY_MOCK is truthy. The `/api/me` route consults resolveMockGatewayContext +// before the real gateway resolution, so production behaviour is untouched. +// +// The scenario is keyed by the signed-in user's email and is deliberately aligned with +// src/server/db/seed.ts, so the same users that exist locally (and can log in via +// /auth/test-login) also carry gateway roles here. Treat it as a replaceable starting +// point for your own demo data, not a contract. + +import type { Env } from '../env' + +export type GatewayAccountRole = 'viewer' | 'editor' | 'manager' | 'admin' + +export interface MockGatewayAccount { + id: string + slug: string + name: string + role: GatewayAccountRole +} + +export interface MockGatewayContext { + accounts: MockGatewayAccount[] + superAdmin: boolean +} + +// Gateway "orgs"/workspaces in the demo scenario. ids are stable so tests can assert on +// them. These are GATEWAY accounts (migration 0070), distinct from the app's own local +// accounts in auth_accounts. +type GatewayOrg = Omit +const ACME: GatewayOrg = { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation' } +const GLOBEX: GatewayOrg = { id: 'gw-globex', slug: 'globex', name: 'Globex Inc' } +const INITECH: GatewayOrg = { id: 'gw-initech', slug: 'initech', name: 'Initech' } + +function at(org: GatewayOrg, role: GatewayAccountRole): MockGatewayAccount { + return { ...org, role } +} + +// email → gateway context. Keys are lowercase; lookups are lowercased too. +export const GATEWAY_MOCK_SCENARIO: Record = { + // Super-admin: no per-account rows needed — counts as admin everywhere. + 'superadmin@example.com': { accounts: [], superAdmin: true }, + // Single-workspace admin. + 'admin@example.com': { accounts: [at(ACME, 'admin')], superAdmin: false }, + // Multi-workspace, mixed roles: manager on Acme, read-only on Globex. + 'manager@example.com': { accounts: [at(ACME, 'manager'), at(GLOBEX, 'viewer')], superAdmin: false }, + // Editor on a single workspace. + 'editor@example.com': { accounts: [at(GLOBEX, 'editor')], superAdmin: false }, + // Read-only. + 'viewer@example.com': { accounts: [at(ACME, 'viewer')], superAdmin: false }, + // OVER-GRANT GUARD CASE: admin on an UNRELATED workspace (Initech) + viewer on Acme. + // Because @etus/auth unions ACCOUNT_ROLE_MAP across EVERY account a user holds, this + // user must NOT gain wildcard/destructive app-wide perms from the Initech admin role + // — the conservative map in src/server/auth/matrix.ts is what keeps that closed. The + // Workspaces UI shows the two distinct per-account roles side by side. + 'multi@example.com': { accounts: [at(INITECH, 'admin'), at(ACME, 'viewer')], superAdmin: false }, +} + +/** True when the dev gateway mock may activate (never in production). */ +export function isGatewayMockEnabled(env: Env): boolean { + return ( + env.ENVIRONMENT !== 'production' && + (env.ETUS_GATEWAY_MOCK === 'true' || env.ETUS_GATEWAY_MOCK === '1') + ) +} + +/** + * The mocked gateway context for `email`, or null when the mock is disabled (so the + * caller falls back to the real gateway resolution). When enabled, an unknown email + * resolves to the safe empty context — the same shape a user with no gateway accounts + * would get from the real gateway. + */ +export function resolveMockGatewayContext( + env: Env, + email: string | null | undefined, +): MockGatewayContext | null { + if (!isGatewayMockEnabled(env)) return null + const key = (email ?? '').toLowerCase() + return GATEWAY_MOCK_SCENARIO[key] ?? { accounts: [], superAdmin: false } +} diff --git a/packages/bhono-app/templates/base/src/server/env.ts b/packages/bhono-app/templates/base/src/server/env.ts index 2cfe0c0..304d9cd 100644 --- a/packages/bhono-app/templates/base/src/server/env.ts +++ b/packages/bhono-app/templates/base/src/server/env.ts @@ -36,6 +36,10 @@ export interface Env { // The resource-bound integration key (ag_app_., scope app.grants.read). // Secret — set via `wrangler secret put`, never commit it. ETUS_INTEGRATION_KEY?: string + // DEV-ONLY: "true"/"1" makes /api/me serve the local gateway scenario fixture + // (src/server/dev/gateway-scenario.ts) instead of the real gateway, so the + // multi-tenant UI can be validated without a live gateway. Ignored in production. + ETUS_GATEWAY_MOCK?: string // SendGrid (invitations) SENDGRID_API_KEY: string diff --git a/packages/bhono-app/templates/base/src/server/index.ts b/packages/bhono-app/templates/base/src/server/index.ts index 0c17582..1ce495d 100644 --- a/packages/bhono-app/templates/base/src/server/index.ts +++ b/packages/bhono-app/templates/base/src/server/index.ts @@ -27,7 +27,7 @@ import { parseUploadBytes, validateEnv } from './env' // built app is cached for the isolate's lifetime (env is stable per deploy). let appInstance: Hono | undefined -function buildApp(env: Env): Hono { +export function buildApp(env: Env): Hono { const auth = getAuth(env) const app = new Hono() @@ -127,6 +127,12 @@ function buildApp(env: Env): Hono { app.route('/auth', auth.routes()) app.route('/auth/admin', auth.adminRoutes()) app.route('/audit', auth.auditRoutes()) + // Hybrid model: account/member MANAGEMENT stays LOCAL (these @etus/auth account + // routes), while per-account ROLES are READ from the gateway for authorization + // (accountRoleMap + /api/me + requireGatewayAccountRole). The gateway has no + // app-facing write API for member management, so the local routes are retained + // on purpose despite the v0.9.x deprecation. See docs/SETUP-GUIDE.md. + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional in the hybrid model app.route('/accounts', auth.accountRoutes()) app.route('/invitations', auth.invitationRoutes()) diff --git a/packages/bhono-app/templates/base/src/server/routes/index.ts b/packages/bhono-app/templates/base/src/server/routes/index.ts index cd7515e..715aaef 100644 --- a/packages/bhono-app/templates/base/src/server/routes/index.ts +++ b/packages/bhono-app/templates/base/src/server/routes/index.ts @@ -2,6 +2,7 @@ import { OpenAPIHono } from '@hono/zod-openapi' import { NONCE } from 'hono/secure-headers' import type { HonoEnv } from '../types' import { storage } from './storage' +import { me } from './me' import { openApiConfig } from './openapi' // Application API router. Identity/tenancy routes (users, accounts, @@ -10,6 +11,7 @@ import { openApiConfig } from './openapi' const api = new OpenAPIHono() api.route('/storage', storage) +api.route('/me', me) // Session cookie auth scheme (issued by @etus/auth on login) api.openAPIRegistry.registerComponent('securitySchemes', 'SessionCookie', { diff --git a/packages/bhono-app/templates/base/src/server/routes/me/index.ts b/packages/bhono-app/templates/base/src/server/routes/me/index.ts new file mode 100644 index 0000000..4e23f36 --- /dev/null +++ b/packages/bhono-app/templates/base/src/server/routes/me/index.ts @@ -0,0 +1,61 @@ +// src/server/routes/me/index.ts +// +// The current user's GATEWAY account context (gateway-as-authority, @etus/auth +// v0.9.1). Distinct from `/auth/me` (identity, served by @etus/auth): this returns +// the per-account roles the gateway resolved for the user (viewer/editor/manager/ +// admin per account) plus the global super-admin flag — the org-level dimension +// the app reads for authorization, alongside its own local workspaces. +// +// Empty/false when `gatewayAuthority` is disabled, so the client always gets a +// safe shape regardless of env. +import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi' +import type { HonoEnv } from '../../types' +import { getAuth } from '../../auth/setup' +import { resolveMockGatewayContext } from '../../dev/gateway-scenario' + +const GatewayAccountSchema = z + .object({ + id: z.string(), + slug: z.string(), + name: z.string(), + role: z.enum(['viewer', 'editor', 'manager', 'admin']), + }) + .openapi('GatewayAccount') + +const GatewayAccountContextSchema = z + .object({ + accounts: z.array(GatewayAccountSchema), + superAdmin: z.boolean(), + }) + .openapi('GatewayAccountContext') + +const getMeRoute = createRoute({ + method: 'get', + path: '/', + tags: ['Me'], + summary: "The current user's gateway accounts and super-admin flag", + description: + 'Per-account roles resolved by the gateway via gatewayAuthority. ' + + 'Returns an empty list and superAdmin:false when gatewayAuthority is disabled.', + security: [{ SessionCookie: [] }], + responses: { + 200: { + content: { 'application/json': { schema: GatewayAccountContextSchema } }, + description: "The current user's gateway account context", + }, + }, +}) + +const me = new OpenAPIHono() + +me.openapi(getMeRoute, (c) => { + // Dev-only: a configured gateway mock (ENVIRONMENT!=production + ETUS_GATEWAY_MOCK) + // short-circuits the real resolution so the multi-tenant UI can be validated and + // tested without a live gateway. Returns null in production → real path below. + const mocked = resolveMockGatewayContext(c.env, c.get('authUser')?.email) + if (mocked) return c.json(mocked, 200) + const auth = getAuth(c.env) + return c.json({ accounts: auth.getGatewayAccounts(c), superAdmin: auth.isSuperAdmin(c) }, 200) +}) + +export { me } diff --git a/packages/bhono-app/templates/base/src/shared/types/api.ts b/packages/bhono-app/templates/base/src/shared/types/api.ts index 9b49fe4..53cb2bc 100644 --- a/packages/bhono-app/templates/base/src/shared/types/api.ts +++ b/packages/bhono-app/templates/base/src/shared/types/api.ts @@ -174,6 +174,45 @@ export interface paths { patch?: never; trace?: never; }; + "/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * The current user's gateway accounts and super-admin flag + * @description Per-account roles resolved by the gateway via gatewayAuthority. Returns an empty list and superAdmin:false when gatewayAuthority is disabled. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The current user's gateway account context */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GatewayAccountContext"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -226,6 +265,17 @@ export interface components { */ publicUrl: string; }; + GatewayAccountContext: { + accounts: components["schemas"]["GatewayAccount"][]; + superAdmin: boolean; + }; + GatewayAccount: { + id: string; + slug: string; + name: string; + /** @enum {string} */ + role: "viewer" | "editor" | "manager" | "admin"; + }; }; responses: never; parameters: never; diff --git a/packages/bhono-app/templates/base/tests/e2e/team.spec.ts b/packages/bhono-app/templates/base/tests/e2e/team.spec.ts new file mode 100644 index 0000000..a5bdfcf --- /dev/null +++ b/packages/bhono-app/templates/base/tests/e2e/team.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, isAuthenticated, closeAllDialogs } from './fixtures' + +/** + * Team management (local membership assignment) E2E. + * + * The Team page manages LOCAL account membership (admin/member/guest) — the assignment + * surface the app owns, distinct from the gateway per-account roles shown on Workspaces. + * + * Each test establishes a fresh admin session with its own workspace (test-login's + * ensureUserAccount guarantees an account), so the assignment UI is enabled + * deterministically rather than depending on shared storageState under parallel workers. + * + * @tags @auth + */ +test.describe('Team management @auth', () => { + test.beforeEach(async ({ page }) => { + const authenticated = await isAuthenticated(page) + test.skip(!authenticated, 'No valid session available. Run auth.setup.ts.') + + await page.goto('/login') + const res = await page.request.post('/auth/test-login', { + data: { email: 'team-admin@example.com', name: 'Team Admin', role: 'admin' }, + failOnStatusCode: false, + }) + test.skip(!res.ok(), 'test-login unavailable') + }) + + test('renders the team management page', async ({ page }) => { + await page.goto('/team') + await expect(page).not.toHaveURL(/login/) + await expect(page.getByRole('heading', { name: /team members/i })).toBeVisible() + }) + + test('opens the invite dialog with a role selector (assignment surface)', async ({ page }) => { + await page.goto('/team') + + // The button enables once GET /accounts resolves the current workspace. + const inviteButton = page.getByRole('button', { name: /invite member/i }) + await expect(inviteButton).toBeEnabled({ timeout: 8000 }) + await inviteButton.click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByText(/invite team member/i)).toBeVisible() + + // The role options the local membership contract supports (guest/member/admin). + for (const role of ['guest', 'member', 'admin']) { + await expect(dialog.getByText(new RegExp(`^${role}$`, 'i')).first()).toBeVisible() + } + + await closeAllDialogs(page) + }) +}) diff --git a/packages/bhono-app/templates/base/tests/e2e/workspaces.spec.ts b/packages/bhono-app/templates/base/tests/e2e/workspaces.spec.ts new file mode 100644 index 0000000..043e8e3 --- /dev/null +++ b/packages/bhono-app/templates/base/tests/e2e/workspaces.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, isAuthenticated } from './fixtures' + +/** + * Workspaces (gateway per-account roles) E2E. + * + * The Workspaces page reads the user's gateway accounts from GET /api/me + * (useGatewayAccounts). With the dev gateway mock OFF, /api/me is empty and the page + * shows its empty state; with it ON (ETUS_GATEWAY_MOCK=1 in config/.dev.vars) a scenario user + * sees their per-account roles. These tests adapt to both so the suite stays green in + * CI while still exercising the rich path locally. + * + * @tags @auth + */ +test.describe('Workspaces @auth', () => { + test.beforeEach(async ({ page }) => { + const authenticated = await isAuthenticated(page) + test.skip(!authenticated, 'No valid session available. Run auth.setup.ts.') + }) + + test('renders the Workspaces page for an authenticated user', async ({ page }) => { + await page.goto('/workspaces') + await expect(page).not.toHaveURL(/login/) + + await expect(page.getByRole('heading', { name: /workspaces/i })).toBeVisible() + // Either gateway-role cards (mock on) or the empty state (mock off) — either one + // proves the page rendered through routing + the useGatewayAccounts query. + await expect( + page.getByText(/No workspaces yet|Read-only access|Create and update|Manage members|Invite members/i), + ).toBeVisible() + }) + + test('is reachable from the sidebar nav', async ({ page }) => { + await page.goto('/dashboard') + + const link = page.getByRole('link', { name: /workspaces/i }) + await expect(link).toBeVisible() + await link.click() + + await expect(page).toHaveURL(/workspaces/) + await expect(page.getByRole('heading', { name: /workspaces/i })).toBeVisible() + }) + + test('shows per-account gateway roles when the dev mock is enabled', async ({ page, baseURL }) => { + // Log in as a scenario user: admin on Initech + viewer on Acme (the over-grant case). + const login = await page.request.post('/auth/test-login', { + data: { email: 'multi@example.com', name: 'Multi Workspace' }, + failOnStatusCode: false, + }) + test.skip(!login.ok(), 'test-login unavailable') + + // Only assert the rich UI when the gateway mock is actually serving the scenario. + const me = await page.request.get(`${baseURL ?? ''}/api/me`) + const body = (me.ok() ? await me.json() : { accounts: [] }) as { accounts?: unknown[] } + test.skip( + !Array.isArray(body.accounts) || body.accounts.length === 0, + 'gateway mock disabled — set ETUS_GATEWAY_MOCK=1 in config/.dev.vars to run this', + ) + + await page.goto('/workspaces') + await expect(page.getByRole('heading', { name: /workspaces/i })).toBeVisible() + + // The two distinct per-account roles render side by side. Use exact matches so the + // workspace NAME ("Initech") isn't conflated with its slug ("initech"). + await expect(page.getByText('Initech', { exact: true })).toBeVisible() + await expect(page.getByText('Acme Corporation', { exact: true })).toBeVisible() + await expect(page.getByText('admin', { exact: true }).first()).toBeVisible() + await expect(page.getByText('viewer', { exact: true }).first()).toBeVisible() + }) +}) diff --git a/packages/bhono-app/templates/base/tests/integration/accounts/membership.test.ts b/packages/bhono-app/templates/base/tests/integration/accounts/membership.test.ts new file mode 100644 index 0000000..34c5194 --- /dev/null +++ b/packages/bhono-app/templates/base/tests/integration/accounts/membership.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import type { Hono } from 'hono' +import { buildApp } from '@server/index' +import type { HonoEnv } from '@server/types' +import type { Env } from '@server/env' +import { getEnv, getSqlite, seedUser, seedUserAccount } from '../setup' + +// Mint a real session via dev test-login and return the session cookie + the account +// test-login provisioned for the user (ensureUserAccount). +async function login( + app: Hono, + env: Env, + email: string, + role = 'admin', +): Promise<{ cookie: string; accountId: string }> { + const res = await app.request( + 'http://localhost/auth/test-login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, name: email.split('@')[0], role }), + }, + env, + ) + expect(res.status).toBe(200) + const setCookie = res.headers.get('set-cookie') ?? '' + const match = /(?:^|,\s*)(auth_sid=[^;]+)/.exec(setCookie) + if (!match) throw new Error(`no session cookie in Set-Cookie: ${setCookie}`) + const body = (await res.json()) as { accountId: string } + return { cookie: match[1], accountId: body.accountId } +} + +// Local membership management (admin/member/guest) is the assignment surface the app +// owns, driven through @etus/auth's account routes. These run against the real worker +// via buildApp() (not the Vite dev server), so /accounts resolves normally. +describe('local membership assignment (account routes)', () => { + it('GET /accounts returns the workspace test-login provisioned for the user', async () => { + const env = getEnv() + const app = buildApp(env) + const { cookie, accountId } = await login(app, env, 'team-admin@example.com') + + const res = await app.request( + 'http://localhost/accounts', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as { accounts: { id: string }[] } + expect(body.accounts.map((a) => a.id)).toContain(accountId) + }) + + it('PATCH /accounts/:id/members/:userId assigns a new role to a member', async () => { + const env = getEnv() + const app = buildApp(env) + const { cookie, accountId } = await login(app, env, 'team-admin@example.com') + + // A second user, seeded as a plain member of the admin's workspace. + const member = await seedUser({ email: 'teammate@example.com', name: 'Teammate', role: 'member', status: 'active' }) + await seedUserAccount({ userId: member.id, accountId, role: 'member' }) + + const res = await app.request( + `http://localhost/accounts/${accountId}/members/${member.id}`, + { + method: 'PATCH', + headers: { Cookie: cookie, 'Content-Type': 'application/json', Origin: 'http://localhost' }, + body: JSON.stringify({ role: 'admin' }), + }, + env, + ) + + expect(res.status).toBe(200) + // The assignment persisted to the membership row. + const row = getSqlite() + .prepare('SELECT role FROM auth_memberships WHERE account_id = ? AND user_id = ?') + .get(accountId, member.id) as { role: string } | undefined + expect(row?.role).toBe('admin') + }) + + it('POST /accounts/:id/members/invite creates a pending invitation with the chosen role', async () => { + const env = getEnv() + const app = buildApp(env) + const { cookie, accountId } = await login(app, env, 'team-admin@example.com') + + const res = await app.request( + `http://localhost/accounts/${accountId}/members/invite`, + { + method: 'POST', + headers: { Cookie: cookie, 'Content-Type': 'application/json', Origin: 'http://localhost' }, + body: JSON.stringify({ email: 'invitee@example.com', role: 'member' }), + }, + env, + ) + + expect(res.status).toBeLessThan(300) + // A pending invitation row exists for the invitee at the chosen role. + const row = getSqlite() + .prepare('SELECT role FROM auth_invitations WHERE account_id = ? AND email = ?') + .get(accountId, 'invitee@example.com') as { role: string } | undefined + expect(row?.role).toBe('member') + }) +}) diff --git a/packages/bhono-app/templates/base/tests/integration/api/me.test.ts b/packages/bhono-app/templates/base/tests/integration/api/me.test.ts new file mode 100644 index 0000000..785bd90 --- /dev/null +++ b/packages/bhono-app/templates/base/tests/integration/api/me.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest' +import type { Hono } from 'hono' +import { buildApp } from '@server/index' +import type { HonoEnv } from '@server/types' +import type { Env } from '@server/env' +import { getEnv } from '../setup' + +interface MeBody { + accounts: { id: string; slug: string; name: string; role: string }[] + superAdmin: boolean +} + +// Mint a real session via the dev /auth/test-login endpoint and return the session +// cookie ("auth_sid="), so /api/me can be exercised as an authenticated user +// through the FULL production middleware — the same path E2E relies on. Both requests +// go to http://localhost (test-login is loopback-gated) with no fingerprint headers, +// so the login + follow-up share the same ("unknown") fingerprint. +async function login( + app: Hono, + env: Env, + email: string, + role = 'member', +): Promise { + const res = await app.request( + 'http://localhost/auth/test-login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, name: email.split('@')[0], role }), + }, + env, + ) + expect(res.status).toBe(200) + const setCookie = res.headers.get('set-cookie') ?? '' + const match = /(?:^|,\s*)(auth_sid=[^;]+)/.exec(setCookie) + if (!match) throw new Error(`no session cookie in Set-Cookie: ${setCookie}`) + return match[1] +} + +// GET /api/me exposes the caller's gateway accounts + super-admin flag, so it MUST +// sit behind the /api/* requireAuthContext gate wired in src/server/index.ts. +// +// The unit test (tests/unit/server/routes/me.test.ts) mounts the `me` router BARE +// — without that gate — so it cannot prove the production wiring rejects +// unauthenticated callers. This suite builds the REAL app via buildApp() and +// drives requests through the full middleware stack to verify the gate. +// +// `getEnv()` is passed as the third arg to app.request so c.env is populated for +// validateEnv + the auth middleware; the hostname is localhost so validateEnv +// runs in loopback mode. +describe('GET /api/me — auth gate (production wiring)', () => { + it('returns 401 for an unauthenticated request (no session cookie)', async () => { + const env = getEnv() + const app = buildApp(env) + + const res = await app.request('http://localhost/api/me', { method: 'GET' }, env) + + expect(res.status).toBe(401) + // The gate short-circuits before the handler runs, so no account/super-admin + // info leaks to an unauthenticated caller. + const body = await res.text() + expect(body).not.toContain('"accounts"') + expect(body).not.toContain('"superAdmin"') + }) + + it('keeps public routes reachable in the same app (the gate is scoped to /api/*, not a boot failure)', async () => { + // Guards against a false-positive 401: if buildApp failed to boot, every route + // would error. A reachable public /health proves the 401 above is the auth + // gate specifically, not the whole app falling over. + const env = getEnv() + const app = buildApp(env) + + const res = await app.request('http://localhost/health', { method: 'GET' }, env) + + expect(res.status).toBe(200) + }) +}) + +// The dev gateway mock (src/server/dev/gateway-scenario.ts) lets the multi-tenant UI +// be validated without a live gateway. These tests drive it end-to-end: a real session +// (test-login) → the /api/* gate → the /api/me handler → the scenario fixture, proving +// the signed-in user's per-account gateway roles surface correctly. +describe('GET /api/me — gateway mock resolution', () => { + // Enable the dev mock on top of the integration env (ENVIRONMENT='test' ≠ production). + function mockEnv(): Env { + return { ...getEnv(), ETUS_GATEWAY_MOCK: '1' } + } + + it("resolves the signed-in user's scenario through the real middleware", async () => { + const env = mockEnv() + const app = buildApp(env) + const cookie = await login(app, env, 'multi@example.com') + + const res = await app.request( + 'http://localhost/api/me', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as MeBody + expect(body.superAdmin).toBe(false) + // The over-grant case: admin on an UNRELATED workspace (Initech) + viewer on Acme. + expect(body.accounts).toEqual([ + { id: 'gw-initech', slug: 'initech', name: 'Initech', role: 'admin' }, + { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'viewer' }, + ]) + }) + + it('reflects super-admin for a super-admin scenario user', async () => { + const env = mockEnv() + const app = buildApp(env) + const cookie = await login(app, env, 'superadmin@example.com', 'owner') + + const res = await app.request( + 'http://localhost/api/me', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as MeBody + expect(body).toEqual({ accounts: [], superAdmin: true }) + }) + + it('returns the safe empty shape when the mock is OFF (no flag set)', async () => { + // Without ETUS_GATEWAY_MOCK the handler takes the real path; gatewayAuthority is + // off in tests, so a valid session still resolves to the empty context. + const env = getEnv() + const app = buildApp(env) + const cookie = await login(app, env, 'multi@example.com') + + const res = await app.request( + 'http://localhost/api/me', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as MeBody + expect(body).toEqual({ accounts: [], superAdmin: false }) + }) +}) diff --git a/packages/bhono-app/templates/base/tests/unit/client/hooks/use-gateway-accounts.test.tsx b/packages/bhono-app/templates/base/tests/unit/client/hooks/use-gateway-accounts.test.tsx new file mode 100644 index 0000000..706fa61 --- /dev/null +++ b/packages/bhono-app/templates/base/tests/unit/client/hooks/use-gateway-accounts.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useGatewayAccounts } from '@/hooks/use-gateway-accounts' + +const mockFetch = vi.fn() +global.fetch = mockFetch + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +const jsonResponse = (body: unknown) => ({ ok: true, json: async () => body }) as Response + +describe('useGatewayAccounts', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.restoreAllMocks()) + + it("exposes the user's gateway accounts and super-admin flag from /api/me", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + accounts: [{ id: 'a', slug: 'unum', name: 'Unum', role: 'manager' }], + superAdmin: false, + }), + ) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.accounts).toEqual([ + { id: 'a', slug: 'unum', name: 'Unum', role: 'manager' }, + ]) + expect(result.current.superAdmin).toBe(false) + expect(mockFetch).toHaveBeenCalledWith('/api/me', { credentials: 'include' }) + }) + + it('hasAccountRole respects the cumulative hierarchy', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + accounts: [{ id: 'a', slug: 'unum', name: 'Unum', role: 'manager' }], + superAdmin: false, + }), + ) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.hasAccountRole('unum', 'editor')).toBe(true) // manager >= editor + expect(result.current.hasAccountRole('unum', 'admin')).toBe(false) // manager < admin + expect(result.current.hasAccountRole('outro', 'viewer')).toBe(false) // not a member + }) + + it('super-admin passes every account-role check', async () => { + mockFetch.mockResolvedValue(jsonResponse({ accounts: [], superAdmin: true })) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.hasAccountRole('anything', 'admin')).toBe(true) + }) + + it('falls back to a safe empty shape when /api/me fails', async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({}) } as Response) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.accounts).toEqual([]) + expect(result.current.superAdmin).toBe(false) + }) +}) diff --git a/packages/bhono-app/templates/base/tests/unit/client/routes/_authenticated/workspaces.test.tsx b/packages/bhono-app/templates/base/tests/unit/client/routes/_authenticated/workspaces.test.tsx new file mode 100644 index 0000000..c0b9807 --- /dev/null +++ b/packages/bhono-app/templates/base/tests/unit/client/routes/_authenticated/workspaces.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import { renderRoute, setupFetchMock } from '@tests/helpers/client-test-utils' + +// Build a /api/me handler for the gateway account context the page reads via +// useGatewayAccounts. (The default mock returns {} → the empty state.) +function meResponse(accounts: unknown[], superAdmin = false) { + return () => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ accounts, superAdmin }), + } as Response) +} + +describe('Workspaces Page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the title and description', async () => { + setupFetchMock() + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Workspaces' })).toBeInTheDocument() + }) + expect(screen.getByText(/the role you hold in each/i)).toBeInTheDocument() + }) + + it('shows the empty state when the user has no gateway workspaces', async () => { + setupFetchMock() // default /api/me → {} → no accounts + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByText(/No workspaces yet/i)).toBeInTheDocument() + }) + }) + + it('renders a card per gateway account with its role and blurb', async () => { + setupFetchMock({ + '/api/me': meResponse([ + { id: 'gw-initech', slug: 'initech', name: 'Initech', role: 'admin' }, + { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'viewer' }, + ]), + }) + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByText('Initech')).toBeInTheDocument() + }) + expect(screen.getByText('Acme Corporation')).toBeInTheDocument() + // Per-account role badges (the over-grant scenario: admin on one, viewer on another). + expect(screen.getByText('admin')).toBeInTheDocument() + expect(screen.getByText('viewer')).toBeInTheDocument() + // The blurb mirrors ACCOUNT_ROLE_MAP at the UI level. + expect(screen.getByText(/Read-only access to resources/i)).toBeInTheDocument() + }) + + it('shows the super-admin banner when the user is a super admin', async () => { + setupFetchMock({ '/api/me': meResponse([], true) }) + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByText('Super admin')).toBeInTheDocument() + }) + expect(screen.getByText(/admin-level access across every workspace/i)).toBeInTheDocument() + }) +}) diff --git a/packages/bhono-app/templates/base/tests/unit/server/auth/matrix.test.ts b/packages/bhono-app/templates/base/tests/unit/server/auth/matrix.test.ts index f440bad..80c8af3 100644 --- a/packages/bhono-app/templates/base/tests/unit/server/auth/matrix.test.ts +++ b/packages/bhono-app/templates/base/tests/unit/server/auth/matrix.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from 'vitest' +import { ACCOUNT_ROLES, mapAccountRolesToPermissions, hasPermission } from '@etus/auth' +import type { GatewayAccount } from '@etus/auth' import { ROLES, ROLE_HIERARCHY, PERMISSIONS_MATRIX, PERMISSION_CATALOG, + ACCOUNT_ROLE_MAP, } from '@server/auth/matrix' // Guards the invariant that @etus/auth depends on: createAuth({ permissions }) @@ -43,3 +46,95 @@ describe('RBAC matrix', () => { } }) }) + +// Gateway per-account roles (viewer < editor < manager < admin, migration 0070) +// mapped to local permissions via ACCOUNT_ROLE_MAP. Drift here silently mis-grants +// when the gateway resolves a per-account role for a user. +describe('ACCOUNT_ROLE_MAP', () => { + it("maps exactly @etus/auth's gateway account roles (anchored to the package, not a literal copy)", () => { + // Importing ACCOUNT_ROLES from the package makes this a REAL drift guard: a + // future @etus/auth that adds/renames a role fails this test instead of + // silently mis-granting. + expect(Object.keys(ACCOUNT_ROLE_MAP).sort()).toEqual([...ACCOUNT_ROLES].sort()) + }) + + it('every permission exists in PERMISSION_CATALOG', () => { + const catalog = new Set(PERMISSION_CATALOG) + for (const [role, perms] of Object.entries(ACCOUNT_ROLE_MAP)) { + for (const perm of perms) { + expect( + catalog.has(perm), + `permission "${perm}" (account role "${role}") is not in PERMISSION_CATALOG`, + ).toBe(true) + } + } + }) + + it('contains NO wildcards (the map is unioned across all accounts, so a wildcard would over-grant the whole app)', () => { + for (const [role, perms] of Object.entries(ACCOUNT_ROLE_MAP)) { + for (const perm of perms) { + expect( + perm === '*' || perm.endsWith(':*'), + `account role "${role}" maps to wildcard "${perm}" — forbidden: ACCOUNT_ROLE_MAP is unioned across every gateway account a user holds, so a wildcard lets admin-on-any-account pass every guard. Use requireGatewayAccountRole(slug, role) for precise per-account authority.`, + ).toBe(false) + } + } + }) + + it('is cumulative: viewer ⊆ editor ⊆ manager ⊆ admin', () => { + const tiers = ['viewer', 'editor', 'manager', 'admin'] as const + for (let i = 1; i < tiers.length; i++) { + const higher = new Set(ACCOUNT_ROLE_MAP[tiers[i]]) + for (const p of ACCOUNT_ROLE_MAP[tiers[i - 1]]) { + expect(higher.has(p), `${tiers[i]} is missing ${tiers[i - 1]} permission "${p}"`).toBe(true) + } + } + }) + + it('does not grant destructive permissions org-wide (no delete / billing:manage / account:delete)', () => { + const forbidden = new Set(['resources:delete', 'account:delete', 'billing:manage']) + for (const [role, perms] of Object.entries(ACCOUNT_ROLE_MAP)) { + for (const perm of perms) { + expect(forbidden.has(perm), `account role "${role}" grants destructive "${perm}" org-wide`).toBe( + false, + ) + } + } + }) +}) + +// Over-grant regression (PR #62 review): @etus/auth UNIONS ACCOUNT_ROLE_MAP across +// EVERY gateway account a user holds (super-admin = admin everywhere). With the old +// `admin: ['*']` this meant admin-on-any-account → full app access. These tests pin +// the closed behavior against the package's REAL mapper + permission check, so a +// regression that re-introduces a wildcard/destructive grant fails here. +describe('ACCOUNT_ROLE_MAP cross-account union does not over-grant', () => { + const acct = (slug: string, role: GatewayAccount['role']): GatewayAccount => ({ + id: `acct-${slug}`, + slug, + name: slug, + role, + }) + + it('admin on an UNRELATED account does not grant a wildcard or destructive permission app-wide', () => { + // Low-privilege in the relevant workspace (viewer), but admin on a side account. + const perms = mapAccountRolesToPermissions( + [acct('unum', 'viewer'), acct('side-project', 'admin')], + ACCOUNT_ROLE_MAP, + false, + ) + expect(perms).not.toContain('*') + expect(hasPermission('resources:delete', perms)).toBe(false) + expect(hasPermission('account:delete', perms)).toBe(false) + expect(hasPermission('billing:manage', perms)).toBe(false) + // It DOES grant the bounded admin perms (the map's intended org-level baseline). + expect(hasPermission('members:role', perms)).toBe(true) + }) + + it('super-admin resolves to the bounded admin set, not a wildcard', () => { + const perms = mapAccountRolesToPermissions([], ACCOUNT_ROLE_MAP, true) + expect(perms).not.toContain('*') + expect(hasPermission('resources:delete', perms)).toBe(false) + expect(hasPermission('members:role', perms)).toBe(true) + }) +}) diff --git a/packages/bhono-app/templates/base/tests/unit/server/dev/gateway-scenario.test.ts b/packages/bhono-app/templates/base/tests/unit/server/dev/gateway-scenario.test.ts new file mode 100644 index 0000000..be48456 --- /dev/null +++ b/packages/bhono-app/templates/base/tests/unit/server/dev/gateway-scenario.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import type { Env } from '@server/env' +import { + GATEWAY_MOCK_SCENARIO, + isGatewayMockEnabled, + resolveMockGatewayContext, + type GatewayAccountRole, +} from '@server/dev/gateway-scenario' + +const ROLES: GatewayAccountRole[] = ['viewer', 'editor', 'manager', 'admin'] + +// The resolver only reads ENVIRONMENT + ETUS_GATEWAY_MOCK; build a minimal Env. +function env(environment: string, mock?: string): Env { + return { ENVIRONMENT: environment, ETUS_GATEWAY_MOCK: mock } as unknown as Env +} + +describe('gateway mock — dev gating', () => { + it('is disabled in production even with the flag set', () => { + expect(isGatewayMockEnabled(env('production', '1'))).toBe(false) + // null → real path (the caller falls back to the live gateway resolution). + expect(resolveMockGatewayContext(env('production', '1'), 'admin@example.com')).toBeNull() + }) + + it('is disabled in dev without the flag', () => { + expect(isGatewayMockEnabled(env('development', undefined))).toBe(false) + expect(resolveMockGatewayContext(env('development'), 'admin@example.com')).toBeNull() + }) + + it('is enabled in a non-production env with the flag ("1" or "true")', () => { + expect(isGatewayMockEnabled(env('development', '1'))).toBe(true) + expect(isGatewayMockEnabled(env('test', 'true'))).toBe(true) + }) +}) + +describe('gateway mock — resolution (enabled)', () => { + const e = env('development', '1') + + it('resolves a known user to their scenario', () => { + expect(resolveMockGatewayContext(e, 'admin@example.com')).toEqual({ + accounts: [{ id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'admin' }], + superAdmin: false, + }) + }) + + it('is case-insensitive on email', () => { + expect(resolveMockGatewayContext(e, 'ADMIN@EXAMPLE.COM')).toEqual( + resolveMockGatewayContext(e, 'admin@example.com'), + ) + }) + + it('resolves an unknown / missing email to the safe empty context', () => { + const empty = { accounts: [], superAdmin: false } + expect(resolveMockGatewayContext(e, 'nobody@example.com')).toEqual(empty) + expect(resolveMockGatewayContext(e, null)).toEqual(empty) + expect(resolveMockGatewayContext(e, undefined)).toEqual(empty) + }) + + it('models a super-admin as superAdmin:true with no per-account rows', () => { + expect(resolveMockGatewayContext(e, 'superadmin@example.com')).toEqual({ + accounts: [], + superAdmin: true, + }) + }) + + // The headline scenario: this is the cross-account over-grant case the conservative + // ACCOUNT_ROLE_MAP guards (see tests/unit/server/auth/matrix.test.ts). Pinning the + // fixture keeps the demo honest — admin on an UNRELATED workspace + viewer on Acme — + // so the Workspaces UI and the over-grant tests stay in sync. + it('models the over-grant case: admin on initech + viewer on acme', () => { + const ctx = resolveMockGatewayContext(e, 'multi@example.com') + expect(ctx?.superAdmin).toBe(false) + expect(ctx?.accounts).toEqual([ + { id: 'gw-initech', slug: 'initech', name: 'Initech', role: 'admin' }, + { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'viewer' }, + ]) + }) +}) + +describe('gateway mock — fixture invariants', () => { + it('every account has id/slug/name and a valid role', () => { + for (const [email, ctx] of Object.entries(GATEWAY_MOCK_SCENARIO)) { + expect(typeof ctx.superAdmin, `${email}.superAdmin`).toBe('boolean') + for (const acct of ctx.accounts) { + expect(acct.id, `${email} account id`).toBeTruthy() + expect(acct.slug, `${email} account slug`).toBeTruthy() + expect(acct.name, `${email} account name`).toBeTruthy() + expect(ROLES, `${email} → ${acct.slug} role "${acct.role}"`).toContain(acct.role) + } + } + }) + + it('scenario keys are lowercase emails (lookups are lowercased)', () => { + for (const email of Object.keys(GATEWAY_MOCK_SCENARIO)) { + expect(email).toBe(email.toLowerCase()) + } + }) +}) diff --git a/packages/bhono-app/templates/base/tests/unit/server/routes/me.test.ts b/packages/bhono-app/templates/base/tests/unit/server/routes/me.test.ts new file mode 100644 index 0000000..4697e6e --- /dev/null +++ b/packages/bhono-app/templates/base/tests/unit/server/routes/me.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { OpenAPIHono } from '@hono/zod-openapi' +import { me } from '@server/routes/me' +import { createMockEnv } from '@tests/helpers/server' +import type { HonoEnv } from '@server/types' + +describe('GET /api/me (gateway account context)', () => { + it('returns a safe empty shape when no gateway context is present (gatewayAuthority off)', async () => { + const res = await me.request('/', {}, createMockEnv()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ accounts: [], superAdmin: false }) + }) + + it("reflects the user's gateway accounts and super-admin flag from context", async () => { + const app = new OpenAPIHono() + app.use('*', async (c, next) => { + // The @etus/auth middleware sets these under /api/*; inject them directly here. + const set = c.set.bind(c) as (key: string, value: unknown) => void + set('authAccounts', [{ id: 'acct-1', slug: 'unum', name: 'Unum', role: 'manager' }]) + set('authSuperAdmin', true) + await next() + }) + app.route('/', me) + + const res = await app.request('/', {}, createMockEnv()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + accounts: [{ id: 'acct-1', slug: 'unum', name: 'Unum', role: 'manager' }], + superAdmin: true, + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 725104b..039546c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: .: dependencies: '@etus/auth': - specifier: ^0.8.0 - version: 0.8.0(hono@4.12.21)(react@19.2.6) + specifier: ^0.9.1 + version: 0.9.1(hono@4.12.21)(react@19.2.6) '@etus/seven-react': specifier: 0.1.0-beta.3 version: 0.1.0-beta.3(@floating-ui/dom@1.7.6)(@tiptap/extension-code-block@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1) @@ -1066,8 +1066,8 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@etus/auth@0.8.0': - resolution: {integrity: sha512-0PpNauEWrTuNfckoZDANU8xIMcX20Y2IrkEZcyJhYnWK4B2LhYkq+UvsPe2DMGZXZ82kWZfyGOude4cia/jdRQ==} + '@etus/auth@0.9.1': + resolution: {integrity: sha512-G7iIu7uoLFD4NHbvM4JEEfYuIhC+VuHXHFr5AEeLapEMBJMjttZctts7Ciju1ZUHythLZWw7PgZPq6UvZsMD4Q==} hasBin: true peerDependencies: hono: '>=4.12.18 <5.0.0' @@ -6359,7 +6359,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@etus/auth@0.8.0(hono@4.12.21)(react@19.2.6)': + '@etus/auth@0.9.1(hono@4.12.21)(react@19.2.6)': dependencies: '@clack/prompts': 1.4.0 commander: 14.0.3 diff --git a/scripts/sync-template.sh b/scripts/sync-template.sh index a651e8d..7ea6fd3 100755 --- a/scripts/sync-template.sh +++ b/scripts/sync-template.sh @@ -53,6 +53,7 @@ rsync -av --checksum --delete \ --exclude='.env' \ --exclude='.dev.vars' \ --exclude='db.sqlite' \ + --exclude='seed.sql' \ --exclude='*.log' \ --exclude='.DS_Store' \ --exclude='packages/' \ diff --git a/src/client/components/sidebar.tsx b/src/client/components/sidebar.tsx index 35c9cd6..02cbb2f 100644 --- a/src/client/components/sidebar.tsx +++ b/src/client/components/sidebar.tsx @@ -14,6 +14,7 @@ interface SidebarProps { const mainNavItems = [ { to: '/dashboard', label: 'Dashboard', icon: Icons.dashboard }, { to: '/team', label: 'Team', icon: Icons.users }, + { to: '/workspaces', label: 'Workspaces', icon: Icons.layers }, { to: '/integrations', label: 'Integrations', icon: Icons.blocks }, ] diff --git a/src/client/hooks/use-gateway-accounts.ts b/src/client/hooks/use-gateway-accounts.ts new file mode 100644 index 0000000..560f499 --- /dev/null +++ b/src/client/hooks/use-gateway-accounts.ts @@ -0,0 +1,64 @@ +// src/client/hooks/use-gateway-accounts.ts +// +// Reads the user's GATEWAY account context from `GET /api/me` (gateway-as-authority, +// @etus/auth v0.9.1): the per-account roles the gateway resolved for the user plus +// the global super-admin flag. This is the org-level dimension the app reads for +// authorization, alongside its own local workspaces. Always resolves to a safe +// shape (empty / false) when gatewayAuthority is disabled. +import { useQuery } from '@tanstack/react-query' + +export type AccountRole = 'viewer' | 'editor' | 'manager' | 'admin' + +export interface GatewayAccount { + id: string + slug: string + name: string + role: AccountRole +} + +interface GatewayAccountContext { + accounts: GatewayAccount[] + superAdmin: boolean +} + +// Cumulative hierarchy: viewer < editor < manager < admin (gateway migration 0070). +const ROLE_RANK: Record = { viewer: 0, editor: 1, manager: 2, admin: 3 } + +async function fetchGatewayAccounts(): Promise { + const res = await fetch('/api/me', { credentials: 'include' }) + if (!res.ok) return { accounts: [], superAdmin: false } + return res.json() +} + +export function useGatewayAccounts() { + const { data, isLoading } = useQuery({ + queryKey: ['gateway', 'accounts'], + queryFn: fetchGatewayAccounts, + retry: false, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 30, + refetchOnMount: false, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }) + + const accounts = data?.accounts ?? [] + const superAdmin = data?.superAdmin ?? false + + return { + accounts, + superAdmin, + isLoading, + /** + * True when the user holds at least `role` on the gateway account `slug` + * (cumulative; a super-admin always passes). Mirrors the server-side + * `auth.requireGatewayAccountRole` / `hasGatewayAccountRole` — use it to gate + * UI, NOT as a security boundary (the server guards remain authoritative). + */ + hasAccountRole(slug: string, role: AccountRole): boolean { + if (superAdmin) return true + const membership = accounts.find((a) => a.slug === slug) + return membership ? ROLE_RANK[membership.role] >= ROLE_RANK[role] : false + }, + } +} diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 35baad4..b0903ab 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as SplatRouteImport } from './routes/$' import { Route as IndexRouteImport } from './routes/index' import { Route as InviteTokenRouteImport } from './routes/invite.$token' +import { Route as AuthenticatedWorkspacesRouteImport } from './routes/_authenticated/workspaces' import { Route as AuthenticatedTeamRouteImport } from './routes/_authenticated/team' import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings' import { Route as AuthenticatedIntegrationsRouteImport } from './routes/_authenticated/integrations' @@ -44,6 +45,11 @@ const InviteTokenRoute = InviteTokenRouteImport.update({ path: '/invite/$token', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedWorkspacesRoute = AuthenticatedWorkspacesRouteImport.update({ + id: '/workspaces', + path: '/workspaces', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedTeamRoute = AuthenticatedTeamRouteImport.update({ id: '/team', path: '/team', @@ -80,6 +86,7 @@ export interface FileRoutesByFullPath { '/integrations': typeof AuthenticatedIntegrationsRoute '/settings': typeof AuthenticatedSettingsRoute '/team': typeof AuthenticatedTeamRoute + '/workspaces': typeof AuthenticatedWorkspacesRoute '/invite/$token': typeof InviteTokenRoute } export interface FileRoutesByTo { @@ -91,6 +98,7 @@ export interface FileRoutesByTo { '/integrations': typeof AuthenticatedIntegrationsRoute '/settings': typeof AuthenticatedSettingsRoute '/team': typeof AuthenticatedTeamRoute + '/workspaces': typeof AuthenticatedWorkspacesRoute '/invite/$token': typeof InviteTokenRoute } export interface FileRoutesById { @@ -104,6 +112,7 @@ export interface FileRoutesById { '/_authenticated/integrations': typeof AuthenticatedIntegrationsRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute '/_authenticated/team': typeof AuthenticatedTeamRoute + '/_authenticated/workspaces': typeof AuthenticatedWorkspacesRoute '/invite/$token': typeof InviteTokenRoute } export interface FileRouteTypes { @@ -117,6 +126,7 @@ export interface FileRouteTypes { | '/integrations' | '/settings' | '/team' + | '/workspaces' | '/invite/$token' fileRoutesByTo: FileRoutesByTo to: @@ -128,6 +138,7 @@ export interface FileRouteTypes { | '/integrations' | '/settings' | '/team' + | '/workspaces' | '/invite/$token' id: | '__root__' @@ -140,6 +151,7 @@ export interface FileRouteTypes { | '/_authenticated/integrations' | '/_authenticated/settings' | '/_authenticated/team' + | '/_authenticated/workspaces' | '/invite/$token' fileRoutesById: FileRoutesById } @@ -188,6 +200,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InviteTokenRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated/workspaces': { + id: '/_authenticated/workspaces' + path: '/workspaces' + fullPath: '/workspaces' + preLoaderRoute: typeof AuthenticatedWorkspacesRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/team': { id: '/_authenticated/team' path: '/team' @@ -232,6 +251,7 @@ interface AuthenticatedRouteChildren { AuthenticatedIntegrationsRoute: typeof AuthenticatedIntegrationsRoute AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute AuthenticatedTeamRoute: typeof AuthenticatedTeamRoute + AuthenticatedWorkspacesRoute: typeof AuthenticatedWorkspacesRoute } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { @@ -240,6 +260,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedIntegrationsRoute: AuthenticatedIntegrationsRoute, AuthenticatedSettingsRoute: AuthenticatedSettingsRoute, AuthenticatedTeamRoute: AuthenticatedTeamRoute, + AuthenticatedWorkspacesRoute: AuthenticatedWorkspacesRoute, } const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( diff --git a/src/client/routes/_authenticated/workspaces.tsx b/src/client/routes/_authenticated/workspaces.tsx new file mode 100644 index 0000000..3adfae3 --- /dev/null +++ b/src/client/routes/_authenticated/workspaces.tsx @@ -0,0 +1,99 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@etus/seven-react' +import { Icons } from '@/components/icons' +import { useGatewayAccounts, type AccountRole } from '@/hooks/use-gateway-accounts' + +export const Route = createFileRoute('/_authenticated/workspaces')({ + component: WorkspacesPage, +}) + +// UI-only descriptions of what each gateway role grants in THIS app. Mirrors +// ACCOUNT_ROLE_MAP (src/server/auth/matrix.ts) at a human level — it is presentation, +// NOT a security boundary (the server `requirePermission` / `requireGatewayAccountRole` +// guards remain authoritative). +const ROLE_META: Record = { + admin: { color: 'primary', blurb: 'Manage members and account settings, plus create and update resources.' }, + manager: { color: 'info', blurb: 'Invite members and read audit logs, plus create and update resources.' }, + editor: { color: 'success', blurb: 'Create and update resources.' }, + viewer: { color: 'muted', blurb: 'Read-only access to resources.' }, +} + +function WorkspacesPage() { + const { accounts, superAdmin, isLoading } = useGatewayAccounts() + + return ( +
+
+

Workspaces

+

+ The gateway accounts you belong to and the role you hold in each. Roles are + resolved by the gateway (viewer < editor < manager < admin). +

+
+ + {superAdmin && ( + + + +
+ Super admin + + You have admin-level access across every workspace, regardless of the list below. + +
+
+
+ )} + + {isLoading ? ( +
+ + Loading workspaces… +
+ ) : accounts.length === 0 ? ( + + + +

No workspaces yet

+

+ {superAdmin + ? 'You hold no explicit per-workspace role, but super admin grants access everywhere.' + : "You don't belong to any gateway workspace yet. An admin can grant you a role."} +

+
+
+ ) : ( +
+ {accounts.map((account) => { + const meta = ROLE_META[account.role] + return ( + + +
+
+ {account.name} + {account.slug} +
+ + {account.role} + +
+
+ +

{meta.blurb}

+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/server/auth/matrix.ts b/src/server/auth/matrix.ts index e3f2561..eb513c4 100644 --- a/src/server/auth/matrix.ts +++ b/src/server/auth/matrix.ts @@ -77,3 +77,52 @@ export const SCOPE_MAP: Record = { ], 'bhono:viewer': ['account:read', 'resources:read'], } + +// Gateway ACCOUNT role → local permissions (gateway-as-authority for per-account +// roles, @etus/auth v0.9.1). Parallel to SCOPE_MAP, but keyed by the gateway's +// per-account membership role (viewer < editor < manager < admin, from gateway +// migration 0070) instead of a scope. +// +// ⚠️ CRITICAL — this is a COARSE, ORG-LEVEL grant. The package UNIONS these +// permissions across EVERY gateway account the subject belongs to (and treats a +// super-admin as admin everywhere), with NO per-account scoping at the +// `requirePermission` layer. Consequences for the values below: +// * NEVER put `'*'` or a namespace wildcard (`resources:*`) here: a user who is +// `admin` (or holds that role) on ANY single gateway account — even one +// unrelated to this app — would then pass that wildcard guard for the WHOLE +// app. Keep the values an explicit, bounded, NON-DESTRUCTIVE set (no +// `:delete`, no `billing:manage`). +// * For real per-account authority (e.g. "admin OF THIS workspace can delete +// it"), gate with the precise guard `auth.requireGatewayAccountRole(slug, +// role)` / the client `hasAccountRole(slug, role)` — NOT this map. +// * NOTE (hybrid model): permissions here are UNIONED with local-role perms; +// they are additive, so removing a user from a LOCAL account does NOT revoke +// perms the gateway still grants. The gateway is authoritative for what it +// resolves. +// +// KEYS are the fixed gateway account roles (`@etus/auth`'s `ACCOUNT_ROLES`). +// VALUES must be PERMISSION_CATALOG entries. Tune per product, keeping the rules +// above. Roles not mapped contribute nothing. +export const ACCOUNT_ROLE_MAP: Record = { + viewer: ['account:read', 'resources:read'], + editor: ['account:read', 'resources:read', 'resources:create', 'resources:update'], + manager: [ + 'account:read', + 'resources:read', + 'resources:create', + 'resources:update', + 'members:invite', + 'audit:read', + ], + admin: [ + 'account:read', + 'account:update', + 'resources:read', + 'resources:create', + 'resources:update', + 'members:invite', + 'members:remove', + 'members:role', + 'audit:read', + ], +} diff --git a/src/server/auth/setup.ts b/src/server/auth/setup.ts index 77918a7..80d50c4 100644 --- a/src/server/auth/setup.ts +++ b/src/server/auth/setup.ts @@ -32,7 +32,7 @@ import { } from '@etus/auth' import type { Env } from '../env' import { sendInvitationEmail } from '../lib/email' -import { PERMISSIONS_MATRIX, ROLE_HIERARCHY, ROLES, SCOPE_MAP } from './matrix' +import { ACCOUNT_ROLE_MAP, PERMISSIONS_MATRIX, ROLE_HIERARCHY, ROLES, SCOPE_MAP } from './matrix' function parseList(csv: string | undefined): string[] { return (csv ?? '') @@ -101,6 +101,11 @@ function buildAuthConfig(env: Env): AuthConfig { resourceId: env.ETUS_RESOURCE_ID ?? '', integrationKey: (e) => (e as Env).ETUS_INTEGRATION_KEY ?? '', scopeMap: SCOPE_MAP, + // Per-account gateway roles (v0.9.1) → local permissions, unioned across the + // subject's gateway accounts (super-admin counts as admin everywhere). The + // package also exposes `authAccounts`/`authSuperAdmin` and the + // `requireGatewayAccountRole(slug, role)` guard for precise per-account gating. + accountRoleMap: ACCOUNT_ROLE_MAP, ttlSeconds: 300, }, // Required in v0.5.0 when the invitation flow is active — without a diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts index 2adefdc..8758bae 100644 --- a/src/server/db/seed.ts +++ b/src/server/db/seed.ts @@ -49,6 +49,11 @@ const userIds = { analytics: uuid(), user1: uuid(), user2: uuid(), + // Scenario-aligned users (emails match src/server/dev/gateway-scenario.ts so the + // same accounts carry gateway per-account roles when ETUS_GATEWAY_MOCK is on). + multi: uuid(), + pending: uuid(), + suspended: uuid(), } // Accounts @@ -64,8 +69,16 @@ const accountsData = [ { id: accountIds.startup, name: 'Tech Startup', slug: 'startup', ownerId: userIds.manager }, ] -// Users -const usersData = [ +// Users. `status` defaults to 'active' in the SQL below; set it explicitly to seed +// pending/suspended accounts for the team-management UI's status states. +const usersData: { + id: string + email: string + name: string + gatewayUserId: string + role: string + status?: 'active' | 'pending' | 'suspended' | 'denied' +}[] = [ { id: userIds.superadmin, email: 'superadmin@example.com', name: 'Super Admin', gatewayUserId: 'gateway-seed-superadmin-001', role: 'owner' }, { id: userIds.admin, email: 'admin@example.com', name: 'Admin User', gatewayUserId: 'gateway-seed-admin-002', role: 'admin' }, { id: userIds.manager, email: 'manager@example.com', name: 'Manager User', gatewayUserId: 'gateway-seed-manager-003', role: 'admin' }, @@ -76,6 +89,12 @@ const usersData = [ { id: userIds.analytics, email: 'analytics@example.com', name: 'Analytics User', gatewayUserId: 'gateway-seed-analytics-008', role: 'member' }, { id: userIds.user1, email: 'user1@example.com', name: 'Test User 1', gatewayUserId: 'gateway-seed-user1-009', role: 'guest' }, { id: userIds.user2, email: 'user2@example.com', name: 'Test User 2', gatewayUserId: 'gateway-seed-user2-010', role: 'guest' }, + // Multi-workspace user: at the gateway, admin on Initech + viewer on Acme (the + // cross-account over-grant case the conservative ACCOUNT_ROLE_MAP guards). + { id: userIds.multi, email: 'multi@example.com', name: 'Multi Workspace', gatewayUserId: 'gateway-seed-multi-011', role: 'member' }, + // Status states for the team-management UI. + { id: userIds.pending, email: 'pending@example.com', name: 'Pending User', gatewayUserId: 'gateway-seed-pending-012', role: 'guest', status: 'pending' }, + { id: userIds.suspended, email: 'suspended@example.com', name: 'Suspended User', gatewayUserId: 'gateway-seed-suspended-013', role: 'member', status: 'suspended' }, ] // Account memberships. @etus/auth account routes currently use membership role @@ -94,6 +113,8 @@ const membershipsData = [ { id: uuid(), userId: userIds.author, accountId: accountIds.acme, role: 'member' }, { id: uuid(), userId: userIds.billing, accountId: accountIds.acme, role: 'member' }, { id: uuid(), userId: userIds.user2, accountId: accountIds.acme, role: 'guest' }, + { id: uuid(), userId: userIds.multi, accountId: accountIds.acme, role: 'member' }, + { id: uuid(), userId: userIds.suspended, accountId: accountIds.acme, role: 'member' }, // Tech Startup { id: uuid(), userId: userIds.manager, accountId: accountIds.startup, role: 'admin' }, { id: uuid(), userId: userIds.analytics, accountId: accountIds.startup, role: 'member' }, @@ -164,7 +185,7 @@ function generateSQL(): string { // Users lines.push('-- Users') for (const user of usersData) { - lines.push(`INSERT INTO auth_users (id, gateway_user_id, email, name, picture, role, status, invited_by, created_at, last_login_at) VALUES (${toSqlValue(user.id)}, ${toSqlValue(user.gatewayUserId)}, ${toSqlValue(user.email)}, ${toSqlValue(user.name)}, NULL, ${toSqlValue(user.role)}, 'active', NULL, ${toSqlValue(timestamp)}, ${toSqlValue(timestamp)});`) + lines.push(`INSERT INTO auth_users (id, gateway_user_id, email, name, picture, role, status, invited_by, created_at, last_login_at) VALUES (${toSqlValue(user.id)}, ${toSqlValue(user.gatewayUserId)}, ${toSqlValue(user.email)}, ${toSqlValue(user.name)}, NULL, ${toSqlValue(user.role)}, ${toSqlValue(user.status ?? 'active')}, NULL, ${toSqlValue(timestamp)}, ${toSqlValue(timestamp)});`) } lines.push('') @@ -219,7 +240,8 @@ function printSummary(): void { console.log('\n👤 Users:') for (const user of usersData) { - console.log(` • ${user.email} - ${user.name} (${user.role})`) + const status = user.status && user.status !== 'active' ? `, ${user.status}` : '' + console.log(` • ${user.email} - ${user.name} (${user.role}${status})`) } console.log('\n🔗 Account Memberships:') @@ -250,6 +272,11 @@ function printSummary(): void { console.log('\n✅ Run the following command to seed the local database:') console.log(' pnpm db:seed:local') console.log(' # or: wrangler --config config/wrangler.json d1 execute --local --file=seed.sql\n') + + console.log('💡 To see GATEWAY per-account roles (viewer/editor/manager/admin) in the UI,') + console.log(' set ETUS_GATEWAY_MOCK=1 in .dev.vars, then log in via /auth/test-login as a') + console.log(' scenario user (e.g. multi@example.com — admin on Initech + viewer on Acme).') + console.log(' See src/server/dev/gateway-scenario.ts.\n') } // ============================================================================ diff --git a/src/server/dev/gateway-scenario.ts b/src/server/dev/gateway-scenario.ts new file mode 100644 index 0000000..5edae96 --- /dev/null +++ b/src/server/dev/gateway-scenario.ts @@ -0,0 +1,87 @@ +// src/server/dev/gateway-scenario.ts +// +// DEV-ONLY mock of the gateway's per-account role resolution (gateway-as-authority, +// @etus/auth v0.9.1). In production the per-account roles are resolved by the gateway +// over HTTP and read via `auth.getGatewayAccounts(c)`. Locally there is no gateway, so +// this fixture lets you validate the multi-tenant UI and write tests against a known +// scenario WITHOUT a live gateway. +// +// Gated TWICE: it only ever activates when ENVIRONMENT !== 'production' AND +// ETUS_GATEWAY_MOCK is truthy. The `/api/me` route consults resolveMockGatewayContext +// before the real gateway resolution, so production behaviour is untouched. +// +// The scenario is keyed by the signed-in user's email and is deliberately aligned with +// src/server/db/seed.ts, so the same users that exist locally (and can log in via +// /auth/test-login) also carry gateway roles here. Treat it as a replaceable starting +// point for your own demo data, not a contract. + +import type { Env } from '../env' + +export type GatewayAccountRole = 'viewer' | 'editor' | 'manager' | 'admin' + +export interface MockGatewayAccount { + id: string + slug: string + name: string + role: GatewayAccountRole +} + +export interface MockGatewayContext { + accounts: MockGatewayAccount[] + superAdmin: boolean +} + +// Gateway "orgs"/workspaces in the demo scenario. ids are stable so tests can assert on +// them. These are GATEWAY accounts (migration 0070), distinct from the app's own local +// accounts in auth_accounts. +type GatewayOrg = Omit +const ACME: GatewayOrg = { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation' } +const GLOBEX: GatewayOrg = { id: 'gw-globex', slug: 'globex', name: 'Globex Inc' } +const INITECH: GatewayOrg = { id: 'gw-initech', slug: 'initech', name: 'Initech' } + +function at(org: GatewayOrg, role: GatewayAccountRole): MockGatewayAccount { + return { ...org, role } +} + +// email → gateway context. Keys are lowercase; lookups are lowercased too. +export const GATEWAY_MOCK_SCENARIO: Record = { + // Super-admin: no per-account rows needed — counts as admin everywhere. + 'superadmin@example.com': { accounts: [], superAdmin: true }, + // Single-workspace admin. + 'admin@example.com': { accounts: [at(ACME, 'admin')], superAdmin: false }, + // Multi-workspace, mixed roles: manager on Acme, read-only on Globex. + 'manager@example.com': { accounts: [at(ACME, 'manager'), at(GLOBEX, 'viewer')], superAdmin: false }, + // Editor on a single workspace. + 'editor@example.com': { accounts: [at(GLOBEX, 'editor')], superAdmin: false }, + // Read-only. + 'viewer@example.com': { accounts: [at(ACME, 'viewer')], superAdmin: false }, + // OVER-GRANT GUARD CASE: admin on an UNRELATED workspace (Initech) + viewer on Acme. + // Because @etus/auth unions ACCOUNT_ROLE_MAP across EVERY account a user holds, this + // user must NOT gain wildcard/destructive app-wide perms from the Initech admin role + // — the conservative map in src/server/auth/matrix.ts is what keeps that closed. The + // Workspaces UI shows the two distinct per-account roles side by side. + 'multi@example.com': { accounts: [at(INITECH, 'admin'), at(ACME, 'viewer')], superAdmin: false }, +} + +/** True when the dev gateway mock may activate (never in production). */ +export function isGatewayMockEnabled(env: Env): boolean { + return ( + env.ENVIRONMENT !== 'production' && + (env.ETUS_GATEWAY_MOCK === 'true' || env.ETUS_GATEWAY_MOCK === '1') + ) +} + +/** + * The mocked gateway context for `email`, or null when the mock is disabled (so the + * caller falls back to the real gateway resolution). When enabled, an unknown email + * resolves to the safe empty context — the same shape a user with no gateway accounts + * would get from the real gateway. + */ +export function resolveMockGatewayContext( + env: Env, + email: string | null | undefined, +): MockGatewayContext | null { + if (!isGatewayMockEnabled(env)) return null + const key = (email ?? '').toLowerCase() + return GATEWAY_MOCK_SCENARIO[key] ?? { accounts: [], superAdmin: false } +} diff --git a/src/server/env.ts b/src/server/env.ts index 2cfe0c0..304d9cd 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -36,6 +36,10 @@ export interface Env { // The resource-bound integration key (ag_app_., scope app.grants.read). // Secret — set via `wrangler secret put`, never commit it. ETUS_INTEGRATION_KEY?: string + // DEV-ONLY: "true"/"1" makes /api/me serve the local gateway scenario fixture + // (src/server/dev/gateway-scenario.ts) instead of the real gateway, so the + // multi-tenant UI can be validated without a live gateway. Ignored in production. + ETUS_GATEWAY_MOCK?: string // SendGrid (invitations) SENDGRID_API_KEY: string diff --git a/src/server/index.ts b/src/server/index.ts index 0c17582..1ce495d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -27,7 +27,7 @@ import { parseUploadBytes, validateEnv } from './env' // built app is cached for the isolate's lifetime (env is stable per deploy). let appInstance: Hono | undefined -function buildApp(env: Env): Hono { +export function buildApp(env: Env): Hono { const auth = getAuth(env) const app = new Hono() @@ -127,6 +127,12 @@ function buildApp(env: Env): Hono { app.route('/auth', auth.routes()) app.route('/auth/admin', auth.adminRoutes()) app.route('/audit', auth.auditRoutes()) + // Hybrid model: account/member MANAGEMENT stays LOCAL (these @etus/auth account + // routes), while per-account ROLES are READ from the gateway for authorization + // (accountRoleMap + /api/me + requireGatewayAccountRole). The gateway has no + // app-facing write API for member management, so the local routes are retained + // on purpose despite the v0.9.x deprecation. See docs/SETUP-GUIDE.md. + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional in the hybrid model app.route('/accounts', auth.accountRoutes()) app.route('/invitations', auth.invitationRoutes()) diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index cd7515e..715aaef 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -2,6 +2,7 @@ import { OpenAPIHono } from '@hono/zod-openapi' import { NONCE } from 'hono/secure-headers' import type { HonoEnv } from '../types' import { storage } from './storage' +import { me } from './me' import { openApiConfig } from './openapi' // Application API router. Identity/tenancy routes (users, accounts, @@ -10,6 +11,7 @@ import { openApiConfig } from './openapi' const api = new OpenAPIHono() api.route('/storage', storage) +api.route('/me', me) // Session cookie auth scheme (issued by @etus/auth on login) api.openAPIRegistry.registerComponent('securitySchemes', 'SessionCookie', { diff --git a/src/server/routes/me/index.ts b/src/server/routes/me/index.ts new file mode 100644 index 0000000..4e23f36 --- /dev/null +++ b/src/server/routes/me/index.ts @@ -0,0 +1,61 @@ +// src/server/routes/me/index.ts +// +// The current user's GATEWAY account context (gateway-as-authority, @etus/auth +// v0.9.1). Distinct from `/auth/me` (identity, served by @etus/auth): this returns +// the per-account roles the gateway resolved for the user (viewer/editor/manager/ +// admin per account) plus the global super-admin flag — the org-level dimension +// the app reads for authorization, alongside its own local workspaces. +// +// Empty/false when `gatewayAuthority` is disabled, so the client always gets a +// safe shape regardless of env. +import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi' +import type { HonoEnv } from '../../types' +import { getAuth } from '../../auth/setup' +import { resolveMockGatewayContext } from '../../dev/gateway-scenario' + +const GatewayAccountSchema = z + .object({ + id: z.string(), + slug: z.string(), + name: z.string(), + role: z.enum(['viewer', 'editor', 'manager', 'admin']), + }) + .openapi('GatewayAccount') + +const GatewayAccountContextSchema = z + .object({ + accounts: z.array(GatewayAccountSchema), + superAdmin: z.boolean(), + }) + .openapi('GatewayAccountContext') + +const getMeRoute = createRoute({ + method: 'get', + path: '/', + tags: ['Me'], + summary: "The current user's gateway accounts and super-admin flag", + description: + 'Per-account roles resolved by the gateway via gatewayAuthority. ' + + 'Returns an empty list and superAdmin:false when gatewayAuthority is disabled.', + security: [{ SessionCookie: [] }], + responses: { + 200: { + content: { 'application/json': { schema: GatewayAccountContextSchema } }, + description: "The current user's gateway account context", + }, + }, +}) + +const me = new OpenAPIHono() + +me.openapi(getMeRoute, (c) => { + // Dev-only: a configured gateway mock (ENVIRONMENT!=production + ETUS_GATEWAY_MOCK) + // short-circuits the real resolution so the multi-tenant UI can be validated and + // tested without a live gateway. Returns null in production → real path below. + const mocked = resolveMockGatewayContext(c.env, c.get('authUser')?.email) + if (mocked) return c.json(mocked, 200) + const auth = getAuth(c.env) + return c.json({ accounts: auth.getGatewayAccounts(c), superAdmin: auth.isSuperAdmin(c) }, 200) +}) + +export { me } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9b49fe4..53cb2bc 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -174,6 +174,45 @@ export interface paths { patch?: never; trace?: never; }; + "/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * The current user's gateway accounts and super-admin flag + * @description Per-account roles resolved by the gateway via gatewayAuthority. Returns an empty list and superAdmin:false when gatewayAuthority is disabled. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The current user's gateway account context */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GatewayAccountContext"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -226,6 +265,17 @@ export interface components { */ publicUrl: string; }; + GatewayAccountContext: { + accounts: components["schemas"]["GatewayAccount"][]; + superAdmin: boolean; + }; + GatewayAccount: { + id: string; + slug: string; + name: string; + /** @enum {string} */ + role: "viewer" | "editor" | "manager" | "admin"; + }; }; responses: never; parameters: never; diff --git a/tests/e2e/team.spec.ts b/tests/e2e/team.spec.ts new file mode 100644 index 0000000..a5bdfcf --- /dev/null +++ b/tests/e2e/team.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, isAuthenticated, closeAllDialogs } from './fixtures' + +/** + * Team management (local membership assignment) E2E. + * + * The Team page manages LOCAL account membership (admin/member/guest) — the assignment + * surface the app owns, distinct from the gateway per-account roles shown on Workspaces. + * + * Each test establishes a fresh admin session with its own workspace (test-login's + * ensureUserAccount guarantees an account), so the assignment UI is enabled + * deterministically rather than depending on shared storageState under parallel workers. + * + * @tags @auth + */ +test.describe('Team management @auth', () => { + test.beforeEach(async ({ page }) => { + const authenticated = await isAuthenticated(page) + test.skip(!authenticated, 'No valid session available. Run auth.setup.ts.') + + await page.goto('/login') + const res = await page.request.post('/auth/test-login', { + data: { email: 'team-admin@example.com', name: 'Team Admin', role: 'admin' }, + failOnStatusCode: false, + }) + test.skip(!res.ok(), 'test-login unavailable') + }) + + test('renders the team management page', async ({ page }) => { + await page.goto('/team') + await expect(page).not.toHaveURL(/login/) + await expect(page.getByRole('heading', { name: /team members/i })).toBeVisible() + }) + + test('opens the invite dialog with a role selector (assignment surface)', async ({ page }) => { + await page.goto('/team') + + // The button enables once GET /accounts resolves the current workspace. + const inviteButton = page.getByRole('button', { name: /invite member/i }) + await expect(inviteButton).toBeEnabled({ timeout: 8000 }) + await inviteButton.click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByText(/invite team member/i)).toBeVisible() + + // The role options the local membership contract supports (guest/member/admin). + for (const role of ['guest', 'member', 'admin']) { + await expect(dialog.getByText(new RegExp(`^${role}$`, 'i')).first()).toBeVisible() + } + + await closeAllDialogs(page) + }) +}) diff --git a/tests/e2e/workspaces.spec.ts b/tests/e2e/workspaces.spec.ts new file mode 100644 index 0000000..043e8e3 --- /dev/null +++ b/tests/e2e/workspaces.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, isAuthenticated } from './fixtures' + +/** + * Workspaces (gateway per-account roles) E2E. + * + * The Workspaces page reads the user's gateway accounts from GET /api/me + * (useGatewayAccounts). With the dev gateway mock OFF, /api/me is empty and the page + * shows its empty state; with it ON (ETUS_GATEWAY_MOCK=1 in config/.dev.vars) a scenario user + * sees their per-account roles. These tests adapt to both so the suite stays green in + * CI while still exercising the rich path locally. + * + * @tags @auth + */ +test.describe('Workspaces @auth', () => { + test.beforeEach(async ({ page }) => { + const authenticated = await isAuthenticated(page) + test.skip(!authenticated, 'No valid session available. Run auth.setup.ts.') + }) + + test('renders the Workspaces page for an authenticated user', async ({ page }) => { + await page.goto('/workspaces') + await expect(page).not.toHaveURL(/login/) + + await expect(page.getByRole('heading', { name: /workspaces/i })).toBeVisible() + // Either gateway-role cards (mock on) or the empty state (mock off) — either one + // proves the page rendered through routing + the useGatewayAccounts query. + await expect( + page.getByText(/No workspaces yet|Read-only access|Create and update|Manage members|Invite members/i), + ).toBeVisible() + }) + + test('is reachable from the sidebar nav', async ({ page }) => { + await page.goto('/dashboard') + + const link = page.getByRole('link', { name: /workspaces/i }) + await expect(link).toBeVisible() + await link.click() + + await expect(page).toHaveURL(/workspaces/) + await expect(page.getByRole('heading', { name: /workspaces/i })).toBeVisible() + }) + + test('shows per-account gateway roles when the dev mock is enabled', async ({ page, baseURL }) => { + // Log in as a scenario user: admin on Initech + viewer on Acme (the over-grant case). + const login = await page.request.post('/auth/test-login', { + data: { email: 'multi@example.com', name: 'Multi Workspace' }, + failOnStatusCode: false, + }) + test.skip(!login.ok(), 'test-login unavailable') + + // Only assert the rich UI when the gateway mock is actually serving the scenario. + const me = await page.request.get(`${baseURL ?? ''}/api/me`) + const body = (me.ok() ? await me.json() : { accounts: [] }) as { accounts?: unknown[] } + test.skip( + !Array.isArray(body.accounts) || body.accounts.length === 0, + 'gateway mock disabled — set ETUS_GATEWAY_MOCK=1 in config/.dev.vars to run this', + ) + + await page.goto('/workspaces') + await expect(page.getByRole('heading', { name: /workspaces/i })).toBeVisible() + + // The two distinct per-account roles render side by side. Use exact matches so the + // workspace NAME ("Initech") isn't conflated with its slug ("initech"). + await expect(page.getByText('Initech', { exact: true })).toBeVisible() + await expect(page.getByText('Acme Corporation', { exact: true })).toBeVisible() + await expect(page.getByText('admin', { exact: true }).first()).toBeVisible() + await expect(page.getByText('viewer', { exact: true }).first()).toBeVisible() + }) +}) diff --git a/tests/integration/accounts/membership.test.ts b/tests/integration/accounts/membership.test.ts new file mode 100644 index 0000000..34c5194 --- /dev/null +++ b/tests/integration/accounts/membership.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import type { Hono } from 'hono' +import { buildApp } from '@server/index' +import type { HonoEnv } from '@server/types' +import type { Env } from '@server/env' +import { getEnv, getSqlite, seedUser, seedUserAccount } from '../setup' + +// Mint a real session via dev test-login and return the session cookie + the account +// test-login provisioned for the user (ensureUserAccount). +async function login( + app: Hono, + env: Env, + email: string, + role = 'admin', +): Promise<{ cookie: string; accountId: string }> { + const res = await app.request( + 'http://localhost/auth/test-login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, name: email.split('@')[0], role }), + }, + env, + ) + expect(res.status).toBe(200) + const setCookie = res.headers.get('set-cookie') ?? '' + const match = /(?:^|,\s*)(auth_sid=[^;]+)/.exec(setCookie) + if (!match) throw new Error(`no session cookie in Set-Cookie: ${setCookie}`) + const body = (await res.json()) as { accountId: string } + return { cookie: match[1], accountId: body.accountId } +} + +// Local membership management (admin/member/guest) is the assignment surface the app +// owns, driven through @etus/auth's account routes. These run against the real worker +// via buildApp() (not the Vite dev server), so /accounts resolves normally. +describe('local membership assignment (account routes)', () => { + it('GET /accounts returns the workspace test-login provisioned for the user', async () => { + const env = getEnv() + const app = buildApp(env) + const { cookie, accountId } = await login(app, env, 'team-admin@example.com') + + const res = await app.request( + 'http://localhost/accounts', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as { accounts: { id: string }[] } + expect(body.accounts.map((a) => a.id)).toContain(accountId) + }) + + it('PATCH /accounts/:id/members/:userId assigns a new role to a member', async () => { + const env = getEnv() + const app = buildApp(env) + const { cookie, accountId } = await login(app, env, 'team-admin@example.com') + + // A second user, seeded as a plain member of the admin's workspace. + const member = await seedUser({ email: 'teammate@example.com', name: 'Teammate', role: 'member', status: 'active' }) + await seedUserAccount({ userId: member.id, accountId, role: 'member' }) + + const res = await app.request( + `http://localhost/accounts/${accountId}/members/${member.id}`, + { + method: 'PATCH', + headers: { Cookie: cookie, 'Content-Type': 'application/json', Origin: 'http://localhost' }, + body: JSON.stringify({ role: 'admin' }), + }, + env, + ) + + expect(res.status).toBe(200) + // The assignment persisted to the membership row. + const row = getSqlite() + .prepare('SELECT role FROM auth_memberships WHERE account_id = ? AND user_id = ?') + .get(accountId, member.id) as { role: string } | undefined + expect(row?.role).toBe('admin') + }) + + it('POST /accounts/:id/members/invite creates a pending invitation with the chosen role', async () => { + const env = getEnv() + const app = buildApp(env) + const { cookie, accountId } = await login(app, env, 'team-admin@example.com') + + const res = await app.request( + `http://localhost/accounts/${accountId}/members/invite`, + { + method: 'POST', + headers: { Cookie: cookie, 'Content-Type': 'application/json', Origin: 'http://localhost' }, + body: JSON.stringify({ email: 'invitee@example.com', role: 'member' }), + }, + env, + ) + + expect(res.status).toBeLessThan(300) + // A pending invitation row exists for the invitee at the chosen role. + const row = getSqlite() + .prepare('SELECT role FROM auth_invitations WHERE account_id = ? AND email = ?') + .get(accountId, 'invitee@example.com') as { role: string } | undefined + expect(row?.role).toBe('member') + }) +}) diff --git a/tests/integration/api/me.test.ts b/tests/integration/api/me.test.ts new file mode 100644 index 0000000..785bd90 --- /dev/null +++ b/tests/integration/api/me.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest' +import type { Hono } from 'hono' +import { buildApp } from '@server/index' +import type { HonoEnv } from '@server/types' +import type { Env } from '@server/env' +import { getEnv } from '../setup' + +interface MeBody { + accounts: { id: string; slug: string; name: string; role: string }[] + superAdmin: boolean +} + +// Mint a real session via the dev /auth/test-login endpoint and return the session +// cookie ("auth_sid="), so /api/me can be exercised as an authenticated user +// through the FULL production middleware — the same path E2E relies on. Both requests +// go to http://localhost (test-login is loopback-gated) with no fingerprint headers, +// so the login + follow-up share the same ("unknown") fingerprint. +async function login( + app: Hono, + env: Env, + email: string, + role = 'member', +): Promise { + const res = await app.request( + 'http://localhost/auth/test-login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, name: email.split('@')[0], role }), + }, + env, + ) + expect(res.status).toBe(200) + const setCookie = res.headers.get('set-cookie') ?? '' + const match = /(?:^|,\s*)(auth_sid=[^;]+)/.exec(setCookie) + if (!match) throw new Error(`no session cookie in Set-Cookie: ${setCookie}`) + return match[1] +} + +// GET /api/me exposes the caller's gateway accounts + super-admin flag, so it MUST +// sit behind the /api/* requireAuthContext gate wired in src/server/index.ts. +// +// The unit test (tests/unit/server/routes/me.test.ts) mounts the `me` router BARE +// — without that gate — so it cannot prove the production wiring rejects +// unauthenticated callers. This suite builds the REAL app via buildApp() and +// drives requests through the full middleware stack to verify the gate. +// +// `getEnv()` is passed as the third arg to app.request so c.env is populated for +// validateEnv + the auth middleware; the hostname is localhost so validateEnv +// runs in loopback mode. +describe('GET /api/me — auth gate (production wiring)', () => { + it('returns 401 for an unauthenticated request (no session cookie)', async () => { + const env = getEnv() + const app = buildApp(env) + + const res = await app.request('http://localhost/api/me', { method: 'GET' }, env) + + expect(res.status).toBe(401) + // The gate short-circuits before the handler runs, so no account/super-admin + // info leaks to an unauthenticated caller. + const body = await res.text() + expect(body).not.toContain('"accounts"') + expect(body).not.toContain('"superAdmin"') + }) + + it('keeps public routes reachable in the same app (the gate is scoped to /api/*, not a boot failure)', async () => { + // Guards against a false-positive 401: if buildApp failed to boot, every route + // would error. A reachable public /health proves the 401 above is the auth + // gate specifically, not the whole app falling over. + const env = getEnv() + const app = buildApp(env) + + const res = await app.request('http://localhost/health', { method: 'GET' }, env) + + expect(res.status).toBe(200) + }) +}) + +// The dev gateway mock (src/server/dev/gateway-scenario.ts) lets the multi-tenant UI +// be validated without a live gateway. These tests drive it end-to-end: a real session +// (test-login) → the /api/* gate → the /api/me handler → the scenario fixture, proving +// the signed-in user's per-account gateway roles surface correctly. +describe('GET /api/me — gateway mock resolution', () => { + // Enable the dev mock on top of the integration env (ENVIRONMENT='test' ≠ production). + function mockEnv(): Env { + return { ...getEnv(), ETUS_GATEWAY_MOCK: '1' } + } + + it("resolves the signed-in user's scenario through the real middleware", async () => { + const env = mockEnv() + const app = buildApp(env) + const cookie = await login(app, env, 'multi@example.com') + + const res = await app.request( + 'http://localhost/api/me', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as MeBody + expect(body.superAdmin).toBe(false) + // The over-grant case: admin on an UNRELATED workspace (Initech) + viewer on Acme. + expect(body.accounts).toEqual([ + { id: 'gw-initech', slug: 'initech', name: 'Initech', role: 'admin' }, + { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'viewer' }, + ]) + }) + + it('reflects super-admin for a super-admin scenario user', async () => { + const env = mockEnv() + const app = buildApp(env) + const cookie = await login(app, env, 'superadmin@example.com', 'owner') + + const res = await app.request( + 'http://localhost/api/me', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as MeBody + expect(body).toEqual({ accounts: [], superAdmin: true }) + }) + + it('returns the safe empty shape when the mock is OFF (no flag set)', async () => { + // Without ETUS_GATEWAY_MOCK the handler takes the real path; gatewayAuthority is + // off in tests, so a valid session still resolves to the empty context. + const env = getEnv() + const app = buildApp(env) + const cookie = await login(app, env, 'multi@example.com') + + const res = await app.request( + 'http://localhost/api/me', + { method: 'GET', headers: { Cookie: cookie } }, + env, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as MeBody + expect(body).toEqual({ accounts: [], superAdmin: false }) + }) +}) diff --git a/tests/unit/client/hooks/use-gateway-accounts.test.tsx b/tests/unit/client/hooks/use-gateway-accounts.test.tsx new file mode 100644 index 0000000..706fa61 --- /dev/null +++ b/tests/unit/client/hooks/use-gateway-accounts.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useGatewayAccounts } from '@/hooks/use-gateway-accounts' + +const mockFetch = vi.fn() +global.fetch = mockFetch + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +const jsonResponse = (body: unknown) => ({ ok: true, json: async () => body }) as Response + +describe('useGatewayAccounts', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.restoreAllMocks()) + + it("exposes the user's gateway accounts and super-admin flag from /api/me", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + accounts: [{ id: 'a', slug: 'unum', name: 'Unum', role: 'manager' }], + superAdmin: false, + }), + ) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.accounts).toEqual([ + { id: 'a', slug: 'unum', name: 'Unum', role: 'manager' }, + ]) + expect(result.current.superAdmin).toBe(false) + expect(mockFetch).toHaveBeenCalledWith('/api/me', { credentials: 'include' }) + }) + + it('hasAccountRole respects the cumulative hierarchy', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + accounts: [{ id: 'a', slug: 'unum', name: 'Unum', role: 'manager' }], + superAdmin: false, + }), + ) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.hasAccountRole('unum', 'editor')).toBe(true) // manager >= editor + expect(result.current.hasAccountRole('unum', 'admin')).toBe(false) // manager < admin + expect(result.current.hasAccountRole('outro', 'viewer')).toBe(false) // not a member + }) + + it('super-admin passes every account-role check', async () => { + mockFetch.mockResolvedValue(jsonResponse({ accounts: [], superAdmin: true })) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.hasAccountRole('anything', 'admin')).toBe(true) + }) + + it('falls back to a safe empty shape when /api/me fails', async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({}) } as Response) + const { result } = renderHook(() => useGatewayAccounts(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.accounts).toEqual([]) + expect(result.current.superAdmin).toBe(false) + }) +}) diff --git a/tests/unit/client/routes/_authenticated/workspaces.test.tsx b/tests/unit/client/routes/_authenticated/workspaces.test.tsx new file mode 100644 index 0000000..c0b9807 --- /dev/null +++ b/tests/unit/client/routes/_authenticated/workspaces.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import { renderRoute, setupFetchMock } from '@tests/helpers/client-test-utils' + +// Build a /api/me handler for the gateway account context the page reads via +// useGatewayAccounts. (The default mock returns {} → the empty state.) +function meResponse(accounts: unknown[], superAdmin = false) { + return () => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ accounts, superAdmin }), + } as Response) +} + +describe('Workspaces Page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the title and description', async () => { + setupFetchMock() + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Workspaces' })).toBeInTheDocument() + }) + expect(screen.getByText(/the role you hold in each/i)).toBeInTheDocument() + }) + + it('shows the empty state when the user has no gateway workspaces', async () => { + setupFetchMock() // default /api/me → {} → no accounts + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByText(/No workspaces yet/i)).toBeInTheDocument() + }) + }) + + it('renders a card per gateway account with its role and blurb', async () => { + setupFetchMock({ + '/api/me': meResponse([ + { id: 'gw-initech', slug: 'initech', name: 'Initech', role: 'admin' }, + { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'viewer' }, + ]), + }) + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByText('Initech')).toBeInTheDocument() + }) + expect(screen.getByText('Acme Corporation')).toBeInTheDocument() + // Per-account role badges (the over-grant scenario: admin on one, viewer on another). + expect(screen.getByText('admin')).toBeInTheDocument() + expect(screen.getByText('viewer')).toBeInTheDocument() + // The blurb mirrors ACCOUNT_ROLE_MAP at the UI level. + expect(screen.getByText(/Read-only access to resources/i)).toBeInTheDocument() + }) + + it('shows the super-admin banner when the user is a super admin', async () => { + setupFetchMock({ '/api/me': meResponse([], true) }) + renderRoute({ initialEntries: ['/workspaces'] }) + + await waitFor(() => { + expect(screen.getByText('Super admin')).toBeInTheDocument() + }) + expect(screen.getByText(/admin-level access across every workspace/i)).toBeInTheDocument() + }) +}) diff --git a/tests/unit/server/auth/matrix.test.ts b/tests/unit/server/auth/matrix.test.ts index f440bad..80c8af3 100644 --- a/tests/unit/server/auth/matrix.test.ts +++ b/tests/unit/server/auth/matrix.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from 'vitest' +import { ACCOUNT_ROLES, mapAccountRolesToPermissions, hasPermission } from '@etus/auth' +import type { GatewayAccount } from '@etus/auth' import { ROLES, ROLE_HIERARCHY, PERMISSIONS_MATRIX, PERMISSION_CATALOG, + ACCOUNT_ROLE_MAP, } from '@server/auth/matrix' // Guards the invariant that @etus/auth depends on: createAuth({ permissions }) @@ -43,3 +46,95 @@ describe('RBAC matrix', () => { } }) }) + +// Gateway per-account roles (viewer < editor < manager < admin, migration 0070) +// mapped to local permissions via ACCOUNT_ROLE_MAP. Drift here silently mis-grants +// when the gateway resolves a per-account role for a user. +describe('ACCOUNT_ROLE_MAP', () => { + it("maps exactly @etus/auth's gateway account roles (anchored to the package, not a literal copy)", () => { + // Importing ACCOUNT_ROLES from the package makes this a REAL drift guard: a + // future @etus/auth that adds/renames a role fails this test instead of + // silently mis-granting. + expect(Object.keys(ACCOUNT_ROLE_MAP).sort()).toEqual([...ACCOUNT_ROLES].sort()) + }) + + it('every permission exists in PERMISSION_CATALOG', () => { + const catalog = new Set(PERMISSION_CATALOG) + for (const [role, perms] of Object.entries(ACCOUNT_ROLE_MAP)) { + for (const perm of perms) { + expect( + catalog.has(perm), + `permission "${perm}" (account role "${role}") is not in PERMISSION_CATALOG`, + ).toBe(true) + } + } + }) + + it('contains NO wildcards (the map is unioned across all accounts, so a wildcard would over-grant the whole app)', () => { + for (const [role, perms] of Object.entries(ACCOUNT_ROLE_MAP)) { + for (const perm of perms) { + expect( + perm === '*' || perm.endsWith(':*'), + `account role "${role}" maps to wildcard "${perm}" — forbidden: ACCOUNT_ROLE_MAP is unioned across every gateway account a user holds, so a wildcard lets admin-on-any-account pass every guard. Use requireGatewayAccountRole(slug, role) for precise per-account authority.`, + ).toBe(false) + } + } + }) + + it('is cumulative: viewer ⊆ editor ⊆ manager ⊆ admin', () => { + const tiers = ['viewer', 'editor', 'manager', 'admin'] as const + for (let i = 1; i < tiers.length; i++) { + const higher = new Set(ACCOUNT_ROLE_MAP[tiers[i]]) + for (const p of ACCOUNT_ROLE_MAP[tiers[i - 1]]) { + expect(higher.has(p), `${tiers[i]} is missing ${tiers[i - 1]} permission "${p}"`).toBe(true) + } + } + }) + + it('does not grant destructive permissions org-wide (no delete / billing:manage / account:delete)', () => { + const forbidden = new Set(['resources:delete', 'account:delete', 'billing:manage']) + for (const [role, perms] of Object.entries(ACCOUNT_ROLE_MAP)) { + for (const perm of perms) { + expect(forbidden.has(perm), `account role "${role}" grants destructive "${perm}" org-wide`).toBe( + false, + ) + } + } + }) +}) + +// Over-grant regression (PR #62 review): @etus/auth UNIONS ACCOUNT_ROLE_MAP across +// EVERY gateway account a user holds (super-admin = admin everywhere). With the old +// `admin: ['*']` this meant admin-on-any-account → full app access. These tests pin +// the closed behavior against the package's REAL mapper + permission check, so a +// regression that re-introduces a wildcard/destructive grant fails here. +describe('ACCOUNT_ROLE_MAP cross-account union does not over-grant', () => { + const acct = (slug: string, role: GatewayAccount['role']): GatewayAccount => ({ + id: `acct-${slug}`, + slug, + name: slug, + role, + }) + + it('admin on an UNRELATED account does not grant a wildcard or destructive permission app-wide', () => { + // Low-privilege in the relevant workspace (viewer), but admin on a side account. + const perms = mapAccountRolesToPermissions( + [acct('unum', 'viewer'), acct('side-project', 'admin')], + ACCOUNT_ROLE_MAP, + false, + ) + expect(perms).not.toContain('*') + expect(hasPermission('resources:delete', perms)).toBe(false) + expect(hasPermission('account:delete', perms)).toBe(false) + expect(hasPermission('billing:manage', perms)).toBe(false) + // It DOES grant the bounded admin perms (the map's intended org-level baseline). + expect(hasPermission('members:role', perms)).toBe(true) + }) + + it('super-admin resolves to the bounded admin set, not a wildcard', () => { + const perms = mapAccountRolesToPermissions([], ACCOUNT_ROLE_MAP, true) + expect(perms).not.toContain('*') + expect(hasPermission('resources:delete', perms)).toBe(false) + expect(hasPermission('members:role', perms)).toBe(true) + }) +}) diff --git a/tests/unit/server/dev/gateway-scenario.test.ts b/tests/unit/server/dev/gateway-scenario.test.ts new file mode 100644 index 0000000..be48456 --- /dev/null +++ b/tests/unit/server/dev/gateway-scenario.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import type { Env } from '@server/env' +import { + GATEWAY_MOCK_SCENARIO, + isGatewayMockEnabled, + resolveMockGatewayContext, + type GatewayAccountRole, +} from '@server/dev/gateway-scenario' + +const ROLES: GatewayAccountRole[] = ['viewer', 'editor', 'manager', 'admin'] + +// The resolver only reads ENVIRONMENT + ETUS_GATEWAY_MOCK; build a minimal Env. +function env(environment: string, mock?: string): Env { + return { ENVIRONMENT: environment, ETUS_GATEWAY_MOCK: mock } as unknown as Env +} + +describe('gateway mock — dev gating', () => { + it('is disabled in production even with the flag set', () => { + expect(isGatewayMockEnabled(env('production', '1'))).toBe(false) + // null → real path (the caller falls back to the live gateway resolution). + expect(resolveMockGatewayContext(env('production', '1'), 'admin@example.com')).toBeNull() + }) + + it('is disabled in dev without the flag', () => { + expect(isGatewayMockEnabled(env('development', undefined))).toBe(false) + expect(resolveMockGatewayContext(env('development'), 'admin@example.com')).toBeNull() + }) + + it('is enabled in a non-production env with the flag ("1" or "true")', () => { + expect(isGatewayMockEnabled(env('development', '1'))).toBe(true) + expect(isGatewayMockEnabled(env('test', 'true'))).toBe(true) + }) +}) + +describe('gateway mock — resolution (enabled)', () => { + const e = env('development', '1') + + it('resolves a known user to their scenario', () => { + expect(resolveMockGatewayContext(e, 'admin@example.com')).toEqual({ + accounts: [{ id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'admin' }], + superAdmin: false, + }) + }) + + it('is case-insensitive on email', () => { + expect(resolveMockGatewayContext(e, 'ADMIN@EXAMPLE.COM')).toEqual( + resolveMockGatewayContext(e, 'admin@example.com'), + ) + }) + + it('resolves an unknown / missing email to the safe empty context', () => { + const empty = { accounts: [], superAdmin: false } + expect(resolveMockGatewayContext(e, 'nobody@example.com')).toEqual(empty) + expect(resolveMockGatewayContext(e, null)).toEqual(empty) + expect(resolveMockGatewayContext(e, undefined)).toEqual(empty) + }) + + it('models a super-admin as superAdmin:true with no per-account rows', () => { + expect(resolveMockGatewayContext(e, 'superadmin@example.com')).toEqual({ + accounts: [], + superAdmin: true, + }) + }) + + // The headline scenario: this is the cross-account over-grant case the conservative + // ACCOUNT_ROLE_MAP guards (see tests/unit/server/auth/matrix.test.ts). Pinning the + // fixture keeps the demo honest — admin on an UNRELATED workspace + viewer on Acme — + // so the Workspaces UI and the over-grant tests stay in sync. + it('models the over-grant case: admin on initech + viewer on acme', () => { + const ctx = resolveMockGatewayContext(e, 'multi@example.com') + expect(ctx?.superAdmin).toBe(false) + expect(ctx?.accounts).toEqual([ + { id: 'gw-initech', slug: 'initech', name: 'Initech', role: 'admin' }, + { id: 'gw-acme', slug: 'acme', name: 'Acme Corporation', role: 'viewer' }, + ]) + }) +}) + +describe('gateway mock — fixture invariants', () => { + it('every account has id/slug/name and a valid role', () => { + for (const [email, ctx] of Object.entries(GATEWAY_MOCK_SCENARIO)) { + expect(typeof ctx.superAdmin, `${email}.superAdmin`).toBe('boolean') + for (const acct of ctx.accounts) { + expect(acct.id, `${email} account id`).toBeTruthy() + expect(acct.slug, `${email} account slug`).toBeTruthy() + expect(acct.name, `${email} account name`).toBeTruthy() + expect(ROLES, `${email} → ${acct.slug} role "${acct.role}"`).toContain(acct.role) + } + } + }) + + it('scenario keys are lowercase emails (lookups are lowercased)', () => { + for (const email of Object.keys(GATEWAY_MOCK_SCENARIO)) { + expect(email).toBe(email.toLowerCase()) + } + }) +}) diff --git a/tests/unit/server/routes/me.test.ts b/tests/unit/server/routes/me.test.ts new file mode 100644 index 0000000..4697e6e --- /dev/null +++ b/tests/unit/server/routes/me.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { OpenAPIHono } from '@hono/zod-openapi' +import { me } from '@server/routes/me' +import { createMockEnv } from '@tests/helpers/server' +import type { HonoEnv } from '@server/types' + +describe('GET /api/me (gateway account context)', () => { + it('returns a safe empty shape when no gateway context is present (gatewayAuthority off)', async () => { + const res = await me.request('/', {}, createMockEnv()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ accounts: [], superAdmin: false }) + }) + + it("reflects the user's gateway accounts and super-admin flag from context", async () => { + const app = new OpenAPIHono() + app.use('*', async (c, next) => { + // The @etus/auth middleware sets these under /api/*; inject them directly here. + const set = c.set.bind(c) as (key: string, value: unknown) => void + set('authAccounts', [{ id: 'acct-1', slug: 'unum', name: 'Unum', role: 'manager' }]) + set('authSuperAdmin', true) + await next() + }) + app.route('/', me) + + const res = await app.request('/', {}, createMockEnv()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + accounts: [{ id: 'acct-1', slug: 'unum', name: 'Unum', role: 'manager' }], + superAdmin: true, + }) + }) +})