From 91543663bf26d282f8e50903f786261989d7dc5e Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Wed, 10 Jun 2026 12:43:33 -0300 Subject: [PATCH 01/11] fix(claude): replace invalid permission wildcards in settings.json "Tool:*" is not valid allow-rule syntax, so /doctor reported all seven entries as skipped. Edit/Write/NotebookEdit become bare tool names (auto-approved, matching the original intent); the broad Bash/WebSearch/ WebFetch wildcards are dropped so Bash stays governed by the specific command rules, and the dead MultiEdit entry is removed. --- .claude/settings.json | 10 +++------- .../bhono-app/templates/base/.claude/settings.json | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) 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/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:*)", From 653514c06850a46b20b418ff9ba252bdb393a0bd Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Wed, 10 Jun 2026 12:44:43 -0300 Subject: [PATCH 02/11] chore(template): sync .npmrc into bhono-app template The root .npmrc gained link-workspace-packages=true in e5375c3 but the mirrored copy under packages/bhono-app/templates/base/ was never re-synced, so the pre-push template sync check fails on any push. --- packages/bhono-app/templates/base/.npmrc | 5 +++++ 1 file changed, 5 insertions(+) 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 From 08cd5f62689b1c55c18b22a566d957719584a263 Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 17:29:07 -0300 Subject: [PATCH 03/11] =?UTF-8?q?chore(deps):=20bump=20@etus/auth=200.8.0?= =?UTF-8?q?=20=E2=86=92=200.9.1=20(gateway=20account=20roles)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- packages/bhono-app/templates/base/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) 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/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/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 From ed41333afaef889b4fe6ed7387a2203ff0b4f6f1 Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 17:33:37 -0300 Subject: [PATCH 04/11] feat(auth): map gateway per-account roles to local permissions (accountRoleMap) --- .../templates/base/src/server/auth/matrix.ts | 34 +++++++++++++++++ .../templates/base/src/server/auth/setup.ts | 7 +++- .../tests/unit/server/auth/matrix.test.ts | 37 +++++++++++++++++++ src/server/auth/matrix.ts | 34 +++++++++++++++++ src/server/auth/setup.ts | 7 +++- tests/unit/server/auth/matrix.test.ts | 37 +++++++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) 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..9649afb 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,37 @@ 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. The package unions these permissions across +// EVERY gateway account the subject belongs to, and treats a super-admin as +// `admin` on every account — so this is a coarse, org-level grant. For a PRECISE +// per-account check (e.g. "manager on this workspace"), use the guard +// `auth.requireGatewayAccountRole(slug, role)` instead of relying on this map. +// +// KEYS are the fixed gateway account roles. VALUES must be PERMISSION_CATALOG +// entries (or wildcards). Tune per product. Roles not mapped contribute nothing. +export const ACCOUNT_ROLE_MAP: Record = { + viewer: ['account:read', 'resources:read'], + editor: [ + 'account:read', + 'members:invite', + 'resources:create', + 'resources:read', + 'resources:update', + ], + manager: [ + 'account:read', + 'account:update', + 'members:invite', + 'members:remove', + 'members:role', + 'audit:read', + 'resources:create', + 'resources:read', + 'resources:update', + ], + admin: ['*'], +} 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/tests/unit/server/auth/matrix.test.ts b/packages/bhono-app/templates/base/tests/unit/server/auth/matrix.test.ts index f440bad..a9219ea 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 @@ -4,6 +4,7 @@ import { 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 +44,39 @@ 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', () => { + const GATEWAY_ACCOUNT_ROLES = ['viewer', 'editor', 'manager', 'admin'] + + it('maps exactly the four gateway account roles', () => { + expect(Object.keys(ACCOUNT_ROLE_MAP).sort()).toEqual([...GATEWAY_ACCOUNT_ROLES].sort()) + }) + + it('every non-wildcard 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) { + if (perm === '*' || perm.endsWith(':*')) continue + expect( + catalog.has(perm), + `permission "${perm}" (account role "${role}") is not in PERMISSION_CATALOG`, + ).toBe(true) + } + } + }) + + it('is cumulative: viewer ⊆ editor ⊆ manager, admin is the wildcard', () => { + const editor = new Set(ACCOUNT_ROLE_MAP.editor) + for (const p of ACCOUNT_ROLE_MAP.viewer) { + expect(editor.has(p), `editor is missing viewer permission "${p}"`).toBe(true) + } + const manager = new Set(ACCOUNT_ROLE_MAP.manager) + for (const p of ACCOUNT_ROLE_MAP.editor) { + expect(manager.has(p), `manager is missing editor permission "${p}"`).toBe(true) + } + expect(ACCOUNT_ROLE_MAP.admin).toContain('*') + }) +}) diff --git a/src/server/auth/matrix.ts b/src/server/auth/matrix.ts index e3f2561..9649afb 100644 --- a/src/server/auth/matrix.ts +++ b/src/server/auth/matrix.ts @@ -77,3 +77,37 @@ 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. The package unions these permissions across +// EVERY gateway account the subject belongs to, and treats a super-admin as +// `admin` on every account — so this is a coarse, org-level grant. For a PRECISE +// per-account check (e.g. "manager on this workspace"), use the guard +// `auth.requireGatewayAccountRole(slug, role)` instead of relying on this map. +// +// KEYS are the fixed gateway account roles. VALUES must be PERMISSION_CATALOG +// entries (or wildcards). Tune per product. Roles not mapped contribute nothing. +export const ACCOUNT_ROLE_MAP: Record = { + viewer: ['account:read', 'resources:read'], + editor: [ + 'account:read', + 'members:invite', + 'resources:create', + 'resources:read', + 'resources:update', + ], + manager: [ + 'account:read', + 'account:update', + 'members:invite', + 'members:remove', + 'members:role', + 'audit:read', + 'resources:create', + 'resources:read', + 'resources:update', + ], + admin: ['*'], +} 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/tests/unit/server/auth/matrix.test.ts b/tests/unit/server/auth/matrix.test.ts index f440bad..a9219ea 100644 --- a/tests/unit/server/auth/matrix.test.ts +++ b/tests/unit/server/auth/matrix.test.ts @@ -4,6 +4,7 @@ import { 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 +44,39 @@ 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', () => { + const GATEWAY_ACCOUNT_ROLES = ['viewer', 'editor', 'manager', 'admin'] + + it('maps exactly the four gateway account roles', () => { + expect(Object.keys(ACCOUNT_ROLE_MAP).sort()).toEqual([...GATEWAY_ACCOUNT_ROLES].sort()) + }) + + it('every non-wildcard 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) { + if (perm === '*' || perm.endsWith(':*')) continue + expect( + catalog.has(perm), + `permission "${perm}" (account role "${role}") is not in PERMISSION_CATALOG`, + ).toBe(true) + } + } + }) + + it('is cumulative: viewer ⊆ editor ⊆ manager, admin is the wildcard', () => { + const editor = new Set(ACCOUNT_ROLE_MAP.editor) + for (const p of ACCOUNT_ROLE_MAP.viewer) { + expect(editor.has(p), `editor is missing viewer permission "${p}"`).toBe(true) + } + const manager = new Set(ACCOUNT_ROLE_MAP.manager) + for (const p of ACCOUNT_ROLE_MAP.editor) { + expect(manager.has(p), `manager is missing editor permission "${p}"`).toBe(true) + } + expect(ACCOUNT_ROLE_MAP.admin).toContain('*') + }) +}) From 5db65448f7e38e6adfef4b4fa9567bbee16b511d Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 18:56:58 -0300 Subject: [PATCH 05/11] feat(api): expose gateway account context to the client (GET /api/me + useGatewayAccounts) --- .../src/client/hooks/use-gateway-accounts.ts | 64 ++++++++++++++++ .../templates/base/src/server/routes/index.ts | 2 + .../base/src/server/routes/me/index.ts | 55 ++++++++++++++ .../hooks/use-gateway-accounts.test.tsx | 73 +++++++++++++++++++ .../base/tests/unit/server/routes/me.test.ts | 32 ++++++++ src/client/hooks/use-gateway-accounts.ts | 64 ++++++++++++++++ src/server/routes/index.ts | 2 + src/server/routes/me/index.ts | 55 ++++++++++++++ .../hooks/use-gateway-accounts.test.tsx | 73 +++++++++++++++++++ tests/unit/server/routes/me.test.ts | 32 ++++++++ 10 files changed, 452 insertions(+) create mode 100644 packages/bhono-app/templates/base/src/client/hooks/use-gateway-accounts.ts create mode 100644 packages/bhono-app/templates/base/src/server/routes/me/index.ts create mode 100644 packages/bhono-app/templates/base/tests/unit/client/hooks/use-gateway-accounts.test.tsx create mode 100644 packages/bhono-app/templates/base/tests/unit/server/routes/me.test.ts create mode 100644 src/client/hooks/use-gateway-accounts.ts create mode 100644 src/server/routes/me/index.ts create mode 100644 tests/unit/client/hooks/use-gateway-accounts.test.tsx create mode 100644 tests/unit/server/routes/me.test.ts 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/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..7998458 --- /dev/null +++ b/packages/bhono-app/templates/base/src/server/routes/me/index.ts @@ -0,0 +1,55 @@ +// 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' + +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) => { + 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/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/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/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/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..7998458 --- /dev/null +++ b/src/server/routes/me/index.ts @@ -0,0 +1,55 @@ +// 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' + +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) => { + const auth = getAuth(c.env) + return c.json({ accounts: auth.getGatewayAccounts(c), superAdmin: auth.isSuperAdmin(c) }, 200) +}) + +export { me } 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/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, + }) + }) +}) From 3a8a654b0c722d39c5f6ada741c10e91cf760cbd Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 18:59:46 -0300 Subject: [PATCH 06/11] docs(auth): document gateway per-account roles integration + changeset --- .changeset/gateway-account-roles.md | 20 ++++++++++ docs/SETUP-GUIDE.md | 38 +++++++++++++++++++ .../templates/base/docs/SETUP-GUIDE.md | 38 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .changeset/gateway-account-roles.md 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/docs/SETUP-GUIDE.md b/docs/SETUP-GUIDE.md index f2817b6..9b08df6 100644 --- a/docs/SETUP-GUIDE.md +++ b/docs/SETUP-GUIDE.md @@ -200,6 +200,44 @@ 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') && } +``` + +> Regra: o `ACCOUNT_ROLE_MAP` é grant **org-level** (une entre contas). Para autz por +> conta/workspace específico, prefira `requireGatewayAccountRole(slug, role)` no +> server e `hasAccountRole(slug, role)` no client. + --- ### 6. Banco de Dados com Hash Diferente diff --git a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md index f2817b6..9b08df6 100644 --- a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md +++ b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md @@ -200,6 +200,44 @@ 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') && } +``` + +> Regra: o `ACCOUNT_ROLE_MAP` é grant **org-level** (une entre contas). Para autz por +> conta/workspace específico, prefira `requireGatewayAccountRole(slug, role)` no +> server e `hasAccountRole(slug, role)` no client. + --- ### 6. Banco de Dados com Hash Diferente From 3eb485ce7ffada46ace01bfd6d801654867be96f Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 19:01:37 -0300 Subject: [PATCH 07/11] fix(auth): retain local account routes in the hybrid model (suppress deprecation) --- packages/bhono-app/templates/base/src/server/index.ts | 6 ++++++ src/server/index.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/bhono-app/templates/base/src/server/index.ts b/packages/bhono-app/templates/base/src/server/index.ts index 0c17582..143194f 100644 --- a/packages/bhono-app/templates/base/src/server/index.ts +++ b/packages/bhono-app/templates/base/src/server/index.ts @@ -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/index.ts b/src/server/index.ts index 0c17582..143194f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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()) From c94f737d88cf6596cd9aae65a7edbafdd8e9551d Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 19:19:40 -0300 Subject: [PATCH 08/11] fix(auth): harden gateway account-role mapping (review findings) - ACCOUNT_ROLE_MAP: remove wildcards (admin was '*') and destructive perms. The package unions the map across ALL of a user's gateway accounts, so a wildcard let admin-on-any-account pass every guard app-wide. Now bounded + non-destructive; precise per-account authority stays in requireGatewayAccountRole. - matrix.test: anchor the role-vocabulary drift guard to @etus/auth's exported ACCOUNT_ROLES (was a tautological literal copy); add no-wildcard + no-destructive invariants. - Regenerate docs/openapi.json + src/shared/types/api.ts (now include /api/me). - SETUP-GUIDE: document the org-level union + no-wildcard rule + that local member removal is not an authz kill-switch while gatewayAuthority is on. --- docs/SETUP-GUIDE.md | 16 ++++-- .../templates/base/docs/SETUP-GUIDE.md | 16 ++++-- .../templates/base/src/server/auth/matrix.ts | 45 +++++++++++------ .../templates/base/src/shared/types/api.ts | 50 +++++++++++++++++++ .../tests/unit/server/auth/matrix.test.ts | 49 ++++++++++++------ src/server/auth/matrix.ts | 45 +++++++++++------ src/shared/types/api.ts | 50 +++++++++++++++++++ tests/unit/server/auth/matrix.test.ts | 49 ++++++++++++------ 8 files changed, 256 insertions(+), 64 deletions(-) diff --git a/docs/SETUP-GUIDE.md b/docs/SETUP-GUIDE.md index 9b08df6..9c07cfb 100644 --- a/docs/SETUP-GUIDE.md +++ b/docs/SETUP-GUIDE.md @@ -234,9 +234,19 @@ const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts() {hasAccountRole('unum', 'manager') && } ``` -> Regra: o `ACCOUNT_ROLE_MAP` é grant **org-level** (une entre contas). Para autz por -> conta/workspace específico, prefira `requireGatewayAccountRole(slug, role)` no -> server e `hasAccountRole(slug, role)` no client. +> ⚠️ **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. --- diff --git a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md index 9b08df6..9c07cfb 100644 --- a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md +++ b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md @@ -234,9 +234,19 @@ const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts() {hasAccountRole('unum', 'manager') && } ``` -> Regra: o `ACCOUNT_ROLE_MAP` é grant **org-level** (une entre contas). Para autz por -> conta/workspace específico, prefira `requireGatewayAccountRole(slug, role)` no -> server e `hasAccountRole(slug, role)` no client. +> ⚠️ **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. --- 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 9649afb..eb513c4 100644 --- a/packages/bhono-app/templates/base/src/server/auth/matrix.ts +++ b/packages/bhono-app/templates/base/src/server/auth/matrix.ts @@ -81,33 +81,48 @@ export const SCOPE_MAP: Record = { // 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. The package unions these permissions across -// EVERY gateway account the subject belongs to, and treats a super-admin as -// `admin` on every account — so this is a coarse, org-level grant. For a PRECISE -// per-account check (e.g. "manager on this workspace"), use the guard -// `auth.requireGatewayAccountRole(slug, role)` instead of relying on this map. +// migration 0070) instead of a scope. // -// KEYS are the fixed gateway account roles. VALUES must be PERMISSION_CATALOG -// entries (or wildcards). Tune per product. Roles not mapped contribute nothing. +// ⚠️ 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: [ + editor: ['account:read', 'resources:read', 'resources:create', 'resources:update'], + manager: [ 'account:read', - 'members:invite', - 'resources:create', 'resources:read', + 'resources:create', 'resources:update', + 'members:invite', + 'audit:read', ], - manager: [ + admin: [ 'account:read', 'account:update', + 'resources:read', + 'resources:create', + 'resources:update', 'members:invite', 'members:remove', 'members:role', 'audit:read', - 'resources:create', - 'resources:read', - 'resources:update', ], - admin: ['*'], } 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/unit/server/auth/matrix.test.ts b/packages/bhono-app/templates/base/tests/unit/server/auth/matrix.test.ts index a9219ea..425fe11 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,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { ACCOUNT_ROLES } from '@etus/auth' import { ROLES, ROLE_HIERARCHY, @@ -49,17 +50,17 @@ describe('RBAC matrix', () => { // 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', () => { - const GATEWAY_ACCOUNT_ROLES = ['viewer', 'editor', 'manager', 'admin'] - - it('maps exactly the four gateway account roles', () => { - expect(Object.keys(ACCOUNT_ROLE_MAP).sort()).toEqual([...GATEWAY_ACCOUNT_ROLES].sort()) + 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 non-wildcard permission exists in PERMISSION_CATALOG', () => { + 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) { - if (perm === '*' || perm.endsWith(':*')) continue expect( catalog.has(perm), `permission "${perm}" (account role "${role}") is not in PERMISSION_CATALOG`, @@ -68,15 +69,35 @@ describe('ACCOUNT_ROLE_MAP', () => { } }) - it('is cumulative: viewer ⊆ editor ⊆ manager, admin is the wildcard', () => { - const editor = new Set(ACCOUNT_ROLE_MAP.editor) - for (const p of ACCOUNT_ROLE_MAP.viewer) { - expect(editor.has(p), `editor is missing viewer permission "${p}"`).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) + } } - const manager = new Set(ACCOUNT_ROLE_MAP.manager) - for (const p of ACCOUNT_ROLE_MAP.editor) { - expect(manager.has(p), `manager is missing editor permission "${p}"`).toBe(true) + }) + + 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, + ) + } } - expect(ACCOUNT_ROLE_MAP.admin).toContain('*') }) }) diff --git a/src/server/auth/matrix.ts b/src/server/auth/matrix.ts index 9649afb..eb513c4 100644 --- a/src/server/auth/matrix.ts +++ b/src/server/auth/matrix.ts @@ -81,33 +81,48 @@ export const SCOPE_MAP: Record = { // 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. The package unions these permissions across -// EVERY gateway account the subject belongs to, and treats a super-admin as -// `admin` on every account — so this is a coarse, org-level grant. For a PRECISE -// per-account check (e.g. "manager on this workspace"), use the guard -// `auth.requireGatewayAccountRole(slug, role)` instead of relying on this map. +// migration 0070) instead of a scope. // -// KEYS are the fixed gateway account roles. VALUES must be PERMISSION_CATALOG -// entries (or wildcards). Tune per product. Roles not mapped contribute nothing. +// ⚠️ 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: [ + editor: ['account:read', 'resources:read', 'resources:create', 'resources:update'], + manager: [ 'account:read', - 'members:invite', - 'resources:create', 'resources:read', + 'resources:create', 'resources:update', + 'members:invite', + 'audit:read', ], - manager: [ + admin: [ 'account:read', 'account:update', + 'resources:read', + 'resources:create', + 'resources:update', 'members:invite', 'members:remove', 'members:role', 'audit:read', - 'resources:create', - 'resources:read', - 'resources:update', ], - admin: ['*'], } 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/unit/server/auth/matrix.test.ts b/tests/unit/server/auth/matrix.test.ts index a9219ea..425fe11 100644 --- a/tests/unit/server/auth/matrix.test.ts +++ b/tests/unit/server/auth/matrix.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { ACCOUNT_ROLES } from '@etus/auth' import { ROLES, ROLE_HIERARCHY, @@ -49,17 +50,17 @@ describe('RBAC matrix', () => { // 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', () => { - const GATEWAY_ACCOUNT_ROLES = ['viewer', 'editor', 'manager', 'admin'] - - it('maps exactly the four gateway account roles', () => { - expect(Object.keys(ACCOUNT_ROLE_MAP).sort()).toEqual([...GATEWAY_ACCOUNT_ROLES].sort()) + 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 non-wildcard permission exists in PERMISSION_CATALOG', () => { + 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) { - if (perm === '*' || perm.endsWith(':*')) continue expect( catalog.has(perm), `permission "${perm}" (account role "${role}") is not in PERMISSION_CATALOG`, @@ -68,15 +69,35 @@ describe('ACCOUNT_ROLE_MAP', () => { } }) - it('is cumulative: viewer ⊆ editor ⊆ manager, admin is the wildcard', () => { - const editor = new Set(ACCOUNT_ROLE_MAP.editor) - for (const p of ACCOUNT_ROLE_MAP.viewer) { - expect(editor.has(p), `editor is missing viewer permission "${p}"`).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) + } } - const manager = new Set(ACCOUNT_ROLE_MAP.manager) - for (const p of ACCOUNT_ROLE_MAP.editor) { - expect(manager.has(p), `manager is missing editor permission "${p}"`).toBe(true) + }) + + 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, + ) + } } - expect(ACCOUNT_ROLE_MAP.admin).toContain('*') }) }) From 290b86af38b18ab3e86c149c511d1d650af1af89 Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 19:36:13 -0300 Subject: [PATCH 09/11] test(auth): cover cross-account over-grant + /api/me auth gate (PR #62 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two regression tests flagged by the PR #62 review panel. Cross-account over-grant (tests/unit/server/auth/matrix.test.ts): drives @etus/auth's REAL mapAccountRolesToPermissions over ACCOUNT_ROLE_MAP and asserts that admin-on-any-account (and super-admin, treated as admin everywhere) resolves to a bounded permission set — never '*' nor a destructive perm (resources:delete / account:delete / billing:manage). Because the package UNIONS the map across every gateway account a user holds, this pins the conservative map: a regression that re-introduces a wildcard/destructive grant fails here. /api/me auth gate (tests/integration/api/me.test.ts): builds the REAL app via buildApp() and asserts GET /api/me returns 401 without a session, exercising the production /api/* requireAuthContext gate. The existing unit test mounts the me router bare, so it could not prove the gate exists. Also asserts /health stays public in the same app to rule out a boot-failure false positive. Exports buildApp from src/server/index.ts so integration tests can drive the full middleware stack. Synced to template (packages/bhono-app/templates/base). --- .../templates/base/src/server/index.ts | 2 +- .../base/tests/integration/api/me.test.ts | 42 +++++++++++++++++++ .../tests/unit/server/auth/matrix.test.ts | 39 ++++++++++++++++- src/server/index.ts | 2 +- tests/integration/api/me.test.ts | 42 +++++++++++++++++++ tests/unit/server/auth/matrix.test.ts | 39 ++++++++++++++++- 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 packages/bhono-app/templates/base/tests/integration/api/me.test.ts create mode 100644 tests/integration/api/me.test.ts diff --git a/packages/bhono-app/templates/base/src/server/index.ts b/packages/bhono-app/templates/base/src/server/index.ts index 143194f..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() 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..dde720c --- /dev/null +++ b/packages/bhono-app/templates/base/tests/integration/api/me.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { buildApp } from '@server/index' +import { getEnv } from '../setup' + +// 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) + }) +}) 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 425fe11..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,5 +1,6 @@ import { describe, it, expect } from 'vitest' -import { ACCOUNT_ROLES } from '@etus/auth' +import { ACCOUNT_ROLES, mapAccountRolesToPermissions, hasPermission } from '@etus/auth' +import type { GatewayAccount } from '@etus/auth' import { ROLES, ROLE_HIERARCHY, @@ -101,3 +102,39 @@ describe('ACCOUNT_ROLE_MAP', () => { } }) }) + +// 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/src/server/index.ts b/src/server/index.ts index 143194f..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() diff --git a/tests/integration/api/me.test.ts b/tests/integration/api/me.test.ts new file mode 100644 index 0000000..dde720c --- /dev/null +++ b/tests/integration/api/me.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { buildApp } from '@server/index' +import { getEnv } from '../setup' + +// 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) + }) +}) diff --git a/tests/unit/server/auth/matrix.test.ts b/tests/unit/server/auth/matrix.test.ts index 425fe11..80c8af3 100644 --- a/tests/unit/server/auth/matrix.test.ts +++ b/tests/unit/server/auth/matrix.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' -import { ACCOUNT_ROLES } from '@etus/auth' +import { ACCOUNT_ROLES, mapAccountRolesToPermissions, hasPermission } from '@etus/auth' +import type { GatewayAccount } from '@etus/auth' import { ROLES, ROLE_HIERARCHY, @@ -101,3 +102,39 @@ describe('ACCOUNT_ROLE_MAP', () => { } }) }) + +// 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) + }) +}) From 4cfe4136a780a445494179d38faf302bc729546e Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 20:44:39 -0300 Subject: [PATCH 10/11] feat(workspaces): multi-tenant gateway-role scenario + dev mock, UI, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete multi-tenant scenario to validate the hybrid gateway-account-roles model end to end, without a live gateway. Server - src/server/dev/gateway-scenario.ts: a dev-only, email-keyed fixture of gateway per-account roles (viewer/editor/manager/admin) + super-admin, incl. the cross-account over-grant case (multi@ = admin on Initech + viewer on Acme). The resolver is gated TWICE: ENVIRONMENT!=production AND ETUS_GATEWAY_MOCK — never prod. - /api/me consults the mock before the real gateway resolution (src/server/routes/me). - Enriched seed.ts with the scenario users + pending/suspended statuses. Client - New Workspaces page (src/client/routes/_authenticated/workspaces.tsx, in the nav) rendering useGatewayAccounts(): super-admin banner, a card per gateway account with its role badge + what it grants, and an empty state. Tests - unit: gateway-scenario resolver + fixture invariants; Workspaces route component. - integration: /api/me drives the mock through the REAL middleware via buildApp() (login → gate → handler → scenario), incl. the over-grant + super-admin cases; local membership assignment (GET /accounts, PATCH role, invite) via the worker. - e2e: Workspaces renders the gateway roles (mock on) + nav; Team page smoke. Docs / hygiene - SETUP-GUIDE: how to validate the UI locally — note the Vite plugin reads config/.dev.vars (next to wrangler.json), NOT the root .dev.vars. - gitignore + sync-template exclude the generated seed.sql. Synced to template (packages/bhono-app/templates/base). --- .dev.vars.example | 10 ++ .gitignore | 1 + docs/SETUP-GUIDE.md | 36 +++++++ .../templates/base/.dev.vars.example | 10 ++ packages/bhono-app/templates/base/_gitignore | 1 + .../templates/base/docs/SETUP-GUIDE.md | 36 +++++++ .../base/src/client/components/sidebar.tsx | 1 + .../base/src/client/routeTree.gen.ts | 21 ++++ .../routes/_authenticated/workspaces.tsx | 99 +++++++++++++++++ .../templates/base/src/server/db/seed.ts | 35 +++++- .../base/src/server/dev/gateway-scenario.ts | 87 +++++++++++++++ .../templates/base/src/server/env.ts | 4 + .../base/src/server/routes/me/index.ts | 6 ++ .../templates/base/tests/e2e/team.spec.ts | 34 ++++++ .../base/tests/e2e/workspaces.spec.ts | 69 ++++++++++++ .../integration/accounts/membership.test.ts | 102 ++++++++++++++++++ .../base/tests/integration/api/me.test.ts | 101 +++++++++++++++++ .../routes/_authenticated/workspaces.test.tsx | 68 ++++++++++++ .../unit/server/dev/gateway-scenario.test.ts | 97 +++++++++++++++++ scripts/sync-template.sh | 1 + src/client/components/sidebar.tsx | 1 + src/client/routeTree.gen.ts | 21 ++++ .../routes/_authenticated/workspaces.tsx | 99 +++++++++++++++++ src/server/db/seed.ts | 35 +++++- src/server/dev/gateway-scenario.ts | 87 +++++++++++++++ src/server/env.ts | 4 + src/server/routes/me/index.ts | 6 ++ tests/e2e/team.spec.ts | 34 ++++++ tests/e2e/workspaces.spec.ts | 69 ++++++++++++ tests/integration/accounts/membership.test.ts | 102 ++++++++++++++++++ tests/integration/api/me.test.ts | 101 +++++++++++++++++ .../routes/_authenticated/workspaces.test.tsx | 68 ++++++++++++ .../unit/server/dev/gateway-scenario.test.ts | 97 +++++++++++++++++ 33 files changed, 1535 insertions(+), 8 deletions(-) create mode 100644 packages/bhono-app/templates/base/src/client/routes/_authenticated/workspaces.tsx create mode 100644 packages/bhono-app/templates/base/src/server/dev/gateway-scenario.ts create mode 100644 packages/bhono-app/templates/base/tests/e2e/team.spec.ts create mode 100644 packages/bhono-app/templates/base/tests/e2e/workspaces.spec.ts create mode 100644 packages/bhono-app/templates/base/tests/integration/accounts/membership.test.ts create mode 100644 packages/bhono-app/templates/base/tests/unit/client/routes/_authenticated/workspaces.test.tsx create mode 100644 packages/bhono-app/templates/base/tests/unit/server/dev/gateway-scenario.test.ts create mode 100644 src/client/routes/_authenticated/workspaces.tsx create mode 100644 src/server/dev/gateway-scenario.ts create mode 100644 tests/e2e/team.spec.ts create mode 100644 tests/e2e/workspaces.spec.ts create mode 100644 tests/integration/accounts/membership.test.ts create mode 100644 tests/unit/client/routes/_authenticated/workspaces.test.tsx create mode 100644 tests/unit/server/dev/gateway-scenario.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index 613651b..4a4eab2 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -19,6 +19,16 @@ 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. +# +# IMPORTANT: the Vite dev server (@cloudflare/vite-plugin) reads .dev.vars next to the +# wrangler config, i.e. config/.dev.vars — NOT this root file. Put the flag there: +# echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars +# ETUS_GATEWAY_MOCK=1 + # SendGrid SENDGRID_API_KEY=your-sendgrid-api-key SENDGRID_FROM_EMAIL=noreply@yourdomain.com 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/docs/SETUP-GUIDE.md b/docs/SETUP-GUIDE.md index 9c07cfb..497f166 100644 --- a/docs/SETUP-GUIDE.md +++ b/docs/SETUP-GUIDE.md @@ -248,6 +248,42 @@ const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts() > 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/.dev.vars.example b/packages/bhono-app/templates/base/.dev.vars.example index 613651b..4a4eab2 100644 --- a/packages/bhono-app/templates/base/.dev.vars.example +++ b/packages/bhono-app/templates/base/.dev.vars.example @@ -19,6 +19,16 @@ 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. +# +# IMPORTANT: the Vite dev server (@cloudflare/vite-plugin) reads .dev.vars next to the +# wrangler config, i.e. config/.dev.vars — NOT this root file. Put the flag there: +# echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars +# 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/_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/docs/SETUP-GUIDE.md b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md index 9c07cfb..497f166 100644 --- a/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md +++ b/packages/bhono-app/templates/base/docs/SETUP-GUIDE.md @@ -248,6 +248,42 @@ const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts() > 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/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/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/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/routes/me/index.ts b/packages/bhono-app/templates/base/src/server/routes/me/index.ts index 7998458..4e23f36 100644 --- a/packages/bhono-app/templates/base/src/server/routes/me/index.ts +++ b/packages/bhono-app/templates/base/src/server/routes/me/index.ts @@ -11,6 +11,7 @@ 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({ @@ -48,6 +49,11 @@ const getMeRoute = createRoute({ 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) }) 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..9a5f13f --- /dev/null +++ b/packages/bhono-app/templates/base/tests/e2e/team.spec.ts @@ -0,0 +1,34 @@ +import { test, expect, isAuthenticated } from './fixtures' + +/** + * Team management (local membership) E2E — smoke. + * + * 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. + * + * Note: the interactive invite/role-assignment flow depends on GET /accounts returning + * the current workspace, which the Vite dev server serves from the SPA fallback rather + * than the worker — so the invite button stays disabled under `pnpm dev`. The + * assignment logic is covered at the integration layer (tests/integration), which + * drives the real worker via buildApp(). Here we smoke-test that the page and its + * assignment entry point render. + * + * @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.') + }) + + 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('shows the invite (assignment) entry point', async ({ page }) => { + await page.goto('/team') + await expect(page.getByRole('button', { name: /invite member/i })).toBeVisible() + }) +}) 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..30f9fe1 --- /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 .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 .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 index dde720c..785bd90 100644 --- a/packages/bhono-app/templates/base/tests/integration/api/me.test.ts +++ b/packages/bhono-app/templates/base/tests/integration/api/me.test.ts @@ -1,7 +1,42 @@ 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. // @@ -40,3 +75,69 @@ describe('GET /api/me — auth gate (production wiring)', () => { 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/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/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/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/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/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/routes/me/index.ts b/src/server/routes/me/index.ts index 7998458..4e23f36 100644 --- a/src/server/routes/me/index.ts +++ b/src/server/routes/me/index.ts @@ -11,6 +11,7 @@ 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({ @@ -48,6 +49,11 @@ const getMeRoute = createRoute({ 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) }) diff --git a/tests/e2e/team.spec.ts b/tests/e2e/team.spec.ts new file mode 100644 index 0000000..9a5f13f --- /dev/null +++ b/tests/e2e/team.spec.ts @@ -0,0 +1,34 @@ +import { test, expect, isAuthenticated } from './fixtures' + +/** + * Team management (local membership) E2E — smoke. + * + * 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. + * + * Note: the interactive invite/role-assignment flow depends on GET /accounts returning + * the current workspace, which the Vite dev server serves from the SPA fallback rather + * than the worker — so the invite button stays disabled under `pnpm dev`. The + * assignment logic is covered at the integration layer (tests/integration), which + * drives the real worker via buildApp(). Here we smoke-test that the page and its + * assignment entry point render. + * + * @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.') + }) + + 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('shows the invite (assignment) entry point', async ({ page }) => { + await page.goto('/team') + await expect(page.getByRole('button', { name: /invite member/i })).toBeVisible() + }) +}) diff --git a/tests/e2e/workspaces.spec.ts b/tests/e2e/workspaces.spec.ts new file mode 100644 index 0000000..30f9fe1 --- /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 .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 .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 index dde720c..785bd90 100644 --- a/tests/integration/api/me.test.ts +++ b/tests/integration/api/me.test.ts @@ -1,7 +1,42 @@ 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. // @@ -40,3 +75,69 @@ describe('GET /api/me — auth gate (production wiring)', () => { 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/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/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()) + } + }) +}) From c227864b24838deb66cec3067d94f6fc56c94352 Mon Sep 17 00:00:00 2001 From: Alberto Andre Date: Mon, 15 Jun 2026 21:08:56 -0300 Subject: [PATCH 11/11] fix(assets): run worker for /accounts,/audit,/invitations,/health (not SPA fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing routing/config bugs found while validating the multi-tenant UI. 1. Worker routes shadowed by the SPA assets fallback (affects DEV and PROD). wrangler.json `assets.not_found_handling: single-page-application` serves index.html for any path NOT in `run_worker_first`, which only listed `/api/*` and `/auth/*`. So `/accounts`, `/audit`, `/invitations` and even `/health` returned the SPA HTML instead of the worker — confirmed against the deployed demo (GET /health → HTML). This broke the Team page (members/invitations could not load → the invite button stayed disabled) and health checks. Fix: add the worker-owned prefixes (exact + wildcard) to `run_worker_first`. Verified in dev: /health → JSON, /accounts → 401 JSON, and the Team invite/assignment E2E now passes end to end. 2. Dev `.dev.vars` was at the repo root, where the Vite dev server never reads it. wrangler + @cloudflare/vite-plugin only read `.dev.vars` from the Wrangler config's directory (config/), so `ENVIRONMENT=development` (and the gateway mock) at the root were silently ignored and `pnpm dev` ran as ENVIRONMENT=production. Relocated `.dev.vars.example` → `config/.dev.vars.example` (the idiomatic location, per Cloudflare docs) and updated the spec comments. Synced to template (packages/bhono-app/templates/base). --- .dev.vars.example => config/.dev.vars.example | 10 ++--- config/wrangler.json | 10 ++++- .../base/{ => config}/.dev.vars.example | 10 ++--- .../templates/base/config/wrangler.json | 10 ++++- .../templates/base/tests/e2e/team.spec.ts | 39 ++++++++++++++----- .../base/tests/e2e/workspaces.spec.ts | 4 +- tests/e2e/team.spec.ts | 39 ++++++++++++++----- tests/e2e/workspaces.spec.ts | 4 +- 8 files changed, 90 insertions(+), 36 deletions(-) rename .dev.vars.example => config/.dev.vars.example (82%) rename packages/bhono-app/templates/base/{ => config}/.dev.vars.example (82%) diff --git a/.dev.vars.example b/config/.dev.vars.example similarity index 82% rename from .dev.vars.example rename to config/.dev.vars.example index 4a4eab2..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 @@ -23,10 +27,6 @@ ETUS_ADMIN_EMAILS=admin@yourdomain.com # (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. -# -# IMPORTANT: the Vite dev server (@cloudflare/vite-plugin) reads .dev.vars next to the -# wrangler config, i.e. config/.dev.vars — NOT this root file. Put the flag there: -# echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars # ETUS_GATEWAY_MOCK=1 # SendGrid 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/packages/bhono-app/templates/base/.dev.vars.example b/packages/bhono-app/templates/base/config/.dev.vars.example similarity index 82% rename from packages/bhono-app/templates/base/.dev.vars.example rename to packages/bhono-app/templates/base/config/.dev.vars.example index 4a4eab2..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 @@ -23,10 +27,6 @@ ETUS_ADMIN_EMAILS=admin@yourdomain.com # (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. -# -# IMPORTANT: the Vite dev server (@cloudflare/vite-plugin) reads .dev.vars next to the -# wrangler config, i.e. config/.dev.vars — NOT this root file. Put the flag there: -# echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars # ETUS_GATEWAY_MOCK=1 # SendGrid 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/tests/e2e/team.spec.ts b/packages/bhono-app/templates/base/tests/e2e/team.spec.ts index 9a5f13f..a5bdfcf 100644 --- a/packages/bhono-app/templates/base/tests/e2e/team.spec.ts +++ b/packages/bhono-app/templates/base/tests/e2e/team.spec.ts @@ -1,17 +1,14 @@ -import { test, expect, isAuthenticated } from './fixtures' +import { test, expect, isAuthenticated, closeAllDialogs } from './fixtures' /** - * Team management (local membership) E2E — smoke. + * 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. * - * Note: the interactive invite/role-assignment flow depends on GET /accounts returning - * the current workspace, which the Vite dev server serves from the SPA fallback rather - * than the worker — so the invite button stays disabled under `pnpm dev`. The - * assignment logic is covered at the integration layer (tests/integration), which - * drives the real worker via buildApp(). Here we smoke-test that the page and its - * assignment entry point render. + * 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 */ @@ -19,6 +16,13 @@ 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 }) => { @@ -27,8 +31,23 @@ test.describe('Team management @auth', () => { await expect(page.getByRole('heading', { name: /team members/i })).toBeVisible() }) - test('shows the invite (assignment) entry point', async ({ page }) => { + test('opens the invite dialog with a role selector (assignment surface)', async ({ page }) => { await page.goto('/team') - await expect(page.getByRole('button', { name: /invite member/i })).toBeVisible() + + // 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 index 30f9fe1..043e8e3 100644 --- a/packages/bhono-app/templates/base/tests/e2e/workspaces.spec.ts +++ b/packages/bhono-app/templates/base/tests/e2e/workspaces.spec.ts @@ -5,7 +5,7 @@ import { test, expect, isAuthenticated } from './fixtures' * * 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 .dev.vars) a scenario user + * 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. * @@ -53,7 +53,7 @@ test.describe('Workspaces @auth', () => { 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 .dev.vars to run this', + 'gateway mock disabled — set ETUS_GATEWAY_MOCK=1 in config/.dev.vars to run this', ) await page.goto('/workspaces') diff --git a/tests/e2e/team.spec.ts b/tests/e2e/team.spec.ts index 9a5f13f..a5bdfcf 100644 --- a/tests/e2e/team.spec.ts +++ b/tests/e2e/team.spec.ts @@ -1,17 +1,14 @@ -import { test, expect, isAuthenticated } from './fixtures' +import { test, expect, isAuthenticated, closeAllDialogs } from './fixtures' /** - * Team management (local membership) E2E — smoke. + * 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. * - * Note: the interactive invite/role-assignment flow depends on GET /accounts returning - * the current workspace, which the Vite dev server serves from the SPA fallback rather - * than the worker — so the invite button stays disabled under `pnpm dev`. The - * assignment logic is covered at the integration layer (tests/integration), which - * drives the real worker via buildApp(). Here we smoke-test that the page and its - * assignment entry point render. + * 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 */ @@ -19,6 +16,13 @@ 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 }) => { @@ -27,8 +31,23 @@ test.describe('Team management @auth', () => { await expect(page.getByRole('heading', { name: /team members/i })).toBeVisible() }) - test('shows the invite (assignment) entry point', async ({ page }) => { + test('opens the invite dialog with a role selector (assignment surface)', async ({ page }) => { await page.goto('/team') - await expect(page.getByRole('button', { name: /invite member/i })).toBeVisible() + + // 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 index 30f9fe1..043e8e3 100644 --- a/tests/e2e/workspaces.spec.ts +++ b/tests/e2e/workspaces.spec.ts @@ -5,7 +5,7 @@ import { test, expect, isAuthenticated } from './fixtures' * * 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 .dev.vars) a scenario user + * 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. * @@ -53,7 +53,7 @@ test.describe('Workspaces @auth', () => { 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 .dev.vars to run this', + 'gateway mock disabled — set ETUS_GATEWAY_MOCK=1 in config/.dev.vars to run this', ) await page.goto('/workspaces')