diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 6afe17c41..ead48dabe 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -191,7 +191,7 @@ const HubEnv = type({ "dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY or PRINCIPAL_KEY_ENCRYPTION_KEY, storing secrets and signing keys at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident", ), "ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe( - "dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks and pending-invite redemption normally require emailVerified; never set this for a real deployment", + "dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks normally require emailVerified; never set this for a real deployment", ), "HUB_ALLOW_GIT_INSIDE_WORK_TREE?": type("'1' | 'true'").describe( "opt-in to initialize hub git-on-disk state inside a directory that is already a git work tree; refused by default because a nested init that misses its own .git walks up and commits onto the enclosing working branch", diff --git a/docs/TENANCY.md b/docs/TENANCY.md index d5f8dd756..8d6c92523 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -68,17 +68,15 @@ is never patched into a vendor route. Two layers, in order: domains"` (with an `allowedDomains` list), or `"open"`. An absent row is closed defaults, identical in effect to `selfSignup: "off"`. -**closed** — self-serve email signup is rejected. An owner adds members -via the native invite/membership path, shares a **copy-link invite** -(token in the URL, out of scope for delivery), or pre-vets an email (or -a whole domain) as a **pending invite** — see below — before that person -ever logs in. +**closed** — self-serve email signup is rejected. New humans join only +when an operator creates them through native APIs, or an owner shares a +**copy-link invite** (token in the URL, out of scope for delivery). **Email must be verified.** better-auth is configured without `requireEmailVerification`, so a freshly-registered address is not proof of ownership on its own. Every email-trust decision -`@workbench/access-policy` makes — an allowed-domains match, an open- -policy pass, a pending-invite redemption — requires +`@workbench/access-policy` makes — an allowed-domains match or an +open-policy pass — requires `user.emailVerified === true`; an unverified email is denied, fail- closed, regardless of what the policy or env otherwise allow. `ALLOW_UNVERIFIED_EMAILS=1` opts out for local dev/test only, mirroring @@ -100,19 +98,6 @@ row flip the env switch automatically — the env switch is an operator deployment fact, the policy row is a per-bench product setting, and the mismatch is meant to be visible, not auto-resolved. -### Pending invites (the not-yet-registered-user bridge) - -The native invite route (`POST /tenants/:id/members/invite`) requires an -existing `user` row looked up by email — it cannot invite someone who -has never signed in. `@workbench/access-policy` bridges that gap with -its own `pending_invite` table: an admin records an email (or a domain, -for a standing "anyone at this domain may join" rule) against a tenant -before that person has an account. On that email's first login, the -onboarding hook resolves the match, redeems it through the native invite -route (now that a user row exists) and an immediate activation, and -consumes an exact-email match (a domain match is a standing rule and is -never consumed). - ### Workbench icon Product metadata per tenant: monogram (1–2 characters) + color token. @@ -329,7 +314,7 @@ needs a weaker role, that is an Interchange conversation first. - `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts - `@workbench/onboarding` — personal bench provision under operator parent - `@workbench/access-policy` — closed-by-default signup/sub-workbench- - creation policy, pending invites (CL-5886) + creation policy - `apps/hub` — `WORKBENCH_SIGNUP`, invite routes, icon routes; one of the explicitly-listed apps/hub mounts pending extraction into a package (see [ARCHITECTURE.md](../ARCHITECTURE.md), CL-6127) diff --git a/packages/access-policy/README.md b/packages/access-policy/README.md index 56c2e3509..c98bd57b6 100644 --- a/packages/access-policy/README.md +++ b/packages/access-policy/README.md @@ -2,21 +2,19 @@ Closed-by-default access policy for the hub: per-tenant self-signup and sub-workbench creation rules, layered over Interchange's native -tenancy/RBAC without patching vendor routes. A pending invite resolves -through the native invite route (`POST /tenants/:id/members/invite`) plus -an immediate status flip to `"active"`, the same two primitives -`packages/settings-ui` already drives by hand — this package only decides -whether those calls are allowed to happen. +tenancy/RBAC without patching vendor routes. Signup stays closed unless +an explicit policy row or env flag opens it. New humans join only when +an operator creates them through native APIs. ## Composition over Interchange -- No parallel tenancy or RBAC model: tenant creation, invites, and role - checks all go through native `@intx/hub-api` routes and grants. +- No parallel tenancy or RBAC model: tenant creation and role checks + all go through native `@intx/hub-api` routes and grants. - `policy.ts` is the pure evaluation core (no DB, no HTTP, no env) — every decision (can this email self-sign-up, can this role create a sub-workbench) reduces to a function call over plain data. -- `gate.ts` composes `policy.ts` with `store.ts` for the two entry points - `packages/onboarding`'s first-login hook calls. +- `gate.ts` composes `policy.ts` with `store.ts` for the signup-gate + entry point `packages/onboarding`'s first-login hook calls. ## Key modules @@ -24,12 +22,11 @@ whether those calls are allowed to happen. `domainAllowed`, `evaluateSignupGate`, `canCreateTenancy`. - `gate.ts` — composes policy + store for the onboarding first-login hook. - `routes.ts` — tenant-scoped HTTP surface: read/edit a tenant's own - policy row, manage pending invites, and the gated - `POST .../child-tenants` surface. + policy row and the gated `POST .../child-tenants` surface. - `store.ts` — Postgres-backed persistence plus an in-memory fake for tests that don't need a real database. -- `schema.ts` — the two product tables (`policy`, `pending_invite`), - siloed in their own `access_policy` Postgres schema, never `public`. +- `schema.ts` — the product table (`policy`), siloed in its own + `access_policy` Postgres schema, never `public`. - `migrations.ts` — package-owned migrations with their own ledger table, so the package can be extracted without disentangling platform history. - `types.ts` — arktype shapes for anything crossing a trust boundary diff --git a/packages/access-policy/src/gate.test.ts b/packages/access-policy/src/gate.test.ts index bba25fb65..e587577c9 100644 --- a/packages/access-policy/src/gate.test.ts +++ b/packages/access-policy/src/gate.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; -import type { ApiCall, ApiResult } from "@corbits/hub-api-client"; -import { checkSignupGate, resolvePendingInviteOnLogin } from "./gate"; +import { checkSignupGate } from "./gate"; import { createInMemoryAccessPolicyStore } from "./store"; const verified = { emailVerified: true, allowUnverifiedEmails: false }; @@ -104,223 +103,3 @@ describe("checkSignupGate", () => { expect(result).toEqual({ allowed: true, reason: "policy_open" }); }); }); - -function fakeApi( - handler: (method: string, path: string, body: unknown) => ApiResult, -): ApiCall { - return async (method, path, body) => handler(method, path, body); -} - -describe("resolvePendingInviteOnLogin", () => { - test("no matching invite -> undefined, no native calls made", async () => { - const store = createInMemoryAccessPolicyStore(); - let calls = 0; - const api = fakeApi(() => { - calls += 1; - throw new Error("should not be called"); - }); - const result = await resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "nobody@acme.example", - ...verified, - }); - expect(result).toBeUndefined(); - expect(calls).toBe(0); - }); - - test("exploit: an unverified email cannot hijack a pending invite for someone else's address", async () => { - const store = createInMemoryAccessPolicyStore(); - await store.createPendingInvite("tnt_acme", { - matchType: "email", - value: "victim@acme.example", - }); - let calls = 0; - const api = fakeApi(() => { - calls += 1; - throw new Error("should not be called"); - }); - - const result = await resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "victim@acme.example", - emailVerified: false, - allowUnverifiedEmails: false, - }); - - expect(result).toBeUndefined(); - expect(calls).toBe(0); - // The invite survives untouched — an unverified claim never even - // looks it up, let alone consumes it. - const stillPending = await store.findMatchingPendingInvite( - "victim@acme.example", - ); - expect(stillPending).toBeDefined(); - }); - - test("an exact-email match invites, activates, and is consumed", async () => { - const store = createInMemoryAccessPolicyStore(); - const invite = await store.createPendingInvite("tnt_acme", { - matchType: "email", - value: "Person@Acme.example", - roleId: "rol_member", - }); - - const calls: { method: string; path: string; body: unknown }[] = []; - const api = fakeApi((method, path, body) => { - calls.push({ method, path, body }); - if (method === "POST" && path.endsWith("/members/invite")) { - return { status: 201, data: { id: "prn_new" }, cookies: [] }; - } - if (method === "PATCH" && path.endsWith("/prn_new")) { - return { status: 200, data: { id: "prn_new" }, cookies: [] }; - } - throw new Error(`unexpected call ${method} ${path}`); - }); - - const result = await resolvePendingInviteOnLogin({ - store, - api, - cookies: ["session=abc"], - email: "person@acme.example", - ...verified, - }); - - expect(result).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" }); - expect(calls).toEqual([ - { - method: "POST", - path: "/api/tenants/tnt_acme/members/invite", - body: { email: "person@acme.example", roleId: "rol_member" }, - }, - { - method: "PATCH", - path: "/api/tenants/tnt_acme/principals/prn_new", - body: { status: "active" }, - }, - ]); - - const stillMatches = await store.findMatchingPendingInvite( - "person@acme.example", - ); - expect(stillMatches).toBeUndefined(); - void invite; - }); - - test("a domain-wildcard match resolves but is never consumed (a standing rule)", async () => { - const store = createInMemoryAccessPolicyStore(); - await store.createPendingInvite("tnt_acme", { - matchType: "domain", - value: "acme.example", - }); - - const api = fakeApi((method, path) => { - if (method === "POST" && path.endsWith("/members/invite")) { - return { status: 201, data: { id: "prn_new" }, cookies: [] }; - } - if (method === "PATCH") { - return { status: 200, data: { id: "prn_new" }, cookies: [] }; - } - throw new Error(`unexpected call ${method} ${path}`); - }); - - const first = await resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "anyone@acme.example", - ...verified, - }); - expect(first).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" }); - - const second = await resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "someone-else@acme.example", - ...verified, - }); - expect(second).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" }); - }); - - test("a redemption failure at the native invite route throws; the invite is already spent (consume-before-redeem)", async () => { - const store = createInMemoryAccessPolicyStore(); - await store.createPendingInvite("tnt_acme", { - matchType: "email", - value: "person@acme.example", - }); - const api = fakeApi(() => ({ - status: 409, - data: { error: { code: "conflict" } }, - cookies: [], - })); - - await expect( - resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "person@acme.example", - ...verified, - }), - ).rejects.toThrow(/could not be redeemed/); - - // Consumption happens before the native call specifically so a - // race never lets two callers both redeem — the tradeoff is that a - // downstream failure (network blip, native route down) leaves this - // invite spent with no member added. That is deliberate: fail - // closed on the race, not on the rare native-route failure. - const stillPending = await store.findMatchingPendingInvite( - "person@acme.example", - ); - expect(stillPending).toBeUndefined(); - }); - - test("TOCTOU: two concurrent redemptions of the same exact-email invite — exactly one wins", async () => { - const store = createInMemoryAccessPolicyStore(); - await store.createPendingInvite("tnt_acme", { - matchType: "email", - value: "person@acme.example", - }); - - let inviteCalls = 0; - const api = fakeApi((method, path) => { - if (method === "POST" && path.endsWith("/members/invite")) { - inviteCalls += 1; - return { status: 201, data: { id: "prn_new" }, cookies: [] }; - } - if (method === "PATCH") { - return { status: 200, data: { id: "prn_new" }, cookies: [] }; - } - throw new Error(`unexpected call ${method} ${path}`); - }); - - const [first, second] = await Promise.all([ - resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "person@acme.example", - ...verified, - }), - resolvePendingInviteOnLogin({ - store, - api, - cookies: [], - email: "person@acme.example", - ...verified, - }), - ]); - - const winners = [first, second].filter((r) => r !== undefined); - const losers = [first, second].filter((r) => r === undefined); - expect(winners).toHaveLength(1); - expect(losers).toHaveLength(1); - // The loser never reaches the native invite route at all — it - // loses at the atomic consume step, before any redemption call. - expect(inviteCalls).toBe(1); - }); -}); diff --git a/packages/access-policy/src/gate.ts b/packages/access-policy/src/gate.ts index 821811712..708b54f82 100644 --- a/packages/access-policy/src/gate.ts +++ b/packages/access-policy/src/gate.ts @@ -1,12 +1,6 @@ // Composition over `./policy.ts`'s pure functions and `./store.ts`'s -// persistence: the two entry points `packages/onboarding`'s first-login -// hook calls. Nothing here patches a vendor route — a pending invite -// resolves through the native invite route (`POST -// /tenants/:id/members/invite`) and an immediate status flip to -// "active" (`PATCH /tenants/:id/principals/:id`), the same two native -// primitives `packages/settings-ui` already drives by hand. -import type { ApiCall } from "@corbits/hub-api-client"; - +// persistence: the signup-gate entry point `packages/onboarding`'s +// first-login hook calls. import { evaluateSignupGate, type SignupGateResult } from "./policy"; import type { AccessPolicyStore } from "./store"; @@ -47,89 +41,3 @@ export async function checkSignupGate( allowUnverifiedEmails: args.allowUnverifiedEmails, }); } - -export type PendingInviteResolution = { - readonly tenantId: string; - readonly principalId: string; -}; - -/** - * Resolves a not-yet-registered email against `access_policy`'s pending - * invites once the user has actually logged in (so a user row now - * exists — the gap the native invite route can't cross on its own). - * On a match: invites the now-existing user through the native route, - * immediately activates the resulting principal (the pending row is - * this package's record of prior consent, so there is no separate - * accept step), and consumes an exact-email match. Returns undefined - * when nothing matches — the caller falls back to its own signup gate. - * - * Requires `emailVerified` (or the `allowUnverifiedEmails` dev escape - * hatch): better-auth is configured without `requireEmailVerification`, - * so without this check an attacker could sign up claiming someone - * else's address and redeem an invite meant for them. Checked before - * even looking the invite up, so an unverified caller learns nothing - * about whether a matching invite exists. - */ -export async function resolvePendingInviteOnLogin(args: { - store: AccessPolicyStore; - api: ApiCall; - cookies: string[]; - email: string; - emailVerified: boolean; - allowUnverifiedEmails: boolean; -}): Promise { - if (!args.emailVerified && !args.allowUnverifiedEmails) return undefined; - - const match = await args.store.findMatchingPendingInvite(args.email); - if (match === undefined) return undefined; - - // Consume an exact-email match BEFORE ever calling the native invite - // route: `consumePendingInvite` is atomic (see store.ts), so exactly - // one concurrent caller racing for the same invite wins this check - // and every other one — including a second login attempt for the - // same address — sees `false` and backs off here, never reaching the - // native route at all. A domain-wildcard match is a standing rule and - // is never consumed, so every matching login redeems independently. - if (match.matchType === "email") { - const won = await args.store.consumePendingInvite(match.id); - if (!won) return undefined; - } - - const inviteBody: { email: string; roleId?: string } = { - email: args.email, - }; - if (match.roleId !== undefined) inviteBody.roleId = match.roleId; - - const invited = await args.api( - "POST", - `/api/tenants/${match.tenantId}/members/invite`, - inviteBody, - args.cookies, - ); - if (invited.status !== 201) { - throw new Error( - `pending invite ${match.id} could not be redeemed against tenant ${match.tenantId} (status ${invited.status}): ${JSON.stringify(invited.data)}`, - ); - } - const principal = invited.data as { id?: unknown }; - if (typeof principal.id !== "string") { - throw new Error( - `pending invite ${match.id} redemption returned no principal id`, - ); - } - const principalId = principal.id; - - const activated = await args.api( - "PATCH", - `/api/tenants/${match.tenantId}/principals/${principalId}`, - { status: "active" }, - args.cookies, - ); - if (activated.status !== 200) { - throw new Error( - `pending invite ${match.id} redeemed a principal but activation failed (status ${activated.status}): ${JSON.stringify(activated.data)}`, - ); - } - - return { tenantId: match.tenantId, principalId }; -} diff --git a/packages/access-policy/src/index.ts b/packages/access-policy/src/index.ts index f0e54f4ae..e8265fd67 100644 --- a/packages/access-policy/src/index.ts +++ b/packages/access-policy/src/index.ts @@ -4,9 +4,7 @@ export { UpdateAccessPolicy, SelfSignupMode, TenancyCreationMode, - CreatePendingInvite, } from "./types"; -export type { PendingInvite } from "./types"; export { resolveAccessPolicy, @@ -29,13 +27,13 @@ export { } from "./store"; export type { AccessPolicyStore } from "./store"; -export { checkSignupGate, resolvePendingInviteOnLogin } from "./gate"; -export type { PendingInviteResolution, SignupGateCheckArgs } from "./gate"; +export { checkSignupGate } from "./gate"; +export type { SignupGateCheckArgs } from "./gate"; export { createAccessPolicyRoutes } from "./routes"; export type { CreateAccessPolicyRoutesDeps } from "./routes"; -export { accessPolicySchema, policy, pendingInvite } from "./schema"; +export { accessPolicySchema, policy } from "./schema"; export { accessPolicyMigrations, diff --git a/packages/access-policy/src/migrations.ts b/packages/access-policy/src/migrations.ts index 9d54af3f7..91ffceb7b 100644 --- a/packages/access-policy/src/migrations.ts +++ b/packages/access-policy/src/migrations.ts @@ -56,6 +56,14 @@ export const accessPolicyMigrations: readonly AccessPolicyMigration[] = [ ON "access_policy"."pending_invite" ("tenant_id"); `, }, + { + name: "0003_drop_pending_invite", + sql: ` + DROP INDEX IF EXISTS "access_policy"."pending_invite_value_idx"; + DROP INDEX IF EXISTS "access_policy"."pending_invite_tenant_idx"; + DROP TABLE IF EXISTS "access_policy"."pending_invite"; + `, + }, ]; const LEDGER_TABLE = "access_policy_migrations"; diff --git a/packages/access-policy/src/policy.ts b/packages/access-policy/src/policy.ts index 63e204d6d..981cd4f73 100644 --- a/packages/access-policy/src/policy.ts +++ b/packages/access-policy/src/policy.ts @@ -90,13 +90,12 @@ export type SignupGateArgs = { readonly envAllowedDomains: readonly string[]; readonly email: string; /** better-auth is configured without `requireEmailVerification`, so - * an unverified address can claim any domain or race a pending - * invite meant for someone else. Every email-trust decision this - * gate makes requires `emailVerified` unless `allowUnverifiedEmails` - * opts out (dev/test only — see `ALLOW_UNVERIFIED_EMAILS`, mirroring - * `ALLOW_PLAINTEXT_SECRETS`). Checked before policy/env are ever - * consulted, so no combination of settings can allow an unverified - * email through. */ + * an unverified address can claim any domain. Every email-trust + * decision this gate makes requires `emailVerified` unless + * `allowUnverifiedEmails` opts out (dev/test only — see + * `ALLOW_UNVERIFIED_EMAILS`, mirroring `ALLOW_PLAINTEXT_SECRETS`). + * Checked before policy/env are ever consulted, so no combination + * of settings can allow an unverified email through. */ readonly emailVerified: boolean; readonly allowUnverifiedEmails: boolean; }; diff --git a/packages/access-policy/src/routes.ts b/packages/access-policy/src/routes.ts index 8235dad2b..b1a92926d 100644 --- a/packages/access-policy/src/routes.ts +++ b/packages/access-policy/src/routes.ts @@ -1,11 +1,11 @@ // Tenant-scoped HTTP surface: reading/editing this tenant's own policy -// row, managing its pending invites, and the one gated tenant-creation -// surface — `POST .../child-tenants` — that checks `tenancyCreation` -// against the caller's native roles (read back through the native -// principal-detail route) before ever calling `POST /api/tenants` -// itself. Every native call goes through the injected `ApiCall`, the -// same self-HTTP-call seam `@workbench/onboarding` already uses — -// nothing here reimplements tenant creation or role resolution. +// row, and the one gated tenant-creation surface — +// `POST .../child-tenants` — that checks `tenancyCreation` against the +// caller's native roles (read back through the native principal-detail +// route) before ever calling `POST /api/tenants` itself. Every native +// call goes through the injected `ApiCall`, the same self-HTTP-call +// seam `@workbench/onboarding` already uses — nothing here +// reimplements tenant creation or role resolution. import { Hono } from "hono"; import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -14,7 +14,7 @@ import { cookiesFromHeader, type ApiCall } from "@corbits/hub-api-client"; import { canCreateTenancy } from "./policy"; import type { AccessPolicyStore } from "./store"; -import { CreatePendingInvite, UpdateAccessPolicy } from "./types"; +import { UpdateAccessPolicy } from "./types"; const CreateChildTenant = type({ name: "string > 0", @@ -55,52 +55,6 @@ export function createAccessPolicyRoutes( return c.json(updated); }); - app.get( - "/pending-invites", - deps.requireGrant("access-policy:*", "manage"), - async (c) => { - const tenant = c.get("tenant"); - const invites = await deps.store.listPendingInvites(tenant.id); - return c.json({ data: invites }); - }, - ); - - app.post( - "/pending-invites", - deps.requireGrant("access-policy:*", "manage"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const raw: unknown = await c.req.json().catch(() => undefined); - const parsed = CreatePendingInvite(raw); - if (parsed instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid invite: ${parsed.summary}`, - }), - 400, - ); - } - const invite = await deps.store.createPendingInvite(tenant.id, { - ...parsed, - invitedBy: parsed.invitedBy ?? principal.id, - }); - return c.json(invite, 201); - }, - ); - - app.delete( - "/pending-invites/:id", - deps.requireGrant("access-policy:*", "manage"), - async (c) => { - const tenant = c.get("tenant"); - const id = c.req.param("id"); - await deps.store.deletePendingInvite(tenant.id, id); - return c.body(null, 204); - }, - ); - // The gated tenant-creation surface: any signed-in member of this // tenant may attempt it, but only one whose native roles satisfy this // tenant's own `tenancyCreation` mode ever reaches `POST /api/tenants`. diff --git a/packages/access-policy/src/schema.ts b/packages/access-policy/src/schema.ts index e3d597328..952ab4905 100644 --- a/packages/access-policy/src/schema.ts +++ b/packages/access-policy/src/schema.ts @@ -1,10 +1,9 @@ -// The two product tables `@workbench/access-policy` owns: one closed-by- -// default policy row per tenant (`policy`), and a bridge table for -// inviting an email that has no user row yet (`pending_invite`). Both -// live in their own `access_policy` Postgres schema, never `public` — -// see docs/package-migrations.md. Tenancy, principals, roles, and -// grants stay entirely native (vendor/intx/db); this package never -// declares its own copy of any of them, it only opines on top. +// The product table `@workbench/access-policy` owns: one closed-by- +// default policy row per tenant (`policy`). It lives in its own +// `access_policy` Postgres schema, never `public` — see +// docs/package-migrations.md. Tenancy, principals, roles, and grants +// stay entirely native (vendor/intx/db); this package never declares +// its own copy of any of them, it only opines on top. import { pgSchema, text, timestamp } from "drizzle-orm/pg-core"; export const accessPolicySchema = pgSchema("access_policy"); @@ -34,20 +33,3 @@ export const policy = accessPolicySchema.table("policy", { }); export type PolicyRow = typeof policy.$inferSelect; - -export const pendingInvite = accessPolicySchema.table("pending_invite", { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - matchType: text("match_type", { enum: ["email", "domain"] }).notNull(), - // Lowercased email (matchType "email") or bare domain (matchType - // "domain"), e.g. "acme.example" with no leading "@". - value: text("value").notNull(), - roleId: text("role_id"), - invitedBy: text("invited_by"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - consumedAt: timestamp("consumed_at", { withTimezone: true }), -}); - -export type PendingInviteRow = typeof pendingInvite.$inferSelect; diff --git a/packages/access-policy/src/store.ts b/packages/access-policy/src/store.ts index 926534ee2..1ba63f4ee 100644 --- a/packages/access-policy/src/store.ts +++ b/packages/access-policy/src/store.ts @@ -1,35 +1,17 @@ -// Postgres-backed persistence for this package's two tables, plus an +// Postgres-backed persistence for this package's policy table, plus an // in-memory fake with the same shape for tests that don't need a real -// database. `generateId` here is a package-local id minter, not -// `@intx/hub-common`'s — that module's `generateId` only mints the -// platform's own closed set of entity kinds (tenant, principal, role, -// ...), and a pending-invite id is a product-owned id this package is -// free to shape itself. -import { and, eq, isNull } from "drizzle-orm"; +// database. +import { eq } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { - resolveAccessPolicy, - domainOf, - serializeAllowedDomains, -} from "./policy"; -import { pendingInvite, policy } from "./schema"; +import { resolveAccessPolicy, serializeAllowedDomains } from "./policy"; +import { policy } from "./schema"; import { DEFAULT_ACCESS_POLICY, type AccessPolicy, - type CreatePendingInvite, - type PendingInvite, type UpdateAccessPolicy, } from "./types"; -function generatePendingInviteId(): string { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join( - "", - ); - return `pinv_${hex}`; -} - export interface AccessPolicyStore { getPolicy(tenantId: string): Promise; /** Whether an explicit policy row exists for this tenant — distinct @@ -42,52 +24,6 @@ export interface AccessPolicyStore { tenantId: string, patch: UpdateAccessPolicy, ): Promise; - createPendingInvite( - tenantId: string, - input: CreatePendingInvite, - ): Promise; - listPendingInvites(tenantId: string): Promise; - deletePendingInvite(tenantId: string, id: string): Promise; - /** Exact-email match first (case-insensitive, unconsumed), then a - * domain-wildcard row for the email's domain. Domain rows are a - * standing rule and are never marked consumed by a match; only an - * exact-email row is single-use. */ - findMatchingPendingInvite(email: string): Promise; - /** Atomically marks an unconsumed row consumed — `UPDATE ... WHERE id - * = $1 AND consumed_at IS NULL RETURNING id` for the Postgres store, - * a single synchronous check-and-set for the in-memory one, so two - * concurrent redemptions of the same invite can never both win. - * Returns `true` for whichever caller's update actually flipped the - * row (this call won the race); `false` means the row was already - * consumed — by a concurrent caller or an earlier call — and the - * caller must treat that exactly like "no invite" rather than - * proceeding to redeem it again. */ - consumePendingInvite(id: string): Promise; -} - -type PendingInviteDbRow = typeof pendingInvite.$inferSelect; - -function toPendingInvite(row: PendingInviteDbRow): PendingInvite { - const result: { - id: string; - tenantId: string; - matchType: "email" | "domain"; - value: string; - roleId?: string; - invitedBy?: string; - createdAt: Date; - consumedAt?: Date; - } = { - id: row.id, - tenantId: row.tenantId, - matchType: row.matchType as "email" | "domain", - value: row.value, - createdAt: row.createdAt, - }; - if (row.roleId !== null) result.roleId = row.roleId; - if (row.invitedBy !== null) result.invitedBy = row.invitedBy; - if (row.consumedAt !== null) result.consumedAt = row.consumedAt; - return result; } export function createDrizzleAccessPolicyStore< @@ -168,92 +104,6 @@ export function createDrizzleAccessPolicyStore< return next; }); }, - - async createPendingInvite(tenantId, input) { - const value = - input.matchType === "email" - ? input.value.trim().toLowerCase() - : input.value.trim().toLowerCase().replace(/^@/, ""); - const row: { - id: string; - tenantId: string; - matchType: "email" | "domain"; - value: string; - roleId?: string; - invitedBy?: string; - } = { - id: generatePendingInviteId(), - tenantId, - matchType: input.matchType, - value, - }; - if (input.roleId !== undefined) row.roleId = input.roleId; - if (input.invitedBy !== undefined) row.invitedBy = input.invitedBy; - const [inserted] = await db.insert(pendingInvite).values(row).returning(); - if (inserted === undefined) { - throw new Error("pending invite insert returned no row"); - } - return toPendingInvite(inserted); - }, - - async listPendingInvites(tenantId) { - const rows = await db - .select() - .from(pendingInvite) - .where(eq(pendingInvite.tenantId, tenantId)); - return rows.map(toPendingInvite); - }, - - async deletePendingInvite(tenantId, id) { - await db - .delete(pendingInvite) - .where( - and(eq(pendingInvite.id, id), eq(pendingInvite.tenantId, tenantId)), - ); - }, - - async findMatchingPendingInvite(email) { - const normalized = email.trim().toLowerCase(); - const domain = domainOf(normalized); - - const [exact] = await db - .select() - .from(pendingInvite) - .where( - and( - eq(pendingInvite.matchType, "email"), - eq(pendingInvite.value, normalized), - isNull(pendingInvite.consumedAt), - ), - ); - if (exact !== undefined) return toPendingInvite(exact); - - if (domain === undefined) return undefined; - const [byDomain] = await db - .select() - .from(pendingInvite) - .where( - and( - eq(pendingInvite.matchType, "domain"), - eq(pendingInvite.value, domain), - ), - ); - return byDomain === undefined ? undefined : toPendingInvite(byDomain); - }, - - async consumePendingInvite(id) { - // A single statement: Postgres row-locking during the UPDATE - // serializes concurrent attempts on the same row, so exactly one - // concurrent call sees `consumed_at IS NULL` still true and gets - // a row back; every other one — whether racing in true parallel - // or arriving after — matches zero rows and gets `false`. - const [updated] = await db - .update(pendingInvite) - .set({ consumedAt: new Date() }) - .where(and(eq(pendingInvite.id, id), isNull(pendingInvite.consumedAt))) - .returning({ id: pendingInvite.id }); - return updated !== undefined; - }, }; } @@ -261,7 +111,6 @@ export function createDrizzleAccessPolicyStore< * Postgres required, same matching semantics as the real store. */ export function createInMemoryAccessPolicyStore(): AccessPolicyStore { const policies = new Map(); - const invites = new Map(); return { async getPolicy(tenantId) { @@ -282,74 +131,5 @@ export function createInMemoryAccessPolicyStore(): AccessPolicyStore { policies.set(tenantId, next); return next; }, - - async createPendingInvite(tenantId, input) { - const value = - input.matchType === "email" - ? input.value.trim().toLowerCase() - : input.value.trim().toLowerCase().replace(/^@/, ""); - const baseInvite = { - id: generatePendingInviteId(), - tenantId, - matchType: input.matchType, - value, - createdAt: new Date(), - }; - const invite: PendingInvite = - input.roleId !== undefined && input.invitedBy !== undefined - ? { ...baseInvite, roleId: input.roleId, invitedBy: input.invitedBy } - : input.roleId !== undefined - ? { ...baseInvite, roleId: input.roleId } - : input.invitedBy !== undefined - ? { ...baseInvite, invitedBy: input.invitedBy } - : baseInvite; - invites.set(invite.id, invite); - return invite; - }, - - async listPendingInvites(tenantId) { - return Array.from(invites.values()).filter( - (i) => i.tenantId === tenantId, - ); - }, - - async deletePendingInvite(tenantId, id) { - const invite = invites.get(id); - if (invite !== undefined && invite.tenantId === tenantId) { - invites.delete(id); - } - }, - - async findMatchingPendingInvite(email) { - const normalized = email.trim().toLowerCase(); - const domain = domainOf(normalized); - - const exact = Array.from(invites.values()).find( - (i) => - i.matchType === "email" && - i.value === normalized && - i.consumedAt === undefined, - ); - if (exact !== undefined) return exact; - - if (domain === undefined) return undefined; - return Array.from(invites.values()).find( - (i) => i.matchType === "domain" && i.value === domain, - ); - }, - - async consumePendingInvite(id) { - // No `await` between the read and the write below: this function - // runs to completion synchronously once started, so two calls - // made "concurrently" (e.g. via Promise.all) never interleave — - // the same atomicity the real store gets from a single UPDATE - // statement and Postgres row locking. - const invite = invites.get(id); - if (invite === undefined || invite.consumedAt !== undefined) { - return false; - } - invites.set(id, { ...invite, consumedAt: new Date() }); - return true; - }, }; } diff --git a/packages/access-policy/src/types.ts b/packages/access-policy/src/types.ts index cd5325b9e..3386b9615 100644 --- a/packages/access-policy/src/types.ts +++ b/packages/access-policy/src/types.ts @@ -43,22 +43,3 @@ export type PolicyRowShape = { readonly allowedDomains: string; readonly tenancyCreation: TenancyCreationMode; }; - -export const CreatePendingInvite = type({ - matchType: "'email' | 'domain'", - value: "string > 0", - "roleId?": "string > 0", - "invitedBy?": "string > 0", -}); -export type CreatePendingInvite = typeof CreatePendingInvite.infer; - -export type PendingInvite = { - readonly id: string; - readonly tenantId: string; - readonly matchType: "email" | "domain"; - readonly value: string; - readonly roleId?: string; - readonly invitedBy?: string; - readonly createdAt: Date; - readonly consumedAt?: Date; -}; diff --git a/packages/access-policy/test/store.drizzle.test.ts b/packages/access-policy/test/store.drizzle.test.ts index 656cf2345..ff1a5cdfb 100644 --- a/packages/access-policy/test/store.drizzle.test.ts +++ b/packages/access-policy/test/store.drizzle.test.ts @@ -129,58 +129,6 @@ describeIfDb("createDrizzleAccessPolicyStore", () => { } }); - test("pending invites: exact-email match is found and consumption sticks", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const store = createDrizzleAccessPolicyStore(drizzle(sql)); - const invite = await store.createPendingInvite("tnt_invites", { - matchType: "email", - value: "Person@Acme.Example", - }); - - const match = await store.findMatchingPendingInvite( - "person@acme.example", - ); - expect(match?.id).toBe(invite.id); - - const won = await store.consumePendingInvite(invite.id); - expect(won).toBe(true); - const afterConsume = await store.findMatchingPendingInvite( - "person@acme.example", - ); - expect(afterConsume).toBeUndefined(); - } finally { - await sql.end(); - } - }); - - test("consumePendingInvite is atomic: two concurrent consumers of the same row, exactly one wins", async () => { - const sql = postgres(scratchUrl, { max: 5 }); - try { - const store = createDrizzleAccessPolicyStore(drizzle(sql)); - const invite = await store.createPendingInvite("tnt_race", { - matchType: "email", - value: "racer@acme.example", - }); - - const results = await Promise.all([ - store.consumePendingInvite(invite.id), - store.consumePendingInvite(invite.id), - store.consumePendingInvite(invite.id), - ]); - - expect(results.filter((won) => won)).toHaveLength(1); - expect(results.filter((won) => !won)).toHaveLength(2); - - const rows = - await sql`select consumed_at from access_policy.pending_invite where id = ${invite.id}`; - expect(rows).toHaveLength(1); - expect(rows[0]?.["consumed_at"]).not.toBeNull(); - } finally { - await sql.end(); - } - }); - test("upsertPolicy is atomic: two concurrent patches to different fields on an existing row both land, neither reverts the other", async () => { const setupSql = postgres(scratchUrl, { max: 1 }); try { @@ -278,54 +226,4 @@ describeIfDb("createDrizzleAccessPolicyStore", () => { await sql.end(); } }); - - test("pending invites: a domain match is found for any email on that domain", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const store = createDrizzleAccessPolicyStore(drizzle(sql)); - await store.createPendingInvite("tnt_domain_invites", { - matchType: "domain", - value: "@Widgets.Example", - }); - - const matchOne = await store.findMatchingPendingInvite( - "alice@widgets.example", - ); - const matchTwo = await store.findMatchingPendingInvite( - "bob@widgets.example", - ); - expect(matchOne?.tenantId).toBe("tnt_domain_invites"); - expect(matchTwo?.tenantId).toBe("tnt_domain_invites"); - - const noMatch = await store.findMatchingPendingInvite( - "carol@other.example", - ); - expect(noMatch).toBeUndefined(); - } finally { - await sql.end(); - } - }); - - test("deletePendingInvite only removes the row for its own tenant", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const store = createDrizzleAccessPolicyStore(drizzle(sql)); - const invite = await store.createPendingInvite("tnt_delete_a", { - matchType: "email", - value: "someone@acme.example", - }); - - await store.deletePendingInvite("tnt_delete_b", invite.id); - expect( - await store.findMatchingPendingInvite("someone@acme.example"), - ).toBeDefined(); - - await store.deletePendingInvite("tnt_delete_a", invite.id); - expect( - await store.findMatchingPendingInvite("someone@acme.example"), - ).toBeUndefined(); - } finally { - await sql.end(); - } - }); }); diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index 5df5659a6..a9d82c909 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -27,7 +27,6 @@ import { parseAs, type ApiCall } from "@corbits/hub-api-client"; import { reportError } from "@corbits/error-sink"; import { checkSignupGate, - resolvePendingInviteOnLogin, type AccessPolicyStore, } from "@workbench/access-policy"; @@ -96,9 +95,9 @@ export type ProvisionArgs = { userId: string; userEmail: string; /** better-auth is configured without `requireEmailVerification` — an - * unverified email must never pass a domain-allowlist or redeem a - * pending invite meant for someone else. See - * `@workbench/access-policy`'s `evaluateSignupGate` doc comment. */ + * unverified email must never pass a domain-allowlist meant for + * someone else. See `@workbench/access-policy`'s `evaluateSignupGate` + * doc comment. */ userEmailVerified: boolean; /** Display name for the personal bench. Required to mint: when omitted * (shell membership probe), returns `needs-onboarding` and creates nothing. */ @@ -344,26 +343,6 @@ export async function provisionPersonalTenantIfNeeded( return { kind: "existing-member", seeded: true, tenantId: own.tenantId }; } - // No membership yet. Before any signup decision, check whether this - // email was already pre-vetted through a pending invite (an admin - // invited an email — or a whole domain — that had no user row yet at - // invite time). A match joins the invited tenant directly; the - // closed-by-default signup gate below never runs for it. This check - // runs on every call, including the bare membership probe, because - // resolving an invite is itself the first-login decision, not - // something that waits on the naming step. - if (args.accessPolicy !== undefined) { - const resolved = await resolvePendingInviteOnLogin({ - store: args.accessPolicy.store, - api: args.api, - cookies: args.cookies, - email: args.userEmail, - emailVerified: args.userEmailVerified, - allowUnverifiedEmails: args.accessPolicy.allowUnverifiedEmails, - }); - if (resolved !== undefined) return { kind: "existing-member" }; - } - // Creation requires an explicit display name from the onboarding // naming step — a shell membership probe (no name) must not silently // mint a personal bench. diff --git a/packages/settings-ui/src/access-policy-api.ts b/packages/settings-ui/src/access-policy-api.ts index 341a3bf1f..b162f4991 100644 --- a/packages/settings-ui/src/access-policy-api.ts +++ b/packages/settings-ui/src/access-policy-api.ts @@ -21,20 +21,6 @@ export type UpdateAccessPolicy = { readonly tenancyCreation?: AccessPolicy["tenancyCreation"]; }; -const PendingInvite = type({ - id: "string", - tenantId: "string", - matchType: "'email' | 'domain'", - value: "string", - "roleId?": "string", - "invitedBy?": "string", - createdAt: "string", - "consumedAt?": "string", -}); -export type PendingInvite = typeof PendingInvite.infer; - -const PendingInvitesPage = type({ data: PendingInvite.array() }); - export class AccessPolicyApiError extends Error { constructor( message: string, @@ -72,41 +58,3 @@ export function updateAccessPolicy( { method: "PATCH", body: JSON.stringify(patch) }, ); } - -export function listPendingInvites( - tenantId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/access-policy/pending-invites`, - PendingInvitesPage, - "loading pending invites", - ).then((page) => page.data); -} - -export function createPendingInvite( - tenantId: string, - input: { - readonly matchType: "email" | "domain"; - readonly value: string; - readonly roleId?: string; - }, -): Promise { - return request( - `/api/tenants/${tenantId}/access-policy/pending-invites`, - PendingInvite, - "adding that invite", - { method: "POST", body: JSON.stringify(input) }, - ); -} - -export function deletePendingInvite( - tenantId: string, - id: string, -): Promise { - return request( - `/api/tenants/${tenantId}/access-policy/pending-invites/${id}`, - (data) => data as void, - "removing that invite", - { method: "DELETE" }, - ); -} diff --git a/packages/settings-ui/src/index.ts b/packages/settings-ui/src/index.ts index 5c8ee2aa5..747ab01bc 100644 --- a/packages/settings-ui/src/index.ts +++ b/packages/settings-ui/src/index.ts @@ -24,11 +24,7 @@ export { // registry (CL-6843). Re-export when a preference store backs it. export { AuditSection } from "./audit-section"; export { AccessPolicyBlock, AccessPolicyEditor } from "./access-policy"; -export { - PeopleSection, - PeopleTable, - InvitePersonDialog, -} from "./people-section"; +export { PeopleSection, PeopleTable } from "./people-section"; export { RolesSection, RolesTable, diff --git a/packages/settings-ui/src/people-section.tsx b/packages/settings-ui/src/people-section.tsx index 5d9785bf7..68c11aff9 100644 --- a/packages/settings-ui/src/people-section.tsx +++ b/packages/settings-ui/src/people-section.tsx @@ -1,25 +1,17 @@ // The "People" settings section: every human (`kind: "user"`) principal on -// this bench, with invite/suspend/reactivate/remove/role actions over the -// native `/api/tenants/:tenantId/principals` and `/roles` routes, plus -// pending invites (an email that hasn't signed up yet) over -// `@workbench/access-policy`'s routes. Agent and workflow principals are -// machine identities, not people to manage here — Roles/Grants sections -// list every kind since those assign to machines too. Never renders a raw -// principal id or a raw agent refId — see `identity.ts`. +// this bench, with suspend/reactivate/remove/role actions over the native +// `/api/tenants/:tenantId/principals` and `/roles` routes. Agent and +// workflow principals are machine identities, not people to manage here — +// Roles/Grants sections list every kind since those assign to machines +// too. Never renders a raw principal id or a raw agent refId — see +// `identity.ts`. New humans join only when an operator creates them +// through native APIs. import { Badge, Button, ConfirmButton, - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, EmptyState, - Input, SettingsPanel, Table, TableBody, @@ -39,12 +31,6 @@ import { import { reportError } from "@corbits/error-sink"; import { PRINCIPAL_KIND_LABEL, principalLabel } from "./identity"; import { AccessPolicyBlock } from "./access-policy"; -import { - createPendingInvite, - deletePendingInvite, - listPendingInvites, - type PendingInvite, -} from "./access-policy-api"; import { SETTINGS_STRINGS } from "./strings"; import { @@ -58,8 +44,6 @@ import { type Role, } from "./tenancy-api"; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const STATUS_TONE: Record = { active: "success", @@ -91,13 +75,7 @@ export function PeopleSection({ const [query, setQuery] = useState>({ kind: "loading", }); - const [invitesQuery, setInvitesQuery] = useState< - APIQuery - >({ kind: "loading" }); const [reloadKey, setReloadKey] = useState(0); - const [inviteOpen, setInviteOpen] = useState(false); - const [inviting, setInviting] = useState(false); - const [inviteError, setInviteError] = useState(null); const [rowError, setRowError] = useState(null); function reload() { @@ -131,23 +109,6 @@ export function PeopleSection({ retry: reload, }); }); - setInvitesQuery({ kind: "loading" }); - listPendingInvites(tenantId) - .then((invites) => { - if (!cancelled) setInvitesQuery({ kind: "ready", data: invites }); - }) - .catch((cause: unknown) => { - if (cancelled) return; - if (cause instanceof UnauthenticatedError) { - setInvitesQuery({ kind: "unauthenticated" }); - return; - } - setInvitesQuery({ - kind: "error", - message: describeQueryError(cause), - retry: reload, - }); - }); return () => { cancelled = true; }; @@ -163,36 +124,6 @@ export function PeopleSection({ ); } - function handleInvite(email: string, roleId: string) { - if (tenantId === null) return; - setInviting(true); - setInviteError(null); - createPendingInvite(tenantId, { matchType: "email", value: email, roleId }) - .then(() => { - setInviteOpen(false); - reload(); - }) - .catch((cause: unknown) => { - reportError(cause, { operation: "settings.people.invite", tenantId }); - setInviteError(SETTINGS_STRINGS.peopleInviteError); - }) - .finally(() => setInviting(false)); - } - - function handleCancelInvite(invite: PendingInvite) { - if (tenantId === null) return; - setRowError(null); - deletePendingInvite(tenantId, invite.id) - .then(reload) - .catch((cause: unknown) => { - reportError(cause, { - operation: "settings.people.cancelInvite", - tenantId, - }); - setRowError(SETTINGS_STRINGS.pendingInviteCancelError); - }); - } - function handleStatusChange( principal: Principal, status: "active" | "suspended", @@ -267,11 +198,6 @@ export function PeopleSection({ title={SETTINGS_STRINGS.peopleSectionTitle} description={SETTINGS_STRINGS.peopleSectionDescription} > -
- -
{rowError !== null && (

{rowError} @@ -287,20 +213,7 @@ export function PeopleSection({ handleRoleChange(p, roleId, people, roles) } /> - - )} @@ -438,178 +351,3 @@ export function PeopleTable({ ); } - -function PendingInvitesBlock({ - query, - roles, - onCancel, -}: { - readonly query: APIQuery; - readonly roles: readonly Role[]; - readonly onCancel: (invite: PendingInvite) => void; -}) { - return ( -

-

- {SETTINGS_STRINGS.pendingInvitesTitle} -

-

- {SETTINGS_STRINGS.pendingInvitesDescription} -

- - {(invites) => - invites.length === 0 ? ( -

- {SETTINGS_STRINGS.pendingInvitesEmpty} -

- ) : ( - - - - Email - Role - Actions - - - - {invites.map((invite) => { - const role = roles.find((r) => r.id === invite.roleId); - return ( - - {invite.value} - - {role === undefined - ? SETTINGS_STRINGS.peopleInviteRoleMember - : role.name.toLowerCase() === "owner" - ? SETTINGS_STRINGS.peopleInviteRoleOwner - : SETTINGS_STRINGS.peopleInviteRoleMember} - - - onCancel(invite)} - > - {SETTINGS_STRINGS.pendingInviteCancel} - - - - ); - })} - -
- ) - } -
-
- ); -} - -export function InvitePersonDialog({ - open, - onOpenChange, - roles, - onInvite, - submitting, - error = null, -}: { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly roles: readonly Role[]; - readonly onInvite: (email: string, roleId: string) => void; - readonly submitting: boolean; - readonly error?: string | null; -}) { - const [email, setEmail] = useState(""); - const memberRole = findSystemRole(roles, "member"); - const ownerRole = findSystemRole(roles, "owner"); - const selectableRoles = [ownerRole, memberRole].filter( - (r): r is Role => r !== undefined, - ); - const [roleId, setRoleId] = useState(memberRole?.id ?? ""); - const canSubmit = EMAIL_PATTERN.test(email.trim()) && roleId.length > 0; - - function reset() { - setEmail(""); - setRoleId(memberRole?.id ?? ""); - } - - return ( - { - onOpenChange(next); - if (!next) reset(); - }} - > - - - {SETTINGS_STRINGS.peopleInviteDialogTitle} - - {SETTINGS_STRINGS.peopleInviteDialogDescription} - - - -
{ - event.preventDefault(); - if (canSubmit) onInvite(email.trim(), roleId); - }} - > - - - {error !== null && ( -

- {error} -

- )} -
-
- - - - -
-
- ); -} diff --git a/packages/settings-ui/src/strings.ts b/packages/settings-ui/src/strings.ts index 2929c8e80..592ae0039 100644 --- a/packages/settings-ui/src/strings.ts +++ b/packages/settings-ui/src/strings.ts @@ -55,20 +55,10 @@ export const SETTINGS_STRINGS = { peopleSectionDescription: "Everyone with a seat on this workbench.", peopleLoadError: "this workbench's people", peopleEmptyTitle: "No people yet", - peopleEmptyDescription: "Invite someone to get this workbench started.", - peopleInviteAction: "Invite someone", - peopleInviteDialogTitle: "Invite someone", - peopleInviteDialogDescription: - "They'll get access when they sign up with this email.", - peopleInviteEmailLabel: "Email", - peopleInviteEmailPlaceholder: "person@example.com", + peopleEmptyDescription: "This workbench has no people with a seat yet.", peopleInviteRoleLabel: "Role", peopleInviteRoleOwner: "Owner", peopleInviteRoleMember: "Member", - peopleInviteSubmit: "Invite", - peopleInviteInviting: "Inviting…", - peopleInviteCancel: "Cancel", - peopleInviteError: "Couldn't send that invite — try again.", peopleSuspend: "Suspend", peopleReactivate: "Reactivate", peopleRemove: "Remove", @@ -84,18 +74,10 @@ export const SETTINGS_STRINGS = { peopleLastOwnerError: "This workbench needs at least one owner — make someone else an owner first.", - pendingInvitesTitle: "Pending invites", - pendingInvitesDescription: "Invited, but not signed up yet.", - pendingInvitesEmpty: "No pending invites.", - pendingInvitesLoadError: "pending invites", - pendingInviteCancel: "Cancel", - pendingInviteCancelConfirm: "Cancel this invite?", - pendingInviteCancelError: "Couldn't cancel that invite — try again.", - accessPolicyHeading: "Who can join", accessPolicyLoadError: "who can join", accessPolicySignupLabel: "Self-signup", - accessPolicySignupOff: "Off — invites only (default)", + accessPolicySignupOff: "Off — no self-signup (default)", accessPolicySignupAllowedDomains: "Anyone with an allowed email domain", accessPolicySignupOpenOption: "Anyone with an account", accessPolicyDomainsLabel: "Allowed email domains", diff --git a/packages/settings-ui/test/people-section.test.tsx b/packages/settings-ui/test/people-section.test.tsx index 747ad61ad..916333457 100644 --- a/packages/settings-ui/test/people-section.test.tsx +++ b/packages/settings-ui/test/people-section.test.tsx @@ -3,10 +3,8 @@ // "workflow") must render exactly the human row — never the machine rows // flooding the human-management surface. // -// CL-5879: invite-by-email creates a pending invite (not a native invite, -// which only works for existing accounts), role changes go through the -// native role-assignment routes, the last owner can't be demoted, and a -// pending invite can be cancelled. +// Role changes go through the native role-assignment routes, and the last +// owner can't be demoted. // // CL-7378: the People table's Actions cells must not inherit page-fill's // nowrap+ellipsis clip, or Suspend/Remove controls get truncated. @@ -45,15 +43,6 @@ const json = (status: number, body: unknown) => const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 10))); -function setNativeValue(el: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )?.set; - setter?.call(el, value); - el.dispatchEvent(new Event("input", { bubbles: true })); -} - const timestamps = { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -131,7 +120,6 @@ function mockFetch(handlers: Record, calls: FetchCall[]) { } const rolesPage = { data: [OWNER_ROLE, MEMBER_ROLE], nextCursor: null }; -const noInvites = { data: [] }; describe("PeopleSection", () => { test("excludes workflow-kind rows and renders only the human member", async () => { @@ -148,7 +136,6 @@ describe("PeopleSection", () => { nextCursor: null, }, "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": noInvites, }, calls, ); @@ -174,7 +161,6 @@ describe("PeopleSection", () => { nextCursor: null, }, "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": noInvites, }, calls, ); @@ -189,7 +175,7 @@ describe("PeopleSection", () => { } }); - test("inviting someone creates a pending invite with the chosen role", async () => { + test("does not show Invite someone and never fetches reserved-email joins", async () => { const calls: FetchCall[] = []; mockFetch( { @@ -198,16 +184,6 @@ describe("PeopleSection", () => { nextCursor: null, }, "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": noInvites, - "POST /api/tenants/tnt_1/access-policy/pending-invites": () => - json(201, { - id: "pinv_1", - tenantId: "tnt_1", - matchType: "email", - value: "bob@example.com", - roleId: "role_member", - createdAt: timestamps.createdAt, - }), }, calls, ); @@ -215,44 +191,11 @@ describe("PeopleSection", () => { const { container, root } = mount(); try { await settle(); - - const inviteButton = Array.from( - container.querySelectorAll("button"), - ).find((b) => b.textContent === "Invite someone"); - expect(inviteButton).toBeDefined(); - act(() => - inviteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })), - ); - await settle(); - - const emailInput = document.querySelector( - 'input[type="email"]', - ) as HTMLInputElement; - act(() => setNativeValue(emailInput, "bob@example.com")); - await settle(); - - const form = document.getElementById( - "invite-person-form", - ) as HTMLFormElement; - act(() => { - form.dispatchEvent( - new Event("submit", { bubbles: true, cancelable: true }), - ); - }); - await settle(); - - const inviteCall = calls.find( - (c) => - c.url === "/api/tenants/tnt_1/access-policy/pending-invites" && - c.init?.method === "POST", - ); - if (inviteCall === undefined) throw new Error("invite call not found"); - const body = JSON.parse(inviteCall.init?.body as string); - expect(body).toEqual({ - matchType: "email", - value: "bob@example.com", - roleId: "role_member", - }); + expect(container.textContent).toContain("Alice Anderson"); + expect(container.textContent).not.toContain("Invite someone"); + expect( + calls.some((c) => c.url.includes("/access-policy/pending-invites")), + ).toBe(false); } finally { act(() => root.unmount()); container.remove(); @@ -276,7 +219,6 @@ describe("PeopleSection", () => { nextCursor: null, }, "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": noInvites, "POST /api/tenants/tnt_1/principals/prn_human_2/roles/role_owner": () => json(200, {}), "DELETE /api/tenants/tnt_1/principals/prn_human_2/roles/role_member": @@ -331,7 +273,6 @@ describe("PeopleSection", () => { nextCursor: null, }, "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": noInvites, }, calls, ); @@ -366,164 +307,19 @@ describe("PeopleSection", () => { } }); - test("cancelling a pending invite deletes it", async () => { - const calls: FetchCall[] = []; - mockFetch( - { - "/api/tenants/tnt_1/principals": { - data: [humanPrincipal()], - nextCursor: null, - }, - "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": { - data: [ - { - id: "pinv_1", - tenantId: "tnt_1", - matchType: "email", - value: "carol@example.com", - roleId: "role_member", - createdAt: timestamps.createdAt, - }, - ], - }, - "DELETE /api/tenants/tnt_1/access-policy/pending-invites/pinv_1": () => - json(204, undefined), - }, - calls, - ); - - const { container, root } = mount(); - try { - await settle(); - expect(container.textContent).toContain("carol@example.com"); - - const cancelButton = Array.from( - container.querySelectorAll("button"), - ).find((b) => b.textContent === "Cancel"); - expect(cancelButton).toBeDefined(); - act(() => - cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })), - ); - await settle(); - - const confirmButton = Array.from( - container.querySelectorAll("button"), - ).find((b) => b.textContent?.includes("Cancel this invite")); - if (confirmButton !== undefined) { - act(() => - confirmButton.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ), - ); - await settle(); - } - - expect( - calls.some( - (c) => - c.url === - "/api/tenants/tnt_1/access-policy/pending-invites/pinv_1" && - c.init?.method === "DELETE", - ), - ).toBe(true); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - // CL-7139: every mutation catch must report the failure through // reportError with its own operation, not just set the generic message. const REPORT_ERROR_CASES: { readonly name: string; readonly operation: string; readonly principals: unknown[]; - readonly invites: { readonly data: unknown[] }; readonly failingHandler: Record; readonly trigger: (container: HTMLDivElement) => Promise; }[] = [ - { - name: "invite", - operation: "settings.people.invite", - principals: [humanPrincipal()], - invites: noInvites, - failingHandler: { - "POST /api/tenants/tnt_1/access-policy/pending-invites": () => - json(500, { error: "boom" }), - }, - trigger: async (container) => { - const inviteButton = Array.from( - container.querySelectorAll("button"), - ).find((b) => b.textContent === "Invite someone"); - act(() => - inviteButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ), - ); - await settle(); - const emailInput = document.querySelector( - 'input[type="email"]', - ) as HTMLInputElement; - act(() => setNativeValue(emailInput, "bob@example.com")); - await settle(); - const form = document.getElementById( - "invite-person-form", - ) as HTMLFormElement; - act(() => { - form.dispatchEvent( - new Event("submit", { bubbles: true, cancelable: true }), - ); - }); - await settle(); - }, - }, - { - name: "cancelInvite", - operation: "settings.people.cancelInvite", - principals: [humanPrincipal()], - invites: { - data: [ - { - id: "pinv_1", - tenantId: "tnt_1", - matchType: "email", - value: "carol@example.com", - roleId: "role_member", - createdAt: timestamps.createdAt, - }, - ], - }, - failingHandler: { - "DELETE /api/tenants/tnt_1/access-policy/pending-invites/pinv_1": () => - json(500, { error: "boom" }), - }, - trigger: async (container) => { - const cancelButton = Array.from( - container.querySelectorAll("button"), - ).find((b) => b.textContent === "Cancel"); - act(() => - cancelButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ), - ); - await settle(); - const confirmButton = Array.from( - container.querySelectorAll("button"), - ).find((b) => b.textContent?.includes("Cancel this invite")); - act(() => - confirmButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ), - ); - await settle(); - }, - }, { name: "updateStatus", operation: "settings.people.updateStatus", principals: [humanPrincipal()], - invites: noInvites, failingHandler: { "PATCH /api/tenants/tnt_1/principals/prn_human_1": () => json(500, { error: "boom" }), @@ -544,7 +340,6 @@ describe("PeopleSection", () => { name: "remove", operation: "settings.people.remove", principals: [humanPrincipal()], - invites: noInvites, failingHandler: { "DELETE /api/tenants/tnt_1/principals/prn_human_1": () => json(500, { error: "boom" }), @@ -582,7 +377,6 @@ describe("PeopleSection", () => { roles: [{ id: MEMBER_ROLE.id, name: MEMBER_ROLE.name }], }), ], - invites: noInvites, failingHandler: { "DELETE /api/tenants/tnt_1/principals/prn_human_2/roles/role_member": () => json(500, { error: "boom" }), @@ -612,7 +406,6 @@ describe("PeopleSection", () => { nextCursor: null, }, "/api/tenants/tnt_1/roles": rolesPage, - "/api/tenants/tnt_1/access-policy/pending-invites": testCase.invites, ...testCase.failingHandler, }, calls, diff --git a/packages/settings-ui/test/query-retry.test.tsx b/packages/settings-ui/test/query-retry.test.tsx index 5e06a55aa..6c70bcb87 100644 --- a/packages/settings-ui/test/query-retry.test.tsx +++ b/packages/settings-ui/test/query-retry.test.tsx @@ -63,9 +63,6 @@ describe("PeopleSection retry", () => { if (url === "/api/tenants/tnt_1/roles") { return json(200, { data: [], nextCursor: null }); } - if (url === "/api/tenants/tnt_1/access-policy/pending-invites") { - return json(200, { data: [] }); - } throw new Error(`unexpected fetch: ${url}`); }) as unknown as typeof fetch; @@ -95,8 +92,6 @@ describe("PeopleSection retry", () => { globalThis.fetch = (async (url: string) => { if (url === "/api/tenants/tnt_1/principals") return json(401, {}); if (url === "/api/tenants/tnt_1/roles") return json(401, {}); - if (url === "/api/tenants/tnt_1/access-policy/pending-invites") - return json(401, {}); throw new Error(`unexpected fetch: ${url}`); }) as unknown as typeof fetch; diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index a1fb57392..d53941f88 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -143,8 +143,8 @@ const ALLOWLIST: readonly { }, { relPath: "packages/access-policy/src/schema.ts", - maxOccurrences: 2, - tables: ["policy", "pending_invite"], + maxOccurrences: 1, + tables: ["policy"], }, { relPath: "packages/onboarding/src/schema.ts",