@wtfalch/authz-store 0.2.1 → 0.4.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 +393 -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 +135 -0
  9. package/dist/audit.js +231 -0
  10. package/dist/binding.d.ts +26 -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/grants.d.ts +2 -0
  29. package/dist/index.d.ts +34 -1
  30. package/dist/index.js +34 -1
  31. package/dist/install-owner.d.ts +25 -0
  32. package/dist/install-owner.js +159 -0
  33. package/dist/invitations.d.ts +160 -0
  34. package/dist/invitations.js +685 -0
  35. package/dist/membership-rows.d.ts +206 -0
  36. package/dist/membership-rows.js +271 -0
  37. package/dist/memberships.d.ts +87 -0
  38. package/dist/memberships.js +272 -0
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/person-records.d.ts +186 -0
  42. package/dist/person-records.js +263 -0
  43. package/dist/platform.d.ts +20 -0
  44. package/dist/platform.js +65 -0
  45. package/dist/policy-access.d.ts +260 -0
  46. package/dist/policy-access.js +357 -0
  47. package/dist/policy-entry.d.ts +11 -0
  48. package/dist/policy-entry.js +10 -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 +449 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +71 -0
  54. package/dist/policy.js +78 -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 +143 -0
  81. package/package.json +13 -4
@@ -0,0 +1,808 @@
1
+ import { sqlState } from '@wtfalch/db';
2
+ import { and, asc, eq, gt, ilike, or, sql } from 'drizzle-orm';
3
+ import { writeParticipationPolicy, writePrimaryAssignment } from './assignments.js';
4
+ import { record, recordAs } from './audit.js';
5
+ import { ensureBuiltInRoles, seedStarterRoles } from './boot.js';
6
+ import { generateInvitationToken } from './invitations.js';
7
+ import { permits, permitsPlatform, permitsPlatformRead, refreshAccess } from './policy-access.js';
8
+ import { roleDef } from './roles.js';
9
+ import { invitations, memberships, tenants } from './schema.js';
10
+ import { ancestorsOf, attachProblem, childrenOf, descendantsOf, lockSubtreeForUpdate, lockTenantsForUpdate, nodeById, } from './tree.js';
11
+ import { done, isUuid, refused, tenantTag, } from './types.js';
12
+ /**
13
+ * Tenant-level writes: creating one (self-serve, gated by a host's own
14
+ * `TENANT_CREATION`), and the operator- or owner-facing settings on an
15
+ * existing one. Every function here locks the tenant row it is about to
16
+ * change `FOR UPDATE` before writing, refuses under a break-glass context
17
+ * (D8: no session ever changes authority or settings), and records exactly
18
+ * one audit row through the `Access` or `Principal` it was handed.
19
+ *
20
+ * Wave 9a (authz#83, issue #99 decision 4) is the member-side writes and the
21
+ * reads: `createTenant`, `renameTenant`, `setSelfDenied`, `setTenantState`,
22
+ * `setCeiling`, `tenantById`, `tenantBySlug`, `allTenants`. Wave 9b adds the
23
+ * operator side: `createTenantAsOperator`, `deleteJustCreatedTenant`,
24
+ * `archiveTenant`.
25
+ *
26
+ * `db` and `scopeColumn` are the host's root handle and tenant-table
27
+ * registry (wave 1), taken as parameters rather than imported, the same as
28
+ * every other write module this campaign has moved. `TENANT_CREATION` and
29
+ * `TENANT_CREATION_CAP_PER_DAY` are no longer package constants: a host
30
+ * passes both through `CreateTenantConfig` (decision 3). `defaultCeiling`
31
+ * and `offered` come off the `policy: StorePolicy` (or the narrower
32
+ * `Pick<StorePolicy, 'defaultCeiling' | 'offered'>` where that is all a
33
+ * function needs) a caller passes in, rather than an import (decision 4).
34
+ */
35
+ /**
36
+ * SQLSTATE may be on the driver error or a native ORM cause; 23505 is
37
+ * unique_violation. Two requests racing for the same slug both pass the
38
+ * collision read below and one of them loses here, which is a refusal to
39
+ * word, not a crash to report.
40
+ */
41
+ function isUniqueViolation(error) {
42
+ return sqlState(error) === '23505';
43
+ }
44
+ /**
45
+ * Whether two ceilings hold exactly the same permissions, order aside: what
46
+ * `setCeiling`'s descendant walk uses to skip an audit row for a descendant
47
+ * `clipCeiling` did not actually have to narrow (D19's rule that an already-
48
+ * inside descendant is not an event).
49
+ */
50
+ function sameCeiling(a, b) {
51
+ if (a.length !== b.length)
52
+ return false;
53
+ const sortedA = [...a].sort();
54
+ const sortedB = [...b].sort();
55
+ return sortedA.every((value, index) => value === sortedB[index]);
56
+ }
57
+ /**
58
+ * The self-serve path (D10): closed by default (`config.creation !==
59
+ * 'anyone'`), and capped per principal per rolling day when open, refused
60
+ * inside the same transaction that would otherwise create the row so the
61
+ * count and the insert never race. The creator becomes the tenant's first
62
+ * owner in the same transaction, which is also its own audited event, so a
63
+ * tenant is never observed to exist without someone who can run it.
64
+ *
65
+ * `profiles` is the host's own port over its `profiles` (or equivalent)
66
+ * table's one question this function asks -- reusing `Pick<
67
+ * OwnerActivityLookup, 'personKnown'>` from install-owner.ts (decision 6)
68
+ * rather than declaring a second port for the same "has this id ever signed
69
+ * in" question. `policy` is the full `StorePolicy`, not the narrower pick
70
+ * decision 4 names for `setSelfDenied`/`setCeiling`: this function also
71
+ * drives `ensureBuiltInRoles`/`seedStarterRoles`/`roleDef`, which need the
72
+ * role-template methods and `applicationId`/`platformId` a bare
73
+ * `Pick<StorePolicy, 'defaultCeiling' | 'offered'>` does not carry.
74
+ */
75
+ export async function createTenant(principal, db, policy, config, profiles, options) {
76
+ if (config.creation !== 'anyone') {
77
+ return refused('creation_closed', 'Only an operator can create an organisation here.');
78
+ }
79
+ return db.transaction(async (tx) => {
80
+ if (principal.class === 'human') {
81
+ if (!(await profiles.personKnown(principal.id)))
82
+ return refused('unknown_person', 'This person has never signed in here.');
83
+ }
84
+ // There is no tenant row to lock before the tenant exists, so the cap
85
+ // serialises on the principal instead: a transaction-scoped advisory lock
86
+ // keyed on their id, held until commit, so two creates racing each other
87
+ // cannot both read the count below before either inserts.
88
+ await tx.execute(sql `select pg_advisory_xact_lock(hashtextextended(${principal.id}, 0))`);
89
+ const [row] = await tx
90
+ .select({ count: sql `count(*)::int` })
91
+ .from(tenants)
92
+ .where(and(eq(tenants.createdBy, principal.id), sql `${tenants.createdAt} > now() - interval '24 hours'`));
93
+ const createdToday = row?.count ?? 0;
94
+ if (createdToday >= config.capPerDay) {
95
+ await recordAs(tx, principal, 'standard', {
96
+ action: 'tenant.created',
97
+ tenantId: null,
98
+ targetType: 'tenant',
99
+ targetId: options.slug,
100
+ outcome: 'denied',
101
+ reason: `more than ${config.capPerDay} organisations created in the last day`,
102
+ });
103
+ return refused('cap_reached', `You can only create ${config.capPerDay} organisations a day.`);
104
+ }
105
+ const [collision] = await tx
106
+ .select({ id: tenants.id })
107
+ .from(tenants)
108
+ .where(eq(tenants.slug, options.slug))
109
+ .limit(1);
110
+ if (collision)
111
+ return refused('slug_taken', 'That address is already taken.');
112
+ let tenantRow;
113
+ try {
114
+ [tenantRow] = await tx
115
+ .insert(tenants)
116
+ .values({
117
+ kind: 'customer',
118
+ slug: options.slug,
119
+ name: options.name,
120
+ ceiling: [...policy.defaultCeiling],
121
+ selfDenied: [],
122
+ createdBy: principal.id,
123
+ })
124
+ .returning({ id: tenants.id });
125
+ }
126
+ catch (error) {
127
+ if (isUniqueViolation(error))
128
+ return refused('slug_taken', 'That address is already taken.');
129
+ throw error;
130
+ }
131
+ if (!tenantRow)
132
+ throw new Error('createTenant: no row returned from the insert');
133
+ await ensureBuiltInRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
134
+ await seedStarterRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
135
+ const owner = await roleDef(tx, policy, tenantRow, 'owner');
136
+ if (!owner)
137
+ throw new Error('Missing starter owner role');
138
+ await tx.insert(memberships).values({
139
+ tenantId: tenantRow.id,
140
+ principalId: principal.id,
141
+ principalClass: principal.class,
142
+ source: 'direct',
143
+ tenantName: options.name,
144
+ grantedBy: principal.id,
145
+ });
146
+ await writePrimaryAssignment(tx, policy, owner, principal, { createdBy: principal.id });
147
+ await writeParticipationPolicy(tx, policy, tenantRow.id, principal);
148
+ await recordAs(tx, principal, 'standard', {
149
+ action: 'tenant.created',
150
+ tenantId: tenantRow.id,
151
+ targetType: 'tenant',
152
+ targetId: tenantRow.id,
153
+ after: { kind: 'customer', slug: options.slug, name: options.name },
154
+ });
155
+ await recordAs(tx, principal, 'standard', {
156
+ action: 'membership.created',
157
+ tenantId: tenantRow.id,
158
+ targetType: 'membership',
159
+ targetId: principal.id,
160
+ after: { role: 'owner' },
161
+ });
162
+ return done({ id: tenantRow.id }, [tenantTag(tenantRow.id)]);
163
+ });
164
+ }
165
+ /** Renames or re-slugs a tenant, keeping `memberships.tenant_name` in step (D5) when the name changes. */
166
+ export async function renameTenant(access, db, scopeColumn, options) {
167
+ if (access.context === 'break_glass') {
168
+ return refused('break_glass', 'A break-glass session never changes an organisation and what it is called.');
169
+ }
170
+ if (!permits(access, 'tenant:update')) {
171
+ return refused('no_permission', 'You do not have permission to rename this organisation.');
172
+ }
173
+ if (options.name === undefined && options.slug === undefined) {
174
+ return refused('nothing_to_change', 'Nothing was given to change.');
175
+ }
176
+ return db.transaction(async (tx) => {
177
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
178
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenant:update'))
179
+ return refused('no_permission', 'Your authority changed.');
180
+ const currentAccess = fresh;
181
+ const [tenantRow] = await tx
182
+ .select({ id: tenants.id, name: tenants.name, slug: tenants.slug })
183
+ .from(tenants)
184
+ .where(eq(tenants.id, currentAccess.tenant.id))
185
+ .for('update');
186
+ if (!tenantRow)
187
+ return refused('tenant_not_found', 'This organisation no longer exists.');
188
+ const nextName = options.name ?? tenantRow.name;
189
+ const nextSlug = options.slug ?? tenantRow.slug;
190
+ if (nextSlug !== tenantRow.slug) {
191
+ const [collision] = await tx
192
+ .select({ id: tenants.id })
193
+ .from(tenants)
194
+ .where(eq(tenants.slug, nextSlug))
195
+ .limit(1);
196
+ if (collision)
197
+ return refused('slug_taken', 'That address is already taken.');
198
+ }
199
+ try {
200
+ await tx
201
+ .update(tenants)
202
+ .set({ name: nextName, slug: nextSlug, updatedAt: new Date() })
203
+ .where(eq(tenants.id, tenantRow.id));
204
+ }
205
+ catch (error) {
206
+ if (isUniqueViolation(error))
207
+ return refused('slug_taken', 'That address is already taken.');
208
+ throw error;
209
+ }
210
+ if (options.name !== undefined) {
211
+ await tx
212
+ .update(memberships)
213
+ .set({ tenantName: nextName })
214
+ .where(eq(memberships.tenantId, tenantRow.id));
215
+ }
216
+ await record(tx, currentAccess, {
217
+ action: 'tenant.settings_changed',
218
+ tenantId: tenantRow.id,
219
+ targetType: 'tenant',
220
+ targetId: tenantRow.id,
221
+ before: { name: tenantRow.name, slug: tenantRow.slug },
222
+ after: { name: nextName, slug: nextSlug },
223
+ });
224
+ return done(undefined, [tenantTag(tenantRow.id)]);
225
+ });
226
+ }
227
+ /**
228
+ * Sets what this tenant has switched off for itself, within what it was
229
+ * offered (D22). Checked before the transaction opens means a bad list never
230
+ * reaches a lock at all; nothing at read time trips over a stored value that
231
+ * should never have been written.
232
+ */
233
+ export async function setSelfDenied(access, db, scopeColumn, policy, list) {
234
+ if (access.context === 'break_glass') {
235
+ return refused('break_glass', 'A break-glass session never changes what an organisation has switched off for itself.');
236
+ }
237
+ if (!permits(access, 'tenant:update')) {
238
+ return refused('no_permission', 'You do not have permission to change this organisation.');
239
+ }
240
+ const problems = list.filter((p) => !policy.offered.includes(p));
241
+ if (problems.length > 0) {
242
+ return refused('invalid_self_denied', `Not offered here, so it cannot be switched off: ${problems.join(', ')}.`);
243
+ }
244
+ return db.transaction(async (tx) => {
245
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
246
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenant:update'))
247
+ return refused('no_permission', 'Your authority changed.');
248
+ const currentAccess = fresh;
249
+ const [tenantRow] = await tx
250
+ .select({ id: tenants.id, selfDenied: tenants.selfDenied })
251
+ .from(tenants)
252
+ .where(eq(tenants.id, currentAccess.tenant.id))
253
+ .for('update');
254
+ if (!tenantRow)
255
+ return refused('tenant_not_found', 'This organisation no longer exists.');
256
+ await tx
257
+ .update(tenants)
258
+ .set({ selfDenied: [...list], updatedAt: new Date() })
259
+ .where(eq(tenants.id, tenantRow.id));
260
+ await record(tx, currentAccess, {
261
+ action: 'tenant.settings_changed',
262
+ tenantId: tenantRow.id,
263
+ targetType: 'tenant',
264
+ targetId: tenantRow.id,
265
+ before: { selfDenied: tenantRow.selfDenied },
266
+ after: { selfDenied: list },
267
+ });
268
+ return done(undefined, [tenantTag(tenantRow.id)]);
269
+ });
270
+ }
271
+ /**
272
+ * Moves a tenant between active, read-only, suspended and archived (D11).
273
+ * `operatorAccess` must be resolved against the operator tenant, which is
274
+ * how this refuses a signed-in customer who somehow reaches the call: their
275
+ * `Access` was never built from the operator tenant, so `kind !== 'operator'`
276
+ * catches it before the permission check even runs. The operator tenant
277
+ * itself can never be the target, or every operator locks themselves out.
278
+ *
279
+ * Archiving is refused while the tenant holds children (D19's fifth rule),
280
+ * unless `cascade` is asked for: cascading archives every descendant in the
281
+ * same breath, which is what satisfies "detach or archive them first" in one
282
+ * transaction rather than requiring it to have already happened.
283
+ */
284
+ export async function setTenantState(operatorAccess, db, scopeColumn, tenantId, state, options = {}) {
285
+ if (operatorAccess.context === 'break_glass') {
286
+ return refused('break_glass', "A break-glass session never changes an organisation's state.");
287
+ }
288
+ if (operatorAccess.tenant.kind !== 'operator' ||
289
+ !permitsPlatform(operatorAccess, 'tenants:state')) {
290
+ return refused('no_permission', "You do not have permission to change an organisation's state.");
291
+ }
292
+ if (!isUuid(tenantId))
293
+ return refused('tenant_not_found', 'This organisation no longer exists.');
294
+ return db.transaction(async (tx) => {
295
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
296
+ if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'tenants:state'))
297
+ return refused('no_permission', 'Your authority changed.');
298
+ const currentAccess = fresh;
299
+ // Locked first, then read. Reading the subtree and then locking what it
300
+ // found lets an attach commit in between, and the child it adds is
301
+ // invisible to everything below: a cascade skips it, and the has_children
302
+ // refusal says there are none while one is sitting there.
303
+ const locked = await lockSubtreeForUpdate(tx, tenantId);
304
+ const target = locked.get(tenantId);
305
+ if (!target)
306
+ return refused('tenant_not_found', 'This organisation no longer exists.');
307
+ if (target.kind === 'operator') {
308
+ return refused('operator_immutable', 'The operator organisation is always active.');
309
+ }
310
+ // The has_children refusal only ever needs to see direct children; a
311
+ // cascade needs the whole subtree, since the state reaches every level.
312
+ const subtree = options.cascade
313
+ ? await descendantsOf(tx, tenantId)
314
+ : await childrenOf(tx, tenantId);
315
+ const hasChildren = subtree.some((node) => node.parentId === tenantId);
316
+ if (state === 'archived' && hasChildren && !options.cascade) {
317
+ return refused('has_children', 'This organisation holds others. Detach or archive them first.');
318
+ }
319
+ await tx.update(tenants).set({ state, updatedAt: new Date() }).where(eq(tenants.id, tenantId));
320
+ await record(tx, currentAccess, {
321
+ action: 'tenant.state_changed',
322
+ tenantId,
323
+ targetType: 'tenant',
324
+ targetId: tenantId,
325
+ before: { state: target.state },
326
+ after: { state },
327
+ });
328
+ const tags = [tenantTag(tenantId)];
329
+ if (options.cascade) {
330
+ for (const node of subtree) {
331
+ const current = locked.get(node.id);
332
+ if (!current || current.state === state)
333
+ continue;
334
+ await tx
335
+ .update(tenants)
336
+ .set({ state, updatedAt: new Date() })
337
+ .where(eq(tenants.id, node.id));
338
+ await record(tx, currentAccess, {
339
+ action: 'tenant.state_changed',
340
+ tenantId: node.id,
341
+ targetType: 'tenant',
342
+ targetId: node.id,
343
+ before: { state: current.state },
344
+ after: { state },
345
+ });
346
+ tags.push(tenantTag(node.id));
347
+ }
348
+ }
349
+ return done(undefined, tags);
350
+ });
351
+ }
352
+ /**
353
+ * Sets what a tenant may use (D22), recorded as `tenant.ceiling_changed`.
354
+ * `platform.tenants:ceiling` governs operator reach; `tenants:ceiling`
355
+ * governs a parent's own children. Both require explicit canonical grants.
356
+ * The target's parent relationship is read from the locked row rather than
357
+ * trusted from the caller. A ceiling that would exceed the target's own
358
+ * parent's is refused with the offenders named regardless of which route
359
+ * authorised the call, and narrowing clips every descendant in the same
360
+ * transaction, nearest level first, skipping a descendant whose ceiling was
361
+ * already inside the new one (no change, no event).
362
+ */
363
+ export async function setCeiling(access, db, scopeColumn, policy, tenantId, list) {
364
+ if (access.context === 'break_glass') {
365
+ return refused('break_glass', 'A break-glass session never changes what an organisation may use.');
366
+ }
367
+ if (!(access.tenant.kind === 'operator'
368
+ ? permitsPlatform(access, 'platform.tenants:ceiling')
369
+ : permits(access, 'tenants:ceiling'))) {
370
+ return refused('no_permission', 'You do not have permission to change what an organisation may use.');
371
+ }
372
+ if (!isUuid(tenantId))
373
+ return refused('tenant_not_found', 'This organisation no longer exists.');
374
+ const problems = list.filter((p) => !policy.offered.includes(p));
375
+ if (problems.length > 0) {
376
+ return refused('invalid_ceiling', `Not an offered permission: ${problems.join(', ')}.`);
377
+ }
378
+ return db.transaction(async (tx) => {
379
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
380
+ if (!fresh ||
381
+ fresh.context === 'break_glass' ||
382
+ !(fresh.tenant.kind === 'operator'
383
+ ? permitsPlatform(fresh, 'platform.tenants:ceiling')
384
+ : permits(fresh, 'tenants:ceiling')))
385
+ return refused('no_permission', 'Your authority changed.');
386
+ const currentAccess = fresh;
387
+ // Locked first, then read: a child attached between an unlocked read and
388
+ // the lock would never be clipped, and would keep a ceiling wider than
389
+ // the parent it now hangs off, permanently and undetectably (the
390
+ // reconciliation check reads derived rows, not ceilings).
391
+ const locked = await lockSubtreeForUpdate(tx, tenantId);
392
+ const target = locked.get(tenantId);
393
+ if (!target)
394
+ return refused('tenant_not_found', 'This organisation no longer exists.');
395
+ if (target.parentId) {
396
+ for (const [id, node] of await lockTenantsForUpdate(tx, [target.parentId])) {
397
+ locked.set(id, node);
398
+ }
399
+ }
400
+ const descendants = await descendantsOf(tx, tenantId);
401
+ const isOperator = currentAccess.tenant.kind === 'operator';
402
+ if (!isOperator && target.parentId !== currentAccess.tenant.id) {
403
+ return refused('no_permission', 'You do not have permission to change what an organisation may use.');
404
+ }
405
+ if (target.parentId) {
406
+ // Not necessarily in `locked`: the parent id above came from the
407
+ // unlocked read, so a parent that changed between that read and the
408
+ // lock is fetched fresh here rather than skipping the check silently.
409
+ const parent = locked.get(target.parentId) ?? (await nodeById(tx, target.parentId));
410
+ if (parent) {
411
+ const offenders = list.filter((p) => !parent.ceiling.includes(p));
412
+ if (offenders.length > 0) {
413
+ return refused('ceiling_exceeds', `${target.name} may use more than ${parent.name} does. Narrow it first: ${offenders.join(', ')}.`);
414
+ }
415
+ }
416
+ }
417
+ await tx
418
+ .update(tenants)
419
+ .set({ ceiling: [...list], updatedAt: new Date() })
420
+ .where(eq(tenants.id, tenantId));
421
+ await record(tx, currentAccess, {
422
+ action: 'tenant.ceiling_changed',
423
+ tenantId,
424
+ targetType: 'tenant',
425
+ targetId: tenantId,
426
+ before: { ceiling: target.ceiling },
427
+ after: { ceiling: list },
428
+ });
429
+ const tags = [tenantTag(tenantId)];
430
+ // Clip every descendant against the ceiling directly above it, nearest
431
+ // level first (descendantsOf returns breadth first), so a grandchild
432
+ // clips against its own parent's ceiling as just computed here, not
433
+ // against the tenant this call named.
434
+ const ceilingAbove = new Map([[tenantId, list]]);
435
+ for (const node of descendants) {
436
+ const current = locked.get(node.id);
437
+ if (!current)
438
+ continue; // left the subtree between the read and the lock
439
+ const parentCeiling = ceilingAbove.get(current.parentId ?? '') ?? current.ceiling;
440
+ const clipped = [...current.ceiling.filter((p) => parentCeiling.includes(p))].sort();
441
+ ceilingAbove.set(node.id, clipped);
442
+ if (sameCeiling(current.ceiling, clipped))
443
+ continue;
444
+ await tx
445
+ .update(tenants)
446
+ .set({ ceiling: clipped, updatedAt: new Date() })
447
+ .where(eq(tenants.id, node.id));
448
+ // One per descendant actually clipped (D19, D22), under the same name
449
+ // as the narrowing that caused it, so a child's own log says its
450
+ // ceiling moved and names the parent's change as the reason.
451
+ await record(tx, currentAccess, {
452
+ action: 'tenant.ceiling_changed',
453
+ tenantId: node.id,
454
+ targetType: 'tenant',
455
+ targetId: node.id,
456
+ before: { ceiling: current.ceiling },
457
+ after: { ceiling: clipped },
458
+ });
459
+ tags.push(tenantTag(node.id));
460
+ }
461
+ return done(undefined, tags);
462
+ });
463
+ }
464
+ /** A tenant by id, or null. Takes whatever handle the caller already has open, raw or scoped-out. */
465
+ export async function tenantById(handle, id) {
466
+ const [row] = await handle.select().from(tenants).where(eq(tenants.id, id)).limit(1);
467
+ return row ?? null;
468
+ }
469
+ /** A tenant by slug, or null. Used to resolve `/org/<slug>` before a membership is known. */
470
+ export async function tenantBySlug(handle, slug) {
471
+ const [row] = await handle.select().from(tenants).where(eq(tenants.slug, slug)).limit(1);
472
+ return row ?? null;
473
+ }
474
+ /**
475
+ * Every organisation, for the operator console (D12).
476
+ *
477
+ * **`tenants:read-all`, which is an operator-scoped permission**, so this is
478
+ * the estate looking at its customers rather than a customer looking at
479
+ * itself. `null` when the actor does not hold it, never an empty page, the
480
+ * distinction `membersOf` and `securityLogFor` both draw: an operator with a
481
+ * narrowed role seeing "no organisations" would conclude the estate was
482
+ * empty.
483
+ *
484
+ * **Archived tenants are included here, unlike in `membershipsFor`.** A
485
+ * member's own switcher hides them because there is nothing they can do with
486
+ * one; an operator's console is exactly where somebody needs to see that a
487
+ * tenant was archived and when. The state is a column on the row rather than
488
+ * a filter on the query.
489
+ *
490
+ * Keyset-paged by `(name, id)`, the same shape and for the same reason as the
491
+ * chooser: an estate's tenant list is the one that grows without bound.
492
+ *
493
+ * `db` is the host's root handle, taken explicitly rather than imported: the
494
+ * store has no database of its own to reach for.
495
+ */
496
+ export async function allTenants(access, db, options = {}) {
497
+ if (!(await permitsPlatformRead(access, db, 'tenants:read')))
498
+ return null;
499
+ const limit = Math.min(options.limit ?? 50, 200);
500
+ const conditions = [];
501
+ if (options.q) {
502
+ // A person types a name, not a pattern.
503
+ const literal = options.q.replace(/[\\%_]/g, (c) => `\\${c}`);
504
+ conditions.push(ilike(tenants.name, `%${literal}%`));
505
+ }
506
+ if (options.after) {
507
+ const { name, id } = options.after;
508
+ const keyset = or(gt(tenants.name, name), and(eq(tenants.name, name), gt(tenants.id, id)));
509
+ if (keyset)
510
+ conditions.push(keyset);
511
+ }
512
+ const rows = await db
513
+ .select({
514
+ id: tenants.id,
515
+ name: tenants.name,
516
+ slug: tenants.slug,
517
+ kind: tenants.kind,
518
+ state: tenants.state,
519
+ parentId: tenants.parentId,
520
+ ceiling: tenants.ceiling,
521
+ selfDenied: tenants.selfDenied,
522
+ })
523
+ .from(tenants)
524
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
525
+ .orderBy(asc(tenants.name), asc(tenants.id))
526
+ .limit(limit + 1);
527
+ const page = rows.slice(0, limit);
528
+ const last = page.at(-1);
529
+ return {
530
+ items: page.map((r) => ({
531
+ id: r.id,
532
+ name: r.name,
533
+ slug: r.slug,
534
+ kind: r.kind,
535
+ state: r.state,
536
+ parentId: r.parentId,
537
+ ceiling: r.ceiling,
538
+ selfDenied: r.selfDenied,
539
+ })),
540
+ next: rows.length > limit && last ? { name: last.name, id: last.id } : null,
541
+ };
542
+ }
543
+ /**
544
+ * The sentinel that unwinds `createTenantAsOperator`'s transaction when
545
+ * `attachTo` turns out to be impossible: `db.transaction` commits whatever a
546
+ * callback returns without throwing, and a tenant already inserted by the
547
+ * time `attachProblem` is checked must not be left behind, standalone and
548
+ * ownerless in every sense but the pending invitation nobody will now get.
549
+ * Caught immediately outside the transaction and turned back into the
550
+ * `Result` refusal it carries, so the function never throws to its own
551
+ * caller.
552
+ */
553
+ class BornAttachedRefusal extends Error {
554
+ result;
555
+ constructor(result) {
556
+ super('createTenantAsOperator: attachTo refused');
557
+ this.result = result;
558
+ }
559
+ }
560
+ /**
561
+ * The onboarding path valet and lokessmie use every day: the customer does
562
+ * not have an account yet, so the tenant is born with a pending owner
563
+ * invitation and no membership at all (M7). The operator never becomes a
564
+ * member of it, which is what keeps D8's rule (no standing operator
565
+ * membership in a customer tenant) true through onboarding; the last-owner
566
+ * guard tolerates the resulting zero-owner tenant because exactly one owner
567
+ * invitation is pending (`ownerSeatCovered` in `owners.ts`).
568
+ *
569
+ * `policy` is the full `StorePolicy`, the same as `createTenant`: this also
570
+ * drives `ensureBuiltInRoles`/`seedStarterRoles`/`roleDef` and
571
+ * `attachProblem`, none of which a bare `Pick<StorePolicy, 'defaultCeiling'
572
+ * | 'offered'>` would carry.
573
+ */
574
+ export async function createTenantAsOperator(operatorAccess, db, scopeColumn, policy, options) {
575
+ if (operatorAccess.context === 'break_glass') {
576
+ return refused('break_glass', 'A break-glass session never creates an organisation.');
577
+ }
578
+ if (operatorAccess.tenant.kind !== 'operator' ||
579
+ !permitsPlatform(operatorAccess, 'tenants:create')) {
580
+ return refused('no_permission', 'You do not have permission to create an organisation.');
581
+ }
582
+ const ownerEmail = options.ownerEmail.trim().toLowerCase();
583
+ try {
584
+ return await db.transaction(async (tx) => {
585
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
586
+ if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'tenants:create'))
587
+ return refused('no_permission', 'Your authority changed.');
588
+ const currentAccess = fresh;
589
+ // Checked ahead of the insert, the same as createTenant above: the
590
+ // ordinary case (someone reuses a slug that already exists) reads as a
591
+ // worded refusal here, and the try/catch below is only the backstop
592
+ // for two operators racing to create the same slug at once.
593
+ const [collision] = await tx
594
+ .select({ id: tenants.id })
595
+ .from(tenants)
596
+ .where(eq(tenants.slug, options.slug))
597
+ .limit(1);
598
+ if (collision)
599
+ return refused('slug_taken', 'That address is already taken.');
600
+ if (options.attachTo) {
601
+ // The parent AND everything above it, locked for the rest of the
602
+ // transaction. The parent alone is not enough: the depth check a few
603
+ // lines down counts the chain above it, and an attach that lengthens
604
+ // that chain concurrently would leave this one reading a depth that
605
+ // was true when it looked and wrong when it committed.
606
+ const above = (await ancestorsOf(tx, options.attachTo)).map((node) => node.id);
607
+ const lockedParent = await lockTenantsForUpdate(tx, [options.attachTo, ...above]);
608
+ if (!lockedParent.has(options.attachTo)) {
609
+ return refused('parent_not_found', 'That organisation no longer exists.');
610
+ }
611
+ }
612
+ let tenantRow;
613
+ try {
614
+ [tenantRow] = await tx
615
+ .insert(tenants)
616
+ .values({
617
+ kind: 'customer',
618
+ slug: options.slug,
619
+ name: options.name,
620
+ ceiling: [...policy.defaultCeiling],
621
+ selfDenied: [],
622
+ createdBy: currentAccess.actor.id,
623
+ })
624
+ .returning({ id: tenants.id });
625
+ }
626
+ catch (error) {
627
+ if (isUniqueViolation(error))
628
+ return refused('slug_taken', 'That address is already taken.');
629
+ throw error;
630
+ }
631
+ if (!tenantRow)
632
+ throw new Error('createTenantAsOperator: no row returned from the insert');
633
+ await ensureBuiltInRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
634
+ await seedStarterRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
635
+ const owner = await roleDef(tx, policy, tenantRow, 'owner');
636
+ if (!owner)
637
+ throw new Error('Missing starter owner role');
638
+ if (options.attachTo) {
639
+ // The only child-side check attachProblem can run before the invitation
640
+ // is sent: the row now exists, born with parent_id null, so an
641
+ // impossible pairing (the parent archived, too deep, the child's
642
+ // default ceiling already wider than the parent's) is refused here
643
+ // rather than surfacing when someone eventually accepts. Refusing
644
+ // throws rather than returning, because the tenant row above is
645
+ // already written in this same transaction and must not be left
646
+ // behind, standalone, when the pairing the caller asked for cannot
647
+ // hold.
648
+ const problem = await attachProblem(tx, policy, options.attachTo, tenantRow.id);
649
+ if (problem)
650
+ throw new BornAttachedRefusal(refused(problem.reason, problem.message));
651
+ }
652
+ // Same token rule as invite(): 32 random bytes, shown once, stored
653
+ // hashed, seven days. There is no existing pending row to rotate here,
654
+ // since the tenant itself was just created.
655
+ const { token, tokenHash, expiresAt } = generateInvitationToken();
656
+ const [invitationRow] = await tx
657
+ .insert(invitations)
658
+ .values({
659
+ tenantId: tenantRow.id,
660
+ email: ownerEmail,
661
+ roleId: owner.id,
662
+ tokenHash,
663
+ expiresAt,
664
+ invitedBy: currentAccess.actor.id,
665
+ invitedByClass: currentAccess.actor.class,
666
+ attachParentId: options.attachTo ?? null,
667
+ })
668
+ .returning({ id: invitations.id });
669
+ if (!invitationRow) {
670
+ throw new Error('createTenantAsOperator: no invitation row returned from the insert');
671
+ }
672
+ await record(tx, currentAccess, {
673
+ action: 'tenant.created',
674
+ tenantId: tenantRow.id,
675
+ targetType: 'tenant',
676
+ targetId: tenantRow.id,
677
+ after: { kind: 'customer', slug: options.slug, name: options.name },
678
+ });
679
+ await record(tx, currentAccess, {
680
+ action: 'invitation.sent',
681
+ tenantId: tenantRow.id,
682
+ targetType: 'invitation',
683
+ targetId: invitationRow.id,
684
+ after: { email: ownerEmail, role: 'owner' },
685
+ });
686
+ return done({ id: tenantRow.id, token, link: options.link(token) }, [
687
+ tenantTag(tenantRow.id),
688
+ ]);
689
+ });
690
+ }
691
+ catch (error) {
692
+ if (error instanceof BornAttachedRefusal)
693
+ return error.result;
694
+ throw error;
695
+ }
696
+ }
697
+ /**
698
+ * Undoes `createTenantAsOperator` when a caller's own second write fails
699
+ * afterward and cannot be retried (issue #40: the billing area creates the
700
+ * tenant first and then a `@wtfalch/foundry` `DeclaredOrg` carrying its id,
701
+ * in a different database, so the two can never share one transaction). This
702
+ * is the compensating half -- hard deletes rather than archiving, unlike the
703
+ * ordinary meaning of `tenant:delete` (`denial.ts`'s `tenant.state_changed`):
704
+ * a tenant this refuses to touch is never one that has ever been usable, and
705
+ * archiving would leave a name on the operator's tenant list for something
706
+ * that does not exist. Refuses once anyone has joined -- the same signal
707
+ * `createTenantAsOperator` relies on for a tenant that is still only a
708
+ * pending invitation (M7) -- so this can never reach a tenant somebody may
709
+ * already be looking at.
710
+ *
711
+ * Writes no audit row: `tenant.deleted` is not among `@wtfalch/authz`'s
712
+ * `core.events` (`schema-check.test.ts` holds the database's closed set
713
+ * equal to exactly `core.events` plus a host's own `APP_EVENTS`), and nothing
714
+ * here may widen that package's vocabulary. The `tenant.created` row the
715
+ * failed attempt already wrote stands on the operator log as the record of
716
+ * it; every child row this deletes (`invitations`, `roles`,
717
+ * `role_permissions`) cascades with the tenant, since none of it ever
718
+ * mattered without it.
719
+ *
720
+ * Moves as an ordinary exported store function, copied from archon with its
721
+ * comment. Its own guards make it operator-only; there is no hook or
722
+ * extension interface, since it has no archon-only dependency (issue #99
723
+ * decision 4: "an optional extension" is this, with less machinery, because
724
+ * only archon ever calls it).
725
+ */
726
+ export async function deleteJustCreatedTenant(operatorAccess, db, scopeColumn, tenantId) {
727
+ if (operatorAccess.context === 'break_glass') {
728
+ return refused('break_glass', 'A break-glass session never deletes an organisation.');
729
+ }
730
+ if (operatorAccess.tenant.kind !== 'operator' ||
731
+ !permitsPlatform(operatorAccess, 'tenants:create')) {
732
+ return refused('no_permission', 'You do not have permission to delete this organisation.');
733
+ }
734
+ if (!isUuid(tenantId))
735
+ return refused('tenant_not_found', 'This organisation no longer exists.');
736
+ return db.transaction(async (tx) => {
737
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
738
+ if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'tenants:create'))
739
+ return refused('no_permission', 'Your authority changed.');
740
+ const [tenantRow] = await tx
741
+ .select({ id: tenants.id, kind: tenants.kind })
742
+ .from(tenants)
743
+ .where(eq(tenants.id, tenantId))
744
+ .for('update');
745
+ if (!tenantRow)
746
+ return refused('tenant_not_found', 'This organisation no longer exists.');
747
+ if (tenantRow.kind === 'operator') {
748
+ return refused('operator_immutable', 'The operator organisation can never be deleted.');
749
+ }
750
+ const [membership] = await tx
751
+ .select({ tenantId: memberships.tenantId })
752
+ .from(memberships)
753
+ .where(eq(memberships.tenantId, tenantId))
754
+ .limit(1);
755
+ if (membership) {
756
+ return refused('has_members', 'This organisation already has a member and can no longer be rolled back.');
757
+ }
758
+ await tx.delete(tenants).where(eq(tenants.id, tenantId));
759
+ return done(undefined, [tenantTag(tenantId)]);
760
+ });
761
+ }
762
+ /**
763
+ * Archives a tenant from inside it, by whoever holds `tenant:delete` there
764
+ * (the owner: the permission is not assignable, so no other system role
765
+ * carries it). Refused while the tenant holds children (D19's fifth rule):
766
+ * an archived parent's own state says nothing about the organisations
767
+ * hanging off it, so those must be detached or archived on purpose first,
768
+ * not silently along for the ride.
769
+ */
770
+ export async function archiveTenant(access, db, scopeColumn) {
771
+ if (access.context === 'break_glass') {
772
+ return refused('break_glass', 'A break-glass session never archives an organisation.');
773
+ }
774
+ if (!permits(access, 'tenant:delete')) {
775
+ return refused('no_permission', 'You do not have permission to archive this organisation.');
776
+ }
777
+ return db.transaction(async (tx) => {
778
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
779
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenant:delete'))
780
+ return refused('no_permission', 'Your authority changed.');
781
+ const currentAccess = fresh;
782
+ // Locked first, then read. The earlier shape read the children, locked
783
+ // that snapshot, and then only re-checked whether one had LEFT, so a
784
+ // child attached in the gap was never counted and the tenant archived
785
+ // out from under it.
786
+ const locked = await lockSubtreeForUpdate(tx, currentAccess.tenant.id);
787
+ const tenantRow = locked.get(currentAccess.tenant.id);
788
+ if (!tenantRow)
789
+ return refused('tenant_not_found', 'This organisation no longer exists.');
790
+ const children = await childrenOf(tx, currentAccess.tenant.id);
791
+ if (children.length > 0) {
792
+ return refused('has_children', 'This organisation holds others. Detach or archive them first.');
793
+ }
794
+ await tx
795
+ .update(tenants)
796
+ .set({ state: 'archived', updatedAt: new Date() })
797
+ .where(eq(tenants.id, tenantRow.id));
798
+ await record(tx, currentAccess, {
799
+ action: 'tenant.state_changed',
800
+ tenantId: tenantRow.id,
801
+ targetType: 'tenant',
802
+ targetId: tenantRow.id,
803
+ before: { state: tenantRow.state },
804
+ after: { state: 'archived' },
805
+ });
806
+ return done(undefined, [tenantTag(tenantRow.id)]);
807
+ });
808
+ }