Skip to content

Commit d14cab9

Browse files
Merge pull request #701 from corbitsdev/cl-7581-delete-pending_invite-table-settings-ui-and-redemption
Delete pending-invite table, settings UI, and redemption
2 parents 6dcd148 + fbe62f9 commit d14cab9

21 files changed

Lines changed: 79 additions & 1379 deletions

‎apps/hub/src/config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ const HubEnv = type({
191191
"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",
192192
),
193193
"ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe(
194-
"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",
194+
"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",
195195
),
196196
"HUB_ALLOW_GIT_INSIDE_WORK_TREE?": type("'1' | 'true'").describe(
197197
"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",

‎docs/TENANCY.md‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,15 @@ is never patched into a vendor route. Two layers, in order:
6868
domains"` (with an `allowedDomains` list), or `"open"`. An absent row
6969
is closed defaults, identical in effect to `selfSignup: "off"`.
7070

71-
**closed** — self-serve email signup is rejected. An owner adds members
72-
via the native invite/membership path, shares a **copy-link invite**
73-
(token in the URL, out of scope for delivery), or pre-vets an email (or
74-
a whole domain) as a **pending invite** — see below — before that person
75-
ever logs in.
71+
**closed** — self-serve email signup is rejected. New humans join only
72+
when an operator creates them through native APIs, or an owner shares a
73+
**copy-link invite** (token in the URL, out of scope for delivery).
7674

7775
**Email must be verified.** better-auth is configured without
7876
`requireEmailVerification`, so a freshly-registered address is not
7977
proof of ownership on its own. Every email-trust decision
80-
`@workbench/access-policy` makes — an allowed-domains match, an open-
81-
policy pass, a pending-invite redemption — requires
78+
`@workbench/access-policy` makes — an allowed-domains match or an
79+
open-policy pass — requires
8280
`user.emailVerified === true`; an unverified email is denied, fail-
8381
closed, regardless of what the policy or env otherwise allow.
8482
`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
10098
deployment fact, the policy row is a per-bench product setting, and the
10199
mismatch is meant to be visible, not auto-resolved.
102100

103-
### Pending invites (the not-yet-registered-user bridge)
104-
105-
The native invite route (`POST /tenants/:id/members/invite`) requires an
106-
existing `user` row looked up by email — it cannot invite someone who
107-
has never signed in. `@workbench/access-policy` bridges that gap with
108-
its own `pending_invite` table: an admin records an email (or a domain,
109-
for a standing "anyone at this domain may join" rule) against a tenant
110-
before that person has an account. On that email's first login, the
111-
onboarding hook resolves the match, redeems it through the native invite
112-
route (now that a user row exists) and an immediate activation, and
113-
consumes an exact-email match (a domain match is a standing rule and is
114-
never consumed).
115-
116101
### Workbench icon
117102

118103
Product metadata per tenant: monogram (1–2 characters) + color token.
@@ -329,7 +314,7 @@ needs a weaker role, that is an Interchange conversation first.
329314
- `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts
330315
- `@workbench/onboarding` — personal bench provision under operator parent
331316
- `@workbench/access-policy` — closed-by-default signup/sub-workbench-
332-
creation policy, pending invites (CL-5886)
317+
creation policy
333318
- `apps/hub` — `WORKBENCH_SIGNUP`, invite routes, icon routes; one of the
334319
explicitly-listed apps/hub mounts pending extraction into a package (see
335320
[ARCHITECTURE.md](../ARCHITECTURE.md), CL-6127)

‎packages/access-policy/README.md‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,31 @@
22

33
Closed-by-default access policy for the hub: per-tenant self-signup and
44
sub-workbench creation rules, layered over Interchange's native
5-
tenancy/RBAC without patching vendor routes. A pending invite resolves
6-
through the native invite route (`POST /tenants/:id/members/invite`) plus
7-
an immediate status flip to `"active"`, the same two primitives
8-
`packages/settings-ui` already drives by hand — this package only decides
9-
whether those calls are allowed to happen.
5+
tenancy/RBAC without patching vendor routes. Signup stays closed unless
6+
an explicit policy row or env flag opens it. New humans join only when
7+
an operator creates them through native APIs.
108

119
## Composition over Interchange
1210

13-
- No parallel tenancy or RBAC model: tenant creation, invites, and role
14-
checks all go through native `@intx/hub-api` routes and grants.
11+
- No parallel tenancy or RBAC model: tenant creation and role checks
12+
all go through native `@intx/hub-api` routes and grants.
1513
- `policy.ts` is the pure evaluation core (no DB, no HTTP, no env) —
1614
every decision (can this email self-sign-up, can this role create a
1715
sub-workbench) reduces to a function call over plain data.
18-
- `gate.ts` composes `policy.ts` with `store.ts` for the two entry points
19-
`packages/onboarding`'s first-login hook calls.
16+
- `gate.ts` composes `policy.ts` with `store.ts` for the signup-gate
17+
entry point `packages/onboarding`'s first-login hook calls.
2018

2119
## Key modules
2220

2321
- `policy.ts` — pure decision functions: `resolveAccessPolicy`,
2422
`domainAllowed`, `evaluateSignupGate`, `canCreateTenancy`.
2523
- `gate.ts` — composes policy + store for the onboarding first-login hook.
2624
- `routes.ts` — tenant-scoped HTTP surface: read/edit a tenant's own
27-
policy row, manage pending invites, and the gated
28-
`POST .../child-tenants` surface.
25+
policy row and the gated `POST .../child-tenants` surface.
2926
- `store.ts` — Postgres-backed persistence plus an in-memory fake for
3027
tests that don't need a real database.
31-
- `schema.ts` — the two product tables (`policy`, `pending_invite`),
32-
siloed in their own `access_policy` Postgres schema, never `public`.
28+
- `schema.ts` — the product table (`policy`), siloed in its own
29+
`access_policy` Postgres schema, never `public`.
3330
- `migrations.ts` — package-owned migrations with their own ledger table,
3431
so the package can be extracted without disentangling platform history.
3532
- `types.ts` — arktype shapes for anything crossing a trust boundary
Lines changed: 1 addition & 222 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { describe, expect, test } from "bun:test";
2-
import type { ApiCall, ApiResult } from "@corbits/hub-api-client";
32

4-
import { checkSignupGate, resolvePendingInviteOnLogin } from "./gate";
3+
import { checkSignupGate } from "./gate";
54
import { createInMemoryAccessPolicyStore } from "./store";
65

76
const verified = { emailVerified: true, allowUnverifiedEmails: false };
@@ -104,223 +103,3 @@ describe("checkSignupGate", () => {
104103
expect(result).toEqual({ allowed: true, reason: "policy_open" });
105104
});
106105
});
107-
108-
function fakeApi(
109-
handler: (method: string, path: string, body: unknown) => ApiResult,
110-
): ApiCall {
111-
return async (method, path, body) => handler(method, path, body);
112-
}
113-
114-
describe("resolvePendingInviteOnLogin", () => {
115-
test("no matching invite -> undefined, no native calls made", async () => {
116-
const store = createInMemoryAccessPolicyStore();
117-
let calls = 0;
118-
const api = fakeApi(() => {
119-
calls += 1;
120-
throw new Error("should not be called");
121-
});
122-
const result = await resolvePendingInviteOnLogin({
123-
store,
124-
api,
125-
cookies: [],
126-
email: "nobody@acme.example",
127-
...verified,
128-
});
129-
expect(result).toBeUndefined();
130-
expect(calls).toBe(0);
131-
});
132-
133-
test("exploit: an unverified email cannot hijack a pending invite for someone else's address", async () => {
134-
const store = createInMemoryAccessPolicyStore();
135-
await store.createPendingInvite("tnt_acme", {
136-
matchType: "email",
137-
value: "victim@acme.example",
138-
});
139-
let calls = 0;
140-
const api = fakeApi(() => {
141-
calls += 1;
142-
throw new Error("should not be called");
143-
});
144-
145-
const result = await resolvePendingInviteOnLogin({
146-
store,
147-
api,
148-
cookies: [],
149-
email: "victim@acme.example",
150-
emailVerified: false,
151-
allowUnverifiedEmails: false,
152-
});
153-
154-
expect(result).toBeUndefined();
155-
expect(calls).toBe(0);
156-
// The invite survives untouched — an unverified claim never even
157-
// looks it up, let alone consumes it.
158-
const stillPending = await store.findMatchingPendingInvite(
159-
"victim@acme.example",
160-
);
161-
expect(stillPending).toBeDefined();
162-
});
163-
164-
test("an exact-email match invites, activates, and is consumed", async () => {
165-
const store = createInMemoryAccessPolicyStore();
166-
const invite = await store.createPendingInvite("tnt_acme", {
167-
matchType: "email",
168-
value: "Person@Acme.example",
169-
roleId: "rol_member",
170-
});
171-
172-
const calls: { method: string; path: string; body: unknown }[] = [];
173-
const api = fakeApi((method, path, body) => {
174-
calls.push({ method, path, body });
175-
if (method === "POST" && path.endsWith("/members/invite")) {
176-
return { status: 201, data: { id: "prn_new" }, cookies: [] };
177-
}
178-
if (method === "PATCH" && path.endsWith("/prn_new")) {
179-
return { status: 200, data: { id: "prn_new" }, cookies: [] };
180-
}
181-
throw new Error(`unexpected call ${method} ${path}`);
182-
});
183-
184-
const result = await resolvePendingInviteOnLogin({
185-
store,
186-
api,
187-
cookies: ["session=abc"],
188-
email: "person@acme.example",
189-
...verified,
190-
});
191-
192-
expect(result).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" });
193-
expect(calls).toEqual([
194-
{
195-
method: "POST",
196-
path: "/api/tenants/tnt_acme/members/invite",
197-
body: { email: "person@acme.example", roleId: "rol_member" },
198-
},
199-
{
200-
method: "PATCH",
201-
path: "/api/tenants/tnt_acme/principals/prn_new",
202-
body: { status: "active" },
203-
},
204-
]);
205-
206-
const stillMatches = await store.findMatchingPendingInvite(
207-
"person@acme.example",
208-
);
209-
expect(stillMatches).toBeUndefined();
210-
void invite;
211-
});
212-
213-
test("a domain-wildcard match resolves but is never consumed (a standing rule)", async () => {
214-
const store = createInMemoryAccessPolicyStore();
215-
await store.createPendingInvite("tnt_acme", {
216-
matchType: "domain",
217-
value: "acme.example",
218-
});
219-
220-
const api = fakeApi((method, path) => {
221-
if (method === "POST" && path.endsWith("/members/invite")) {
222-
return { status: 201, data: { id: "prn_new" }, cookies: [] };
223-
}
224-
if (method === "PATCH") {
225-
return { status: 200, data: { id: "prn_new" }, cookies: [] };
226-
}
227-
throw new Error(`unexpected call ${method} ${path}`);
228-
});
229-
230-
const first = await resolvePendingInviteOnLogin({
231-
store,
232-
api,
233-
cookies: [],
234-
email: "anyone@acme.example",
235-
...verified,
236-
});
237-
expect(first).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" });
238-
239-
const second = await resolvePendingInviteOnLogin({
240-
store,
241-
api,
242-
cookies: [],
243-
email: "someone-else@acme.example",
244-
...verified,
245-
});
246-
expect(second).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" });
247-
});
248-
249-
test("a redemption failure at the native invite route throws; the invite is already spent (consume-before-redeem)", async () => {
250-
const store = createInMemoryAccessPolicyStore();
251-
await store.createPendingInvite("tnt_acme", {
252-
matchType: "email",
253-
value: "person@acme.example",
254-
});
255-
const api = fakeApi(() => ({
256-
status: 409,
257-
data: { error: { code: "conflict" } },
258-
cookies: [],
259-
}));
260-
261-
await expect(
262-
resolvePendingInviteOnLogin({
263-
store,
264-
api,
265-
cookies: [],
266-
email: "person@acme.example",
267-
...verified,
268-
}),
269-
).rejects.toThrow(/could not be redeemed/);
270-
271-
// Consumption happens before the native call specifically so a
272-
// race never lets two callers both redeem — the tradeoff is that a
273-
// downstream failure (network blip, native route down) leaves this
274-
// invite spent with no member added. That is deliberate: fail
275-
// closed on the race, not on the rare native-route failure.
276-
const stillPending = await store.findMatchingPendingInvite(
277-
"person@acme.example",
278-
);
279-
expect(stillPending).toBeUndefined();
280-
});
281-
282-
test("TOCTOU: two concurrent redemptions of the same exact-email invite — exactly one wins", async () => {
283-
const store = createInMemoryAccessPolicyStore();
284-
await store.createPendingInvite("tnt_acme", {
285-
matchType: "email",
286-
value: "person@acme.example",
287-
});
288-
289-
let inviteCalls = 0;
290-
const api = fakeApi((method, path) => {
291-
if (method === "POST" && path.endsWith("/members/invite")) {
292-
inviteCalls += 1;
293-
return { status: 201, data: { id: "prn_new" }, cookies: [] };
294-
}
295-
if (method === "PATCH") {
296-
return { status: 200, data: { id: "prn_new" }, cookies: [] };
297-
}
298-
throw new Error(`unexpected call ${method} ${path}`);
299-
});
300-
301-
const [first, second] = await Promise.all([
302-
resolvePendingInviteOnLogin({
303-
store,
304-
api,
305-
cookies: [],
306-
email: "person@acme.example",
307-
...verified,
308-
}),
309-
resolvePendingInviteOnLogin({
310-
store,
311-
api,
312-
cookies: [],
313-
email: "person@acme.example",
314-
...verified,
315-
}),
316-
]);
317-
318-
const winners = [first, second].filter((r) => r !== undefined);
319-
const losers = [first, second].filter((r) => r === undefined);
320-
expect(winners).toHaveLength(1);
321-
expect(losers).toHaveLength(1);
322-
// The loser never reaches the native invite route at all — it
323-
// loses at the atomic consume step, before any redemption call.
324-
expect(inviteCalls).toBe(1);
325-
});
326-
});

0 commit comments

Comments
 (0)