@wtfalch/authz-store 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +335 -5
  2. package/dist/activations.d.ts +60 -0
  3. package/dist/activations.js +516 -0
  4. package/dist/alerts.d.ts +86 -0
  5. package/dist/alerts.js +132 -0
  6. package/dist/assignments.d.ts +71 -0
  7. package/dist/assignments.js +103 -0
  8. package/dist/audit.d.ts +122 -0
  9. package/dist/audit.js +194 -0
  10. package/dist/binding.d.ts +17 -0
  11. package/dist/binding.js +8 -0
  12. package/dist/boot.d.ts +54 -0
  13. package/dist/boot.js +143 -0
  14. package/dist/bootstrap.d.ts +28 -0
  15. package/dist/bootstrap.js +83 -0
  16. package/dist/break-glass.d.ts +61 -0
  17. package/dist/break-glass.js +247 -0
  18. package/dist/credentials.d.ts +94 -0
  19. package/dist/credentials.js +230 -0
  20. package/dist/denial.d.ts +9 -0
  21. package/dist/denial.js +49 -0
  22. package/dist/erase.d.ts +58 -0
  23. package/dist/erase.js +108 -0
  24. package/dist/events.d.ts +77 -0
  25. package/dist/events.js +107 -0
  26. package/dist/export.d.ts +161 -0
  27. package/dist/export.js +293 -0
  28. package/dist/index.d.ts +33 -1
  29. package/dist/index.js +33 -1
  30. package/dist/install-owner.d.ts +25 -0
  31. package/dist/install-owner.js +159 -0
  32. package/dist/invitations.d.ts +160 -0
  33. package/dist/invitations.js +685 -0
  34. package/dist/membership-rows.d.ts +206 -0
  35. package/dist/membership-rows.js +271 -0
  36. package/dist/memberships.d.ts +87 -0
  37. package/dist/memberships.js +272 -0
  38. package/dist/migrate.js +15 -4
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/owners.d.ts +6 -1
  42. package/dist/owners.js +7 -3
  43. package/dist/person-records.d.ts +186 -0
  44. package/dist/person-records.js +263 -0
  45. package/dist/platform.d.ts +20 -0
  46. package/dist/platform.js +65 -0
  47. package/dist/policy-access.d.ts +260 -0
  48. package/dist/policy-access.js +348 -0
  49. package/dist/policy-resources.d.ts +5 -0
  50. package/dist/policy-resources.js +46 -0
  51. package/dist/policy-schema.d.ts +445 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +64 -0
  54. package/dist/policy.js +65 -0
  55. package/dist/propagate.d.ts +43 -0
  56. package/dist/propagate.js +47 -0
  57. package/dist/reconcile.d.ts +78 -0
  58. package/dist/reconcile.js +94 -0
  59. package/dist/resource-access.d.ts +344 -0
  60. package/dist/resource-access.js +656 -0
  61. package/dist/role-keys.d.ts +9 -0
  62. package/dist/role-keys.js +9 -0
  63. package/dist/roles.d.ts +36 -0
  64. package/dist/roles.js +191 -0
  65. package/dist/schema.d.ts +18 -1
  66. package/dist/schema.js +8 -1
  67. package/dist/startup.d.ts +57 -0
  68. package/dist/startup.js +113 -0
  69. package/dist/tenants.d.ts +213 -0
  70. package/dist/tenants.js +808 -0
  71. package/dist/tree-writes.d.ts +65 -0
  72. package/dist/tree-writes.js +201 -0
  73. package/dist/tree.d.ts +272 -0
  74. package/dist/tree.js +565 -0
  75. package/dist/types.d.ts +87 -0
  76. package/dist/types.js +15 -0
  77. package/migrations/0003_product_tenant_kind.sql +14 -0
  78. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  79. package/migrations/0005_activations.sql +71 -0
  80. package/migrations/0006_erase_person.sql +134 -0
  81. package/package.json +9 -4
@@ -0,0 +1,685 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { core } from '@wtfalch/authz';
3
+ import { and, desc, eq, gt, sql } from 'drizzle-orm';
4
+ import { alias } from 'drizzle-orm/pg-core';
5
+ import { identityWhere, primaryAssignment, roleAssignmentRefusal, roleById, writeParticipationPolicy, writePrimaryAssignment, } from './assignments.js';
6
+ import { record, recordAs } from './audit.js';
7
+ import { ownerCoverage, ownerSeatCovered } from './owners.js';
8
+ import { loadAccess, permits, permitsPlatform, permitsRead, policyRole, refreshAccess, } from './policy-access.js';
9
+ import { propagateMembershipChange, writerFromPrincipal } from './propagate.js';
10
+ import { roleDef } from './roles.js';
11
+ import { authzEvents, invitations, memberships, tenants } from './schema.js';
12
+ import { attachInTx } from './tree-writes.js';
13
+ import { attachCustomRoleBlockers, attachProblem, derivedEventAction, lockAttachScope, } from './tree.js';
14
+ import { done, refused, tenantTag } from './types.js';
15
+ /** A second name for `tenants`, so a query can join a tenant row and its parent's in the same select without a column-name collision. */
16
+ const parentTenants = alias(tenants, 'parent_tenants');
17
+ /**
18
+ * The token every invitation row uses: 32 random bytes (the package's own
19
+ * `core.invitation.tokenBytes`), hex-encoded for the link the mail carries,
20
+ * with its sha256 hex stored on the row. Exported so a host's own
21
+ * `createTenantAsOperator` builds its own pending invitation with exactly
22
+ * the same rule, rather than a second copy of it.
23
+ */
24
+ export function generateInvitationToken() {
25
+ const token = randomBytes(core.invitation.tokenBytes).toString('hex');
26
+ const tokenHash = createHash('sha256').update(token).digest('hex');
27
+ const expiresAt = new Date(Date.now() + core.invitation.ttlDays * 24 * 60 * 60 * 1000);
28
+ return { token, tokenHash, expiresAt };
29
+ }
30
+ const invitationRoleKey = sql `(select key from authz_roles where id=${invitations.roleId} and tenant_id=${invitations.tenantId})`;
31
+ const refusalMessage = () => 'You cannot delegate or remove that role with your current authority.';
32
+ const grantRefusalMessage = (_reason) => refusalMessage();
33
+ const removeRefusalMessage = (_reason) => refusalMessage();
34
+ /** `roleById`, resolved to a `ResourceRole`, or `null`. `binding` is a parameter here (not read from an `Access`) so `acceptInvitation` (wave 8b) can call this before it has one. */
35
+ async function roleForInvitation(tx, binding, tenantId, id) {
36
+ const row = await roleById(tx, binding, tenantId, id);
37
+ return row ? policyRole(row) : null;
38
+ }
39
+ async function recentSendCount(tx, tenantId) {
40
+ const [row] = await tx
41
+ .select({ count: sql `count(*)::int` })
42
+ .from(authzEvents)
43
+ .where(and(eq(authzEvents.tenantId, tenantId), eq(authzEvents.action, 'invitation.sent'), eq(authzEvents.outcome, 'success'), gt(authzEvents.occurredAt, sql `now() - interval '1 hour'`)));
44
+ return row?.count ?? 0;
45
+ }
46
+ /**
47
+ * Sends an invitation, or re-sends one: a pending row already sitting on
48
+ * `(tenant, email)` has its token and expiry rotated in place rather than a
49
+ * second row being created, which is also what the unique index on that
50
+ * pair enforces. The role on a rotated row is replaced by whatever this call
51
+ * asked for, since that is the role the grant check below just verified;
52
+ * leaving the old role in place would store something nobody re-checked.
53
+ *
54
+ * There is no check here for "this email already belongs to a member":
55
+ * a membership carries a principal id and a class, never an email (the
56
+ * import rule scans for exactly that), so there is no predicate to write.
57
+ * An address that already has an account simply accepts and finds it is
58
+ * already a member (see `acceptInvitation`, wave 8b).
59
+ */
60
+ export async function invite(access, db, scopeColumn, options) {
61
+ if (access.context === 'break_glass') {
62
+ return refused('break_glass', 'A break-glass session never invites anyone.');
63
+ }
64
+ const email = options.email.trim().toLowerCase();
65
+ if (email.length > 254 || !/^\S+@\S+\.\S+$/.test(email))
66
+ return refused('invalid_email', 'Enter a valid email address.');
67
+ return db.transaction(async (tx) => {
68
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
69
+ if (!fresh || fresh.context === 'break_glass')
70
+ return refused('no_permission', 'Your authority changed.');
71
+ const currentAccess = fresh;
72
+ const tenant = fresh.tenant;
73
+ const targetRole = await roleDef(tx, currentAccess.binding, tenant, options.role);
74
+ if (!targetRole) {
75
+ return refused('unknown_role', `"${options.role}" is not a role this organisation has.`);
76
+ }
77
+ const grantRefusal = roleAssignmentRefusal(currentAccess, targetRole, primaryAssignment(targetRole, { id: 'invited-person', class: 'human' }), 'members:grant');
78
+ if (grantRefusal)
79
+ return refused(grantRefusal, grantRefusalMessage(grantRefusal));
80
+ const sentThisHour = await recentSendCount(tx, tenant.id);
81
+ if (sentThisHour >= options.capPerHour) {
82
+ await record(tx, currentAccess, {
83
+ action: 'invitation.sent',
84
+ tenantId: tenant.id,
85
+ targetType: 'invitation',
86
+ // Truncated: the audit row's target_id CHECK caps it at 256
87
+ // characters, and an email is caller-supplied text with no length
88
+ // limit of its own, so a long one must not turn a graceful refusal
89
+ // into a failed insert.
90
+ targetId: email.slice(0, 256),
91
+ outcome: 'denied',
92
+ reason: `more than ${options.capPerHour} invitations sent from this organisation in the last hour`,
93
+ });
94
+ return refused('cap_reached', `This organisation can only send ${options.capPerHour} invitations an hour.`);
95
+ }
96
+ const [existingPending] = await tx
97
+ .select({ id: invitations.id, roleId: invitations.roleId, expiresAt: invitations.expiresAt })
98
+ .from(invitations)
99
+ .where(and(eq(invitations.tenantId, tenant.id), eq(invitations.email, email), eq(invitations.status, 'pending')))
100
+ .limit(1);
101
+ if (existingPending && existingPending.roleId !== targetRole.id) {
102
+ const previous = await roleForInvitation(tx, currentAccess.binding, tenant.id, existingPending.roleId);
103
+ if (!previous ||
104
+ roleAssignmentRefusal(currentAccess, previous, primaryAssignment(previous, { id: 'invited-person', class: 'human' }), 'members:revoke'))
105
+ return refused('guarded', 'You cannot replace this invitation’s role.');
106
+ // Not `.builtIn`: a starter owner role (ADR 0016) is `built_in: false`
107
+ // for a tenant created after that change, but only the app's own owner
108
+ // role can ever hold the literal key `owner` (custom keys start with
109
+ // `c_`), so the key alone still tells a real owner change from a
110
+ // lateral one between two non-owner roles.
111
+ if (previous.key === 'owner' && targetRole.key !== 'owner') {
112
+ const coverage = await ownerCoverage(tx, currentAccess.binding, tenant.id);
113
+ if (!ownerSeatCovered({
114
+ ...coverage,
115
+ pendingOwnerInvitations: coverage.pendingOwnerInvitations -
116
+ (existingPending.expiresAt.getTime() > Date.now() ? 1 : 0),
117
+ }))
118
+ return refused('last_owner', 'This invitation is the organisation’s only recovery owner.');
119
+ }
120
+ }
121
+ const { token, tokenHash, expiresAt } = generateInvitationToken();
122
+ let invitationId;
123
+ if (existingPending) {
124
+ invitationId = existingPending.id;
125
+ await tx
126
+ .update(invitations)
127
+ // mail_state and its clock reset with the token. A resend is a fresh
128
+ // attempt, and leaving the old 'failed' behind meant the stuck-mail
129
+ // alert went on firing about a row somebody had just fixed, with a
130
+ // timestamp older than the remediation.
131
+ .set({
132
+ roleId: targetRole.id,
133
+ tokenHash,
134
+ expiresAt,
135
+ invitedBy: currentAccess.actor.id,
136
+ invitedByClass: currentAccess.actor.class,
137
+ mailState: 'pending',
138
+ mailStateAt: null,
139
+ })
140
+ .where(eq(invitations.id, existingPending.id));
141
+ }
142
+ else {
143
+ const [row] = await tx
144
+ .insert(invitations)
145
+ .values({
146
+ tenantId: tenant.id,
147
+ email,
148
+ roleId: targetRole.id,
149
+ tokenHash,
150
+ expiresAt,
151
+ invitedBy: currentAccess.actor.id,
152
+ invitedByClass: currentAccess.actor.class,
153
+ })
154
+ .returning({ id: invitations.id });
155
+ if (!row)
156
+ throw new Error('invite: no row returned from the insert');
157
+ invitationId = row.id;
158
+ }
159
+ await record(tx, currentAccess, {
160
+ action: 'invitation.sent',
161
+ tenantId: tenant.id,
162
+ targetType: 'invitation',
163
+ targetId: invitationId,
164
+ after: { email, role: options.role },
165
+ });
166
+ return done({ id: invitationId, token, link: options.link(token), email }, [
167
+ tenantTag(tenant.id),
168
+ ]);
169
+ });
170
+ }
171
+ /** `members:grant`; sets whether the mail for one invitation went out. Never audited: this is delivery bookkeeping, not an authorisation event. */
172
+ export async function markInvitationMail(access, db, invitationId, state) {
173
+ if (access.context === 'break_glass') {
174
+ return refused('break_glass', 'A break-glass session never manages invitations.');
175
+ }
176
+ if (!permits(access, 'members:grant')) {
177
+ return refused('no_permission', 'You do not have permission to manage invitations here.');
178
+ }
179
+ const [row] = await db
180
+ .update(invitations)
181
+ .set({ mailState: state, mailStateAt: new Date() })
182
+ .where(and(eq(invitations.id, invitationId), eq(invitations.tenantId, access.tenant.id)))
183
+ .returning({ id: invitations.id });
184
+ if (!row)
185
+ return refused('invitation_not_found', 'This invitation no longer exists.');
186
+ return done(undefined, [tenantTag(access.tenant.id)]);
187
+ }
188
+ /**
189
+ * A re-invite: reads the row's current email and role and sends through the
190
+ * same path `invite` uses, which is what rotates its token and expiry.
191
+ * Gated on `members:grant` before the row is even read, the same as
192
+ * `markInvitationMail`: without that, a member who cannot see invitations at
193
+ * all could still learn that a given invitation id exists here, and whether
194
+ * it is still pending, just from which refusal comes back.
195
+ */
196
+ export async function resendInvitation(access, db, scopeColumn, invitationId, options) {
197
+ if (!permits(access, 'members:grant')) {
198
+ return refused('no_permission', 'You do not have permission to manage invitations here.');
199
+ }
200
+ const [row] = await db
201
+ .select({ email: invitations.email, role: invitationRoleKey, status: invitations.status })
202
+ .from(invitations)
203
+ .where(and(eq(invitations.id, invitationId), eq(invitations.tenantId, access.tenant.id)))
204
+ .limit(1);
205
+ if (!row)
206
+ return refused('invitation_not_found', 'This invitation no longer exists.');
207
+ if (row.status !== 'pending') {
208
+ return refused('not_pending', 'This invitation is no longer pending, so it cannot be resent.');
209
+ }
210
+ return invite(access, db, scopeColumn, {
211
+ email: row.email,
212
+ role: row.role,
213
+ capPerHour: options.capPerHour,
214
+ link: options.link,
215
+ });
216
+ }
217
+ /**
218
+ * Withdraws a pending invitation. Guarded the same way removing a member is:
219
+ * `members:revoke` and the guard-permission test on the role being taken
220
+ * away, and, for an owner invitation specifically, the same coverage rule
221
+ * a host's own membership removal enforces for a membership row: revoking it
222
+ * must not leave the tenant with no owner and no other pending owner
223
+ * invitation.
224
+ */
225
+ export async function revokeInvitation(access, db, scopeColumn, invitationId) {
226
+ if (access.context === 'break_glass') {
227
+ return refused('break_glass', 'A break-glass session never revokes an invitation.');
228
+ }
229
+ // Checked before any row is read, the same as markInvitationMail and
230
+ // resendInvitation: without this, a member holding no members:revoke at
231
+ // all could still learn that an invitation id exists and whether it is
232
+ // pending, from which refusal comes back below.
233
+ if (!permits(access, 'members:revoke')) {
234
+ return refused('no_revoke_permission', removeRefusalMessage('no_revoke_permission'));
235
+ }
236
+ return db.transaction(async (tx) => {
237
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
238
+ if (!fresh || fresh.context === 'break_glass')
239
+ return refused('no_permission', 'Your authority changed.');
240
+ const currentAccess = fresh;
241
+ const tenant = fresh.tenant;
242
+ const [row] = await tx
243
+ .select({
244
+ id: invitations.id,
245
+ roleId: invitations.roleId,
246
+ status: invitations.status,
247
+ expiresAt: invitations.expiresAt,
248
+ })
249
+ .from(invitations)
250
+ .where(and(eq(invitations.id, invitationId), eq(invitations.tenantId, tenant.id)))
251
+ .for('update');
252
+ if (!row)
253
+ return refused('invitation_not_found', 'This invitation no longer exists.');
254
+ if (row.status !== 'pending') {
255
+ return refused('not_pending', 'This invitation is no longer pending.');
256
+ }
257
+ const invitedRole = await roleForInvitation(tx, currentAccess.binding, tenant.id, row.roleId);
258
+ if (!invitedRole) {
259
+ return refused('unknown_role', 'The invited role no longer exists in this organisation.');
260
+ }
261
+ const removeRefusal = roleAssignmentRefusal(currentAccess, invitedRole, primaryAssignment(invitedRole, { id: 'invited-person', class: 'human' }), 'members:revoke');
262
+ if (removeRefusal)
263
+ return refused(removeRefusal, removeRefusalMessage(removeRefusal));
264
+ // Not `.builtIn`: see the same note above `roleAssignmentRefusal`'s
265
+ // `previous.key === 'owner'` check further up this file.
266
+ if (invitedRole.key === 'owner') {
267
+ // This row is only still part of the current coverage count while it
268
+ // is both pending and unexpired, which is exactly what `ownerCoverage`
269
+ // itself counts; subtracting one only makes sense when it was counted.
270
+ const coverage = await ownerCoverage(tx, currentAccess.binding, tenant.id);
271
+ const stillCounted = row.expiresAt.getTime() > Date.now();
272
+ const remainingPending = stillCounted
273
+ ? Math.max(0, coverage.pendingOwnerInvitations - 1)
274
+ : coverage.pendingOwnerInvitations;
275
+ const wouldBeCovered = ownerSeatCovered({
276
+ directOwners: coverage.directOwners,
277
+ pendingOwnerInvitations: remainingPending,
278
+ });
279
+ if (!wouldBeCovered) {
280
+ return refused('owner_seat_uncovered', 'Revoking this invitation would leave the organisation with no owner and no other pending owner invitation.');
281
+ }
282
+ }
283
+ await tx
284
+ .update(invitations)
285
+ .set({ status: 'revoked', revokedBy: currentAccess.actor.id, revokedAt: new Date() })
286
+ .where(eq(invitations.id, row.id));
287
+ await record(tx, currentAccess, {
288
+ action: 'invitation.revoked',
289
+ tenantId: tenant.id,
290
+ targetType: 'invitation',
291
+ targetId: row.id,
292
+ });
293
+ return done(undefined, [tenantTag(tenant.id)]);
294
+ });
295
+ }
296
+ /**
297
+ * Whether the operator who invited a tenant's first owner still could: a
298
+ * current membership in the operator tenant whose role resolves to
299
+ * `tenants:create`, and a target tenant that still has no direct owner.
300
+ */
301
+ async function operatorMayStillOnboard(tx, binding, db, scopeColumn, operatorId, tenantId) {
302
+ const [operatorTenant] = await tx
303
+ .select({
304
+ id: tenants.id,
305
+ ceiling: tenants.ceiling,
306
+ selfDenied: tenants.selfDenied,
307
+ state: tenants.state,
308
+ })
309
+ .from(tenants)
310
+ .where(eq(tenants.kind, 'operator'))
311
+ .limit(1);
312
+ if (!operatorTenant)
313
+ return false;
314
+ const actor = await loadAccess(tx, binding, db, scopeColumn, { id: operatorId, class: 'human', display: operatorId, email: null, emailVerified: false }, operatorTenant.id, false);
315
+ if (!actor || !permitsPlatform(actor, 'tenants:create'))
316
+ return false;
317
+ const coverage = await ownerCoverage(tx, binding, tenantId);
318
+ return coverage.directOwners === 0;
319
+ }
320
+ /**
321
+ * Thrown only inside `attemptBornAttach`'s own nested transaction, to unwind
322
+ * it on an ordinary refusal without touching anything the enclosing
323
+ * transaction already wrote. Caught immediately outside the nested
324
+ * transaction and turned back into a worded `AttachRefusal`; never reaches
325
+ * `acceptInvitation`'s own caller as a throw.
326
+ */
327
+ class AttachAttemptRefused extends Error {
328
+ reason;
329
+ constructor(reason, message) {
330
+ super(message);
331
+ this.reason = reason;
332
+ this.name = 'AttachAttemptRefused';
333
+ }
334
+ }
335
+ /**
336
+ * The attachment half of a born-attached invitation (D10, D19, M5), run in
337
+ * its own nested transaction, a Postgres savepoint, so a refusal here rolls
338
+ * back only this attempt and never the owner membership `acceptInvitation`
339
+ * already wrote in the enclosing transaction. Getting this backwards, by
340
+ * letting a refusal here abort the whole acceptance, would lock a customer
341
+ * out of the organisation they were just given, over something only the
342
+ * parent side controls (its ceiling narrowing, or a custom role appearing at
343
+ * it) between the tenant's creation and this moment.
344
+ *
345
+ * Runs the same two checks `nesting.ts`'s `acceptAttach` runs before writing
346
+ * (`attachProblem`, then `attachCustomRoleBlockers` over the pairing the
347
+ * attach would create, D19's rule that a derived row never carries a custom
348
+ * role), then `attachInTx`, `tree-writes.ts`'s only writer of the attachment
349
+ * and the rows it fills; nothing here is a second implementation of what
350
+ * that file already decides. `policy` is `attachProblem`'s own parameter
351
+ * (wave 4a); `binding` is threaded to every tree function that needs it.
352
+ * Returns the refusal, or null once attached, for the caller to fold into
353
+ * the result and the audit trail.
354
+ */
355
+ async function attemptBornAttach(tx, binding, policy, principal, context, parentId, childId) {
356
+ try {
357
+ return await tx.transaction(async (attachTx) => {
358
+ await lockAttachScope(attachTx, parentId, childId);
359
+ const problem = await attachProblem(attachTx, policy, parentId, childId);
360
+ if (problem)
361
+ throw new AttachAttemptRefused(problem.reason, problem.message);
362
+ const blockers = await attachCustomRoleBlockers(attachTx, binding, parentId, childId);
363
+ if (blockers.length > 0) {
364
+ const offenders = [...new Set(blockers.map((blocker) => blocker.role))];
365
+ throw new AttachAttemptRefused('custom_role_at_parent', `Cannot attach yet: a custom role there would have to reach this organisation, which a derived membership can never carry (${offenders.join(', ')}). Move whoever holds it to a system role first.`);
366
+ }
367
+ const changes = await attachInTx(attachTx, binding, parentId, childId);
368
+ for (const change of changes) {
369
+ await recordAs(attachTx, principal, context, {
370
+ action: derivedEventAction(change.kind),
371
+ tenantId: change.tenantId,
372
+ targetType: 'membership',
373
+ targetId: change.principalId,
374
+ before: change.before
375
+ ? { role: change.before.role, viaTenantId: change.before.viaTenantId }
376
+ : undefined,
377
+ after: change.after
378
+ ? { role: change.after.role, viaTenantId: change.after.viaTenantId }
379
+ : undefined,
380
+ });
381
+ }
382
+ await recordAs(attachTx, principal, context, {
383
+ action: 'tenant.parent_attached',
384
+ tenantId: childId,
385
+ targetType: 'tenant',
386
+ targetId: childId,
387
+ });
388
+ return null;
389
+ });
390
+ }
391
+ catch (error) {
392
+ if (error instanceof AttachAttemptRefused) {
393
+ return { reason: error.reason, message: error.message };
394
+ }
395
+ throw error;
396
+ }
397
+ }
398
+ /**
399
+ * Accepts an invitation. There is no `Access` yet: the person is not a
400
+ * member until this function decides they may become one, so every check
401
+ * here reads rows directly rather than resolving one. Order follows D10 and
402
+ * D3 exactly: not pending, expired, unverified email, mismatched email,
403
+ * tenant state, then the inviter's authority re-checked as it stands now
404
+ * (not as it stood when they sent the invitation), then the profile, then
405
+ * the membership itself.
406
+ *
407
+ * `binding` and `policy` are threaded through to `roleForInvitation`,
408
+ * `loadAccess`/`operatorMayStillOnboard` and `attemptBornAttach`, the same
409
+ * as every other write this campaign has moved. `profiles` is the host's own
410
+ * port over its `profiles` table's one question this function asks --
411
+ * reusing `Pick<OwnerActivityLookup, 'personKnown'>` from install-owner.ts
412
+ * rather than declaring a second port for the same "has this id ever signed
413
+ * in" question.
414
+ */
415
+ export async function acceptInvitation(principal, db, scopeColumn, binding, policy, profiles, token) {
416
+ if (principal.class !== 'human')
417
+ return refused('not_a_person', 'Only a signed-in person can accept an invitation.');
418
+ const tokenHash = createHash('sha256').update(token).digest('hex');
419
+ return db.transaction(async (tx) => {
420
+ const [location] = await tx
421
+ .select({ tenantId: invitations.tenantId })
422
+ .from(invitations)
423
+ .where(eq(invitations.tokenHash, tokenHash))
424
+ .limit(1);
425
+ if (!location)
426
+ return refused('invitation_not_found', 'This invitation link is not valid.');
427
+ await tx
428
+ .select({ id: tenants.id })
429
+ .from(tenants)
430
+ .where(eq(tenants.id, location.tenantId))
431
+ .for('update');
432
+ const [row] = await tx
433
+ .select()
434
+ .from(invitations)
435
+ .where(eq(invitations.tokenHash, tokenHash))
436
+ .for('update');
437
+ if (!row)
438
+ return refused('invitation_not_found', 'This invitation link is not valid.');
439
+ if (row.status !== 'pending') {
440
+ return refused('not_pending', 'This invitation has already been used.');
441
+ }
442
+ const [tenantRow] = await tx
443
+ .select({
444
+ id: tenants.id,
445
+ kind: tenants.kind,
446
+ name: tenants.name,
447
+ state: tenants.state,
448
+ ceiling: tenants.ceiling,
449
+ selfDenied: tenants.selfDenied,
450
+ })
451
+ .from(tenants)
452
+ .where(eq(tenants.id, row.tenantId))
453
+ .limit(1);
454
+ if (!tenantRow)
455
+ return refused('tenant_not_found', 'This organisation no longer exists.');
456
+ const kind = tenantRow.kind;
457
+ // Events about an operator tenant carry context 'operator', the same
458
+ // rule loadAccess uses to decide a resolved Access's own context.
459
+ const context = kind === 'operator' ? 'operator' : 'standard';
460
+ if (row.expiresAt.getTime() <= Date.now()) {
461
+ await tx.update(invitations).set({ status: 'expired' }).where(eq(invitations.id, row.id));
462
+ await recordAs(tx, principal, context, {
463
+ action: 'invitation.expired',
464
+ tenantId: tenantRow.id,
465
+ targetType: 'invitation',
466
+ targetId: row.id,
467
+ reason: 'expired',
468
+ });
469
+ return refused('expired', 'This invitation has expired. Ask for a new one.');
470
+ }
471
+ if (!principal.emailVerified) {
472
+ return refused('email_unverified', 'Verify your email address before accepting this invitation.');
473
+ }
474
+ if (!principal.email || principal.email.toLowerCase() !== row.email) {
475
+ return refused('email_mismatch', 'This invitation was sent to a different address.');
476
+ }
477
+ // Read as a refusal, not a status change: the row stays pending, because
478
+ // it is exactly as good once the tenant is active again.
479
+ if (tenantRow.state === 'suspended' || tenantRow.state === 'archived') {
480
+ return refused('tenant_state', `This organisation is ${tenantRow.state}, so nobody can join it right now.`);
481
+ }
482
+ const invitedRole = await roleForInvitation(tx, binding, tenantRow.id, row.roleId);
483
+ if (!invitedRole)
484
+ return refused('unknown_role', 'The invited role no longer exists.');
485
+ let inviterMay = false;
486
+ if (row.invitedBy && row.invitedByClass) {
487
+ const inviter = await loadAccess(tx, binding, db, scopeColumn, {
488
+ id: row.invitedBy,
489
+ class: row.invitedByClass,
490
+ display: row.invitedBy,
491
+ email: null,
492
+ emailVerified: false,
493
+ }, tenantRow.id, false, true);
494
+ if (inviter)
495
+ inviterMay =
496
+ roleAssignmentRefusal(inviter, invitedRole, primaryAssignment(invitedRole, { id: principal.id, class: principal.class }), 'members:grant') === null;
497
+ // Not `.builtIn`: see the same note above `roleAssignmentRefusal`'s
498
+ // `previous.key === 'owner'` check further up this file.
499
+ else if (row.invitedByClass === 'human' && invitedRole.key === 'owner' && kind === 'customer')
500
+ inviterMay = await operatorMayStillOnboard(tx, binding, db, scopeColumn, row.invitedBy, tenantRow.id);
501
+ }
502
+ if (!inviterMay) {
503
+ await tx.update(invitations).set({ status: 'expired' }).where(eq(invitations.id, row.id));
504
+ await recordAs(tx, principal, context, {
505
+ action: 'invitation.expired',
506
+ tenantId: tenantRow.id,
507
+ targetType: 'invitation',
508
+ targetId: row.id,
509
+ reason: 'inviter_no_longer_may',
510
+ });
511
+ return refused('inviter_no_longer_may', 'Whoever sent this invitation can no longer grant that role. Ask for a new one.');
512
+ }
513
+ // ensureProfile is the caller's job before this is ever reached (a
514
+ // signed-in person always has one by then); this is the fail-closed
515
+ // check for a stray call that skipped it. `profiles` is the host's own
516
+ // port over its own `profiles` (or equivalent) table.
517
+ if (!(await profiles.personKnown(principal.id)))
518
+ return refused('no_profile', 'Sign in first, then open this link again.');
519
+ const [existingMembership] = await tx
520
+ .select({ principalId: memberships.principalId, source: memberships.source })
521
+ .from(memberships)
522
+ .where(identityWhere(tenantRow.id, principal))
523
+ .limit(1);
524
+ // Reached here through a derived row, this acceptance would do nothing at
525
+ // all: the role it offers cannot be written over a row the parent
526
+ // organisation manages (D19: a child sees a derived row and cannot change
527
+ // it), and the branch below would have marked the invitation accepted and
528
+ // returned success while granting nothing. Refused in words instead, with
529
+ // the invitation left pending, so it becomes acceptable again the day the
530
+ // organisations are detached. Whether a child should be able to hold a
531
+ // direct row over a derived one is a real question and a deliberate door,
532
+ // not something to decide inside an acceptance.
533
+ if (existingMembership?.source === 'inherited') {
534
+ return refused('inherited', 'You already reach this organisation through the one that holds it, and that role is managed there, so this invitation cannot add another.');
535
+ }
536
+ if (existingMembership) {
537
+ await tx
538
+ .update(invitations)
539
+ .set({ status: 'accepted', acceptedBy: principal.id, acceptedAt: new Date() })
540
+ .where(eq(invitations.id, row.id));
541
+ await recordAs(tx, principal, context, {
542
+ action: 'invitation.accepted',
543
+ tenantId: tenantRow.id,
544
+ targetType: 'invitation',
545
+ targetId: row.id,
546
+ });
547
+ return done({ tenantId: tenantRow.id }, [tenantTag(tenantRow.id)]);
548
+ }
549
+ // The membership row's own insert is @wtfalch/people's `grantMembership`,
550
+ // inlined here rather than imported: `@wtfalch/people` 0.2.3 depends on
551
+ // `@wtfalch/authz-store` itself, so importing it back would be a cycle
552
+ // (authz#83 wave 8 decision 5). The role assignment and
553
+ // participation-policy writes stay this file's own, run right after the
554
+ // insert inside the same transaction, the same split a host's own
555
+ // `memberships.ts` draws through `onGranted`. `@wtfalch/people`'s
556
+ // `grantMembership` takes no audit argument here (Boule never passed
557
+ // one), so this inlined insert writes none either; `invitation.accepted`
558
+ // and `membership.created` below are this function's own rows.
559
+ const [alreadyMember] = await tx
560
+ .select({ principalId: memberships.principalId })
561
+ .from(memberships)
562
+ .where(identityWhere(tenantRow.id, principal))
563
+ .limit(1);
564
+ // Unreachable in practice: `existingMembership` above already checked
565
+ // this inside the same transaction.
566
+ if (alreadyMember)
567
+ return refused('already_member', 'You already participate here.');
568
+ await tx.insert(memberships).values({
569
+ tenantId: tenantRow.id,
570
+ principalId: principal.id,
571
+ principalClass: principal.class,
572
+ source: 'direct',
573
+ viaTenantId: null,
574
+ tenantName: tenantRow.name,
575
+ grantedBy: row.invitedBy,
576
+ });
577
+ await writePrimaryAssignment(tx, binding, invitedRole, { id: principal.id, class: principal.class }, { createdBy: row.invitedBy });
578
+ await writeParticipationPolicy(tx, binding, tenantRow.id, principal);
579
+ await tx
580
+ .update(invitations)
581
+ .set({ status: 'accepted', acceptedBy: principal.id, acceptedAt: new Date() })
582
+ .where(eq(invitations.id, row.id));
583
+ await recordAs(tx, principal, context, {
584
+ action: 'invitation.accepted',
585
+ tenantId: tenantRow.id,
586
+ targetType: 'invitation',
587
+ targetId: row.id,
588
+ });
589
+ await recordAs(tx, principal, context, {
590
+ action: 'membership.created',
591
+ tenantId: tenantRow.id,
592
+ targetType: 'membership',
593
+ targetId: principal.id,
594
+ after: { roleId: invitedRole.id, role: invitedRole.key },
595
+ });
596
+ // D19: the row above is a direct membership like any other, so a tenant
597
+ // that holds children owes them the derived rows it implies. The
598
+ // propagating writes exist for exactly this reason; an acceptance that
599
+ // skipped it would leave a person with authority at a parent and none in
600
+ // its children until something else happened to recompute, which the
601
+ // reconciliation check would then report as drift nobody caused on
602
+ // purpose.
603
+ const acceptedTags = await propagateMembershipChange(tx, binding, writerFromPrincipal(principal, context), tenantRow.id, principal.id);
604
+ // D10, D19: a tenant an operator created with attachTo carries the
605
+ // parent on the invitation rather than on itself, because the
606
+ // attachment only happens once someone has actually accepted and become
607
+ // its first owner. That owner is already written above; a refused
608
+ // attachment is reported, never allowed to undo them.
609
+ if (row.attachParentId) {
610
+ const attachRefusal = await attemptBornAttach(tx, binding, policy, principal, context, row.attachParentId, tenantRow.id);
611
+ const tags = attachRefusal
612
+ ? [tenantTag(tenantRow.id), ...acceptedTags]
613
+ : [tenantTag(tenantRow.id), tenantTag(row.attachParentId), ...acceptedTags];
614
+ return done({
615
+ tenantId: tenantRow.id,
616
+ attached: !attachRefusal,
617
+ attachRefusal: attachRefusal ?? undefined,
618
+ }, tags);
619
+ }
620
+ return done({ tenantId: tenantRow.id }, [tenantTag(tenantRow.id), ...acceptedTags]);
621
+ });
622
+ }
623
+ /** `members:read`; every pending invitation for the members page, with `token_hash` never among the selected columns. */
624
+ export async function pendingInvitations(access, db) {
625
+ if (!(await permitsRead(access, db, 'members:read')))
626
+ return [];
627
+ const rows = await db
628
+ .select({
629
+ id: invitations.id,
630
+ email: invitations.email,
631
+ role: invitationRoleKey,
632
+ status: invitations.status,
633
+ mailState: invitations.mailState,
634
+ invitedBy: invitations.invitedBy,
635
+ expiresAt: invitations.expiresAt,
636
+ createdAt: invitations.createdAt,
637
+ parentName: parentTenants.name,
638
+ })
639
+ .from(invitations)
640
+ .leftJoin(parentTenants, eq(invitations.attachParentId, parentTenants.id))
641
+ .where(and(eq(invitations.tenantId, access.tenant.id), eq(invitations.status, 'pending')))
642
+ .orderBy(desc(invitations.createdAt));
643
+ // The left join's own column is typed as though it always matched; it is
644
+ // null at runtime for every ordinary invitation, so this makes the type
645
+ // say what the value already does rather than widen the query itself.
646
+ return rows.map((row) => ({ ...row, parentName: row.parentName ?? null }));
647
+ }
648
+ /** For the acceptance page, reachable before anyone is signed in: what the invitation offers, never the hash, the inviter's address, or the parent's id. */
649
+ export async function invitationPreview(token, db) {
650
+ const tokenHash = createHash('sha256').update(token).digest('hex');
651
+ const [row] = await db
652
+ .select({
653
+ tenantName: tenants.name,
654
+ role: invitationRoleKey,
655
+ status: invitations.status,
656
+ expiresAt: invitations.expiresAt,
657
+ parentName: parentTenants.name,
658
+ })
659
+ .from(invitations)
660
+ .innerJoin(tenants, eq(invitations.tenantId, tenants.id))
661
+ .leftJoin(parentTenants, eq(invitations.attachParentId, parentTenants.id))
662
+ .where(eq(invitations.tokenHash, tokenHash))
663
+ .limit(1);
664
+ return row ? { ...row, parentName: row.parentName ?? null } : null;
665
+ }
666
+ /**
667
+ * The tenant a token's invitation belongs to, regardless of its status --
668
+ * pending, accepted, revoked or expired. Exists for one caller only: the
669
+ * accept page, so that a `not_pending` refusal (the row has already been
670
+ * used) can be told apart from every other refusal by asking a second
671
+ * question -- is this signed-in person already a member there -- without
672
+ * touching `acceptInvitation` itself. Unlike `invitationPreview`, this may
673
+ * only be called once a principal is established: it hands back the raw
674
+ * tenant id, which `invitationPreview`'s own docblock deliberately never
675
+ * does for a visitor who has not signed in yet.
676
+ */
677
+ export async function invitationTenantId(token, db) {
678
+ const tokenHash = createHash('sha256').update(token).digest('hex');
679
+ const [row] = await db
680
+ .select({ tenantId: invitations.tenantId })
681
+ .from(invitations)
682
+ .where(eq(invitations.tokenHash, tokenHash))
683
+ .limit(1);
684
+ return row?.tenantId ?? null;
685
+ }