@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,65 @@
1
+ import type { StoreBinding } from './binding.js';
2
+ import type { DbOrTx } from './scoped.js';
3
+ import { type DerivedChange } from './tree.js';
4
+ /**
5
+ * The write and inheritance-propagation half of the tenant tree (D19):
6
+ * `recomputeDerived`, and the two functions that call it, `attachInTx` and
7
+ * `detachInTx`. `./tree.js` holds the reads and the pure derivation rule
8
+ * this half writes out — read that file's header for the rule itself and
9
+ * why authority is materialised at write time rather than walked on read.
10
+ */
11
+ /**
12
+ * A derived row would have had to carry a custom role, which D19 forbids
13
+ * ("a derived membership carries a system role, never a custom one"). Every
14
+ * caller checks `customRoleBlockers` (`./tree.js`) before writing, so
15
+ * reaching this means one of them did not: it throws inside the caller's
16
+ * transaction rather than writing a row the invariant says cannot exist.
17
+ */
18
+ export declare class DerivedCustomRoleError extends Error {
19
+ readonly tenantId: string;
20
+ readonly principalId: string;
21
+ readonly role: string;
22
+ constructor(tenantId: string, principalId: string, role: string);
23
+ }
24
+ /**
25
+ * Makes the derived rows in these tenants match the rule, and returns what it
26
+ * changed. The one writer: a grant, a role change, a removal, an attach and a
27
+ * detach all reduce to "these tenants may now hold different derived rows for
28
+ * these principals, work out which and write it".
29
+ *
30
+ * Three properties this holds, and the property tests pin (Tests 20):
31
+ *
32
+ * - A `direct` row is never read as a candidate and never written over. The
33
+ * update and delete below both carry `source = 'inherited'` in their
34
+ * predicate, so even a bug in the diff cannot reach one (M6).
35
+ * - A derived row never carries a custom role: it throws instead, because a
36
+ * caller reaching that point skipped `customRoleBlockers` and the
37
+ * transaction should fail rather than store a row the invariant forbids.
38
+ * - Running it twice changes nothing the second time, which is what lets the
39
+ * reconciliation check compare against a fresh computation.
40
+ *
41
+ * Pass `principalIds` when the change is about particular people (a grant at
42
+ * a parent); leave it out when the shape of the tree itself moved (an attach
43
+ * or a detach) and every principal above is in question.
44
+ */
45
+ export declare function recomputeDerived(tx: DbOrTx, binding: StoreBinding, tenantIds: readonly string[], principalIds?: readonly string[]): Promise<DerivedChange[]>;
46
+ /**
47
+ * Writes the attachment and fills the derived rows, in the caller's
48
+ * transaction. The caller has already decided that this may happen
49
+ * (`attachProblem` returned null, the consent was given, the permission was
50
+ * held) and writes the audit rows; this is the two writes that make it true.
51
+ *
52
+ * The subtree, not just the child: attaching a child that already holds
53
+ * children of its own reaches all of them, which is why the depth rule is
54
+ * checked against the child's own height rather than against the child alone.
55
+ */
56
+ export declare function attachInTx(tx: DbOrTx, binding: StoreBinding, parentId: string, childId: string): Promise<DerivedChange[]>;
57
+ /**
58
+ * Clears the attachment and the derived rows it supported, in the caller's
59
+ * transaction. Order matters: the parent link is cleared first, so the
60
+ * recomputation that follows reads the tree as it now is and removes exactly
61
+ * the rows the link was holding up. Direct rows in the child are left
62
+ * standing (M6), which is the whole reason detach needs a report
63
+ * (`detachReport` in `./tree.js`).
64
+ */
65
+ export declare function detachInTx(tx: DbOrTx, binding: StoreBinding, childId: string): Promise<DerivedChange[]>;
@@ -0,0 +1,201 @@
1
+ import { and, eq, inArray } from 'drizzle-orm';
2
+ import { primaryRoleKey, writeParticipationPolicy, writePrimaryAssignment } from './assignments.js';
3
+ import { policyAssignments } from './policy-schema.js';
4
+ import { isCustomRoleKey } from './role-keys.js';
5
+ import { roleDef } from './roles.js';
6
+ import { memberships, tenants } from './schema.js';
7
+ import { descendantsOf, expectedDerivedFor, nodeById, } from './tree.js';
8
+ /**
9
+ * The write and inheritance-propagation half of the tenant tree (D19):
10
+ * `recomputeDerived`, and the two functions that call it, `attachInTx` and
11
+ * `detachInTx`. `./tree.js` holds the reads and the pure derivation rule
12
+ * this half writes out — read that file's header for the rule itself and
13
+ * why authority is materialised at write time rather than walked on read.
14
+ */
15
+ /**
16
+ * A derived row would have had to carry a custom role, which D19 forbids
17
+ * ("a derived membership carries a system role, never a custom one"). Every
18
+ * caller checks `customRoleBlockers` (`./tree.js`) before writing, so
19
+ * reaching this means one of them did not: it throws inside the caller's
20
+ * transaction rather than writing a row the invariant says cannot exist.
21
+ */
22
+ export class DerivedCustomRoleError extends Error {
23
+ tenantId;
24
+ principalId;
25
+ role;
26
+ constructor(tenantId, principalId, role) {
27
+ super(`a derived membership for ${principalId} in ${tenantId} would carry the custom role "${role}"`);
28
+ this.tenantId = tenantId;
29
+ this.principalId = principalId;
30
+ this.role = role;
31
+ this.name = 'DerivedCustomRoleError';
32
+ }
33
+ }
34
+ /**
35
+ * Makes the derived rows in these tenants match the rule, and returns what it
36
+ * changed. The one writer: a grant, a role change, a removal, an attach and a
37
+ * detach all reduce to "these tenants may now hold different derived rows for
38
+ * these principals, work out which and write it".
39
+ *
40
+ * Three properties this holds, and the property tests pin (Tests 20):
41
+ *
42
+ * - A `direct` row is never read as a candidate and never written over. The
43
+ * update and delete below both carry `source = 'inherited'` in their
44
+ * predicate, so even a bug in the diff cannot reach one (M6).
45
+ * - A derived row never carries a custom role: it throws instead, because a
46
+ * caller reaching that point skipped `customRoleBlockers` and the
47
+ * transaction should fail rather than store a row the invariant forbids.
48
+ * - Running it twice changes nothing the second time, which is what lets the
49
+ * reconciliation check compare against a fresh computation.
50
+ *
51
+ * Pass `principalIds` when the change is about particular people (a grant at
52
+ * a parent); leave it out when the shape of the tree itself moved (an attach
53
+ * or a detach) and every principal above is in question.
54
+ */
55
+ export async function recomputeDerived(tx, binding, tenantIds, principalIds) {
56
+ const changes = [];
57
+ const identity = (row) => `${row.principalClass}:${row.principalId}`;
58
+ for (const tenantId of tenantIds) {
59
+ const node = await nodeById(tx, tenantId);
60
+ if (!node)
61
+ continue;
62
+ const expected = await expectedDerivedFor(tx, binding, tenantId, principalIds);
63
+ for (const row of expected) {
64
+ if (isCustomRoleKey(row.role)) {
65
+ throw new DerivedCustomRoleError(tenantId, row.principalId, row.role);
66
+ }
67
+ }
68
+ const expectedById = new Map(expected.map((row) => [identity(row), row]));
69
+ const existingConditions = [
70
+ eq(memberships.tenantId, tenantId),
71
+ eq(memberships.source, 'inherited'),
72
+ ];
73
+ if (principalIds) {
74
+ if (principalIds.length === 0)
75
+ continue;
76
+ existingConditions.push(inArray(memberships.principalId, [...principalIds]));
77
+ }
78
+ const existing = await tx
79
+ .select({
80
+ principalId: memberships.principalId,
81
+ principalClass: memberships.principalClass,
82
+ role: primaryRoleKey(binding),
83
+ viaTenantId: memberships.viaTenantId,
84
+ })
85
+ .from(memberships)
86
+ .where(and(...existingConditions));
87
+ const existingById = new Map(existing.map((row) => [identity(row), row]));
88
+ for (const row of expected) {
89
+ const current = existingById.get(identity(row));
90
+ if (!current) {
91
+ // A direct row for this principal would have kept them out of
92
+ // `expected` entirely, so there is nothing here to collide with.
93
+ await tx.insert(memberships).values({
94
+ tenantId,
95
+ principalId: row.principalId,
96
+ principalClass: row.principalClass,
97
+ source: 'inherited',
98
+ viaTenantId: row.viaTenantId,
99
+ tenantName: node.name,
100
+ grantedBy: row.grantedBy,
101
+ });
102
+ await syncInheritedAssignment(tx, binding, tenantId, row);
103
+ if (row.principalClass === 'human')
104
+ await writeParticipationPolicy(tx, binding, tenantId, {
105
+ id: row.principalId,
106
+ class: 'human',
107
+ });
108
+ changes.push({
109
+ tenantId,
110
+ principalId: row.principalId,
111
+ kind: 'created',
112
+ before: null,
113
+ after: { role: row.role, viaTenantId: row.viaTenantId },
114
+ });
115
+ continue;
116
+ }
117
+ if (current.role === row.role && current.viaTenantId === row.viaTenantId)
118
+ continue;
119
+ await tx
120
+ .update(memberships)
121
+ .set({
122
+ viaTenantId: row.viaTenantId,
123
+ grantedBy: row.grantedBy,
124
+ updatedAt: new Date(),
125
+ })
126
+ .where(and(eq(memberships.tenantId, tenantId), eq(memberships.principalId, row.principalId), eq(memberships.principalClass, row.principalClass), eq(memberships.source, 'inherited')));
127
+ await syncInheritedAssignment(tx, binding, tenantId, row);
128
+ changes.push({
129
+ tenantId,
130
+ principalId: row.principalId,
131
+ kind: 'role_changed',
132
+ before: { role: current.role, viaTenantId: current.viaTenantId },
133
+ after: { role: row.role, viaTenantId: row.viaTenantId },
134
+ });
135
+ }
136
+ for (const current of existing) {
137
+ if (expectedById.has(identity(current)))
138
+ continue;
139
+ await tx
140
+ .delete(memberships)
141
+ .where(and(eq(memberships.tenantId, tenantId), eq(memberships.principalId, current.principalId), eq(memberships.principalClass, current.principalClass), eq(memberships.source, 'inherited')));
142
+ changes.push({
143
+ tenantId,
144
+ principalId: current.principalId,
145
+ kind: 'ended',
146
+ before: { role: current.role, viaTenantId: current.viaTenantId },
147
+ after: null,
148
+ });
149
+ }
150
+ }
151
+ return changes;
152
+ }
153
+ /**
154
+ * Writes the attachment and fills the derived rows, in the caller's
155
+ * transaction. The caller has already decided that this may happen
156
+ * (`attachProblem` returned null, the consent was given, the permission was
157
+ * held) and writes the audit rows; this is the two writes that make it true.
158
+ *
159
+ * The subtree, not just the child: attaching a child that already holds
160
+ * children of its own reaches all of them, which is why the depth rule is
161
+ * checked against the child's own height rather than against the child alone.
162
+ */
163
+ export async function attachInTx(tx, binding, parentId, childId) {
164
+ await tx.update(tenants).set({ parentId, updatedAt: new Date() }).where(eq(tenants.id, childId));
165
+ const subtree = [childId, ...(await descendantsOf(tx, childId)).map((node) => node.id)];
166
+ return recomputeDerived(tx, binding, subtree);
167
+ }
168
+ /**
169
+ * Clears the attachment and the derived rows it supported, in the caller's
170
+ * transaction. Order matters: the parent link is cleared first, so the
171
+ * recomputation that follows reads the tree as it now is and removes exactly
172
+ * the rows the link was holding up. Direct rows in the child are left
173
+ * standing (M6), which is the whole reason detach needs a report
174
+ * (`detachReport` in `./tree.js`).
175
+ */
176
+ export async function detachInTx(tx, binding, childId) {
177
+ const subtree = [childId, ...(await descendantsOf(tx, childId)).map((node) => node.id)];
178
+ await tx
179
+ .update(tenants)
180
+ .set({ parentId: null, updatedAt: new Date() })
181
+ .where(eq(tenants.id, childId));
182
+ return recomputeDerived(tx, binding, subtree);
183
+ }
184
+ async function syncInheritedAssignment(tx, binding, tenantId, row) {
185
+ const role = row.role ? await roleDef(tx, binding, { id: tenantId }, row.role) : null;
186
+ if (row.role && !role)
187
+ throw new Error('Missing destination built-in policy');
188
+ // `built_in` alone is not the right test any more: a starter role (ADR
189
+ // 0016) is a real, app-defined role that must still cross organisation
190
+ // boundaries, even though its row is `built_in: false` for a tenant
191
+ // created after that change. `isCustomRoleKey` is what actually
192
+ // distinguishes a tenant-authored role from one this app defines.
193
+ if (role && isCustomRoleKey(role.key))
194
+ throw new Error('Custom roles cannot cross organisation boundaries');
195
+ if (role)
196
+ await writePrimaryAssignment(tx, binding, role, { id: row.principalId, class: row.principalClass }, { source: 'inherited', viaTenantId: row.viaTenantId, createdBy: row.grantedBy });
197
+ else
198
+ await tx
199
+ .delete(policyAssignments)
200
+ .where(and(eq(policyAssignments.tenantId, tenantId), eq(policyAssignments.applicationId, binding.applicationId), eq(policyAssignments.platformId, binding.platformId), eq(policyAssignments.principalId, row.principalId), eq(policyAssignments.principalClass, row.principalClass), eq(policyAssignments.primary, true), eq(policyAssignments.source, 'inherited')));
201
+ }
package/dist/tree.d.ts ADDED
@@ -0,0 +1,272 @@
1
+ import type { EventName, TenantKind, TenantState } from '@wtfalch/authz';
2
+ import type { StoreBinding } from './binding.js';
3
+ import type { StorePolicy } from './policy.js';
4
+ import type { DbOrTx } from './scoped.js';
5
+ /**
6
+ * The tenant tree and the derived membership rows that hang off it (D19).
7
+ *
8
+ * This file is the read and query half: node lookups, ancestor/descendant
9
+ * walks, the attach-eligibility check, and the pure derivation rule that
10
+ * says what a tenant's derived rows *should* be. `./tree-writes.js` is the
11
+ * other half — `recomputeDerived` and the two functions that call it,
12
+ * `attachInTx`/`detachInTx` — split out because the two together ran past
13
+ * this package's review size. The policy is in the host's own `nesting.ts`
14
+ * (who may propose, accept, decline and detach), `memberships.ts`
15
+ * (propagation on a grant) and `tenants.ts` (ceilings, state and archiving).
16
+ * Everything here takes a transaction handle and writes no audit row of its
17
+ * own: the caller knows which actor is doing this and what to log, and every
18
+ * function below is meant to run inside the caller's single transaction,
19
+ * after it has locked the tenants involved with `lockTenantsForUpdate`.
20
+ *
21
+ * **Authority flows downhill, materialised at write time.** A membership at
22
+ * an ancestor is mirrored into real rows in every descendant, marked
23
+ * `source = 'inherited'` with `via_tenant_id` naming the ancestor it came
24
+ * from, rather than walked upward on the read path. That is what keeps
25
+ * `accessFor` a single indexed probe at any depth (D19: the alternative
26
+ * measured 450 times slower on the list shape), and it is why the four
27
+ * propagating writes (in `./tree-writes.js`) are the ones that pay the cost.
28
+ *
29
+ * **The derived rule, stated once, because everything here is that rule.**
30
+ * For a tenant T and a principal P:
31
+ *
32
+ * - If P holds a `direct` row in T, T's own row is what counts and nothing
33
+ * here ever touches it (M6). Agency staff invited into a client before the
34
+ * attach keep their direct role, and detach leaves it behind.
35
+ * - Otherwise P holds a derived row in T exactly when some ancestor of T has
36
+ * a `direct` row for P, and the row mirrors the **nearest** such ancestor:
37
+ * its role, with `via_tenant_id` naming it.
38
+ * - Except when P holds a membership in the operator tenant, in which case P
39
+ * never holds a derived row anywhere (D8). An attachment is two customer
40
+ * organisations consenting about each other, not about whoever runs the
41
+ * console, and this is the one clause that keeps "an operator has no
42
+ * standing reach into membership" true once tenants can hold tenants.
43
+ * - Otherwise P holds no row in T.
44
+ *
45
+ * Nearest wins because the rule has to be a pure function of the direct rows
46
+ * above T for the reconciliation check (D9) to mean anything: given the tree
47
+ * and its direct rows, exactly one derived set is correct, so a row that
48
+ * should have gone and did not, or never got written, is a difference anyone
49
+ * can compute. At a policy's `maxDepth` of 1 there is only ever one ancestor
50
+ * and the rule degenerates to "the parent's row"; it is written for depth N
51
+ * because a two-level host (lokessmie) runs the same code at depth 2.
52
+ *
53
+ * **Depth counts edges.** A policy's `maxDepth` bounds every traversal here,
54
+ * plus a hard cap besides, so malformed data throws rather than looping.
55
+ */
56
+ /** What every tree read returns: the tenant fields nesting decisions are made from. */
57
+ export interface TenantNode {
58
+ readonly id: string;
59
+ readonly kind: TenantKind;
60
+ readonly slug: string;
61
+ readonly name: string;
62
+ readonly state: TenantState;
63
+ readonly parentId: string | null;
64
+ readonly ceiling: readonly string[];
65
+ readonly selfDenied: readonly string[];
66
+ }
67
+ /**
68
+ * A parent chain that does not terminate, or is longer than any depth this
69
+ * app allows. Cycles are impossible through the functions in this file (an
70
+ * attach refuses one before it is written), so this is a corruption report,
71
+ * not a condition a caller handles: it throws rather than returning a
72
+ * refusal, and it fails the transaction it is in.
73
+ */
74
+ export declare class TenantCycleError extends Error {
75
+ constructor(tenantId: string);
76
+ }
77
+ /** One tenant, or null. */
78
+ export declare function nodeById(tx: DbOrTx, tenantId: string): Promise<TenantNode | null>;
79
+ /**
80
+ * Locks the given tenants `FOR UPDATE`, always in ascending id order, and
81
+ * returns them keyed by id. The order is the point: an attach and a rename
82
+ * touching the same two rows from opposite ends would deadlock, and sorting
83
+ * the ids gives every transaction in this module the same lock sequence.
84
+ * Ids that name no row are simply absent from the map.
85
+ */
86
+ export declare function lockTenantsForUpdate(tx: DbOrTx, tenantIds: readonly string[]): Promise<Map<string, TenantNode>>;
87
+ /**
88
+ * Locks a tenant and everything below it, and keeps looking until the set
89
+ * stops growing.
90
+ *
91
+ * Reading the subtree and then locking what it found is not the same thing,
92
+ * and the difference is a live bug rather than a nicety: an attach that
93
+ * commits between the read and the lock adds a child the caller will never
94
+ * see, so a ceiling cascade skips it and an "are there children" check says
95
+ * no while a child is sitting right there.
96
+ *
97
+ * Locking downward in waves closes it. An attach of C under P must lock P,
98
+ * so once P is locked no new child of P can appear; reading P's children
99
+ * under that lock gives a set that cannot grow, and locking those children
100
+ * in turn freezes the next level. The loop runs at most `MAX_CHAIN` times
101
+ * and, at the depths a policy allows, two or three.
102
+ */
103
+ export declare function lockSubtreeForUpdate(tx: DbOrTx, rootId: string): Promise<Map<string, TenantNode>>;
104
+ /**
105
+ * Locks everything `attachProblem` is about to read: the child and its whole
106
+ * subtree, the proposed parent, and every ancestor above the parent.
107
+ *
108
+ * Locking only the two named tenants is not enough, because the depth rule
109
+ * counts rows neither of them is. Two attaches at opposite ends of one chain
110
+ * lock disjoint pairs, each reads a depth that is true at the time and stale
111
+ * by the time it commits, and together they build a tree deeper than
112
+ * `maxDepth` allows. That cannot happen at a policy depth of 1, where no
113
+ * chain is long enough to have two ends; it happens at a two-level host's
114
+ * depth of 2, which runs this same code.
115
+ */
116
+ export declare function lockAttachScope(tx: DbOrTx, parentId: string, childId: string): Promise<void>;
117
+ /**
118
+ * Every ancestor of this tenant, nearest first: its parent, then its
119
+ * parent's parent, and so on to the root. Empty for a tenant with no parent.
120
+ * Bounded by `MAX_CHAIN` and by a visited set, so a cycle in the data throws
121
+ * instead of hanging.
122
+ */
123
+ export declare function ancestorsOf(tx: DbOrTx, tenantId: string): Promise<TenantNode[]>;
124
+ /** The tenants whose `parent_id` is this one. */
125
+ export declare function childrenOf(tx: DbOrTx, tenantId: string): Promise<TenantNode[]>;
126
+ /**
127
+ * Every tenant below this one, breadth first, nearest level first. Excludes
128
+ * the tenant itself. One query per level rather than a recursive CTE: the
129
+ * depth is small, so this is two or three round trips with plainly
130
+ * readable SQL, and the loop is where the cycle guard lives.
131
+ */
132
+ export declare function descendantsOf(tx: DbOrTx, tenantId: string): Promise<TenantNode[]>;
133
+ /** How many edges sit above this tenant: 0 for a root, 1 for a child of a root. */
134
+ export declare function depthAbove(tx: DbOrTx, tenantId: string): Promise<number>;
135
+ /**
136
+ * How many edges sit below this tenant at its deepest: 0 for a leaf, 1 for a
137
+ * tenant whose children are all leaves. Computed from the subtree's own
138
+ * parent links rather than a second traversal per node.
139
+ */
140
+ export declare function heightBelow(tx: DbOrTx, tenantId: string): Promise<number>;
141
+ /** Why two tenants may not be attached, with a reason a host page can hand to a caller. */
142
+ export interface AttachProblem {
143
+ readonly reason: string;
144
+ readonly message: string;
145
+ /** The permission strings a ceiling refusal names, so the page can list them. */
146
+ readonly offenders?: readonly string[];
147
+ }
148
+ /**
149
+ * Whether `childId` may be attached under `parentId`, checked against every
150
+ * cross-row rule D19 and D22 state. Called three times over the life of one
151
+ * attachment and deliberately so: by the host's `proposeAttach`, so a
152
+ * proposal that could never be accepted is refused when it is written; by
153
+ * `acceptAttach` inside the lock, which is the check that actually decides;
154
+ * and by the born-attached invitation path, where the proposal step never
155
+ * happened. Nothing is cached between them, because everything it reads can
156
+ * move while a proposal sits pending.
157
+ *
158
+ * Ceilings are the last check rather than the first because its refusal is
159
+ * the only one that names offenders, and a caller showing "narrow these two
160
+ * permissions first" should not be shown it about a pair that could never be
161
+ * attached anyway.
162
+ *
163
+ * `policy` carries `maxDepth` and `offered` (wave 3's `StorePolicy`) — the
164
+ * only two fields this function needs from it, not the role templates.
165
+ */
166
+ export declare function attachProblem(tx: DbOrTx, policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, parentId: string, childId: string): Promise<AttachProblem | null>;
167
+ /** What the rule says a derived row in one tenant should hold. */
168
+ export interface ExpectedDerived {
169
+ readonly principalId: string;
170
+ readonly principalClass: string;
171
+ readonly role: string;
172
+ readonly viaTenantId: string;
173
+ readonly grantedBy: string | null;
174
+ }
175
+ /**
176
+ * What derived rows tenant `tenantId` should hold, by the rule at the top of
177
+ * this file: the nearest ancestor's direct row for each principal, minus
178
+ * every principal who holds a direct row here. `principalIds` narrows the
179
+ * question to a few principals, which is what a single grant needs; without
180
+ * it the answer covers everyone the ancestry knows about, which is what an
181
+ * attach and the reconciliation check need.
182
+ *
183
+ * The one read every writer and the reconciliation check share, on purpose:
184
+ * reconciliation exists to catch a write that did not happen or a row that
185
+ * did not go, so it must ask the same question the writer answered. What
186
+ * catches the rule itself being wrong is the property tests, not a second
187
+ * copy of the rule that could be wrong in the same way.
188
+ */
189
+ export declare function expectedDerivedFor(tx: DbOrTx, binding: StoreBinding, tenantId: string, principalIds?: readonly string[]): Promise<ExpectedDerived[]>;
190
+ /** A principal whose derived row would have to carry a custom role, and where it would come from. */
191
+ export interface CustomRoleBlocker {
192
+ readonly tenantId: string;
193
+ readonly principalId: string;
194
+ readonly role: string;
195
+ readonly viaTenantId: string;
196
+ }
197
+ /**
198
+ * The custom roles standing in the way of writing derived rows for these
199
+ * tenants (D19: a derived membership carries a system role, never a custom
200
+ * one). Empty means the propagation may go ahead. Callers use this to refuse
201
+ * with the offenders named rather than propagating partially: the fix is to
202
+ * move those people to a system role at the parent, and only the parent can
203
+ * decide that.
204
+ */
205
+ export declare function customRoleBlockers(tx: DbOrTx, binding: StoreBinding, tenantIds: readonly string[], principalIds?: readonly string[]): Promise<CustomRoleBlocker[]>;
206
+ /**
207
+ * The same question `customRoleBlockers` answers, but about an attachment
208
+ * that has not happened yet: what custom roles would have to reach the
209
+ * child's subtree if `childId` were attached under `parentId`.
210
+ *
211
+ * This exists because the obvious call does not work. `customRoleBlockers`
212
+ * reads the ancestry a tenant has *now*, and a child being proposed to still
213
+ * has none, so asking it before the attach always answers "nothing" and the
214
+ * refusal D19 asks for could never fire. The two agents that hit this each
215
+ * worked around it by writing `parent_id` first and undoing the transaction
216
+ * when the check then fired, which works but makes a refusal cost a rollback
217
+ * and leaves every future caller to rediscover the trap.
218
+ *
219
+ * Call this BEFORE the attachment is written. Afterwards the ordinary
220
+ * `customRoleBlockers` is the right question, because by then the chain this
221
+ * one has to imagine is the chain that exists.
222
+ */
223
+ export declare function attachCustomRoleBlockers(tx: DbOrTx, binding: StoreBinding, parentId: string, childId: string): Promise<CustomRoleBlocker[]>;
224
+ /** What one `recomputeDerived` (`./tree-writes.js`) call changed, for the caller's audit rows and for the tests. */
225
+ export interface DerivedChange {
226
+ readonly tenantId: string;
227
+ readonly principalId: string;
228
+ readonly kind: 'created' | 'role_changed' | 'ended';
229
+ readonly before: {
230
+ readonly role: string;
231
+ readonly viaTenantId: string | null;
232
+ } | null;
233
+ readonly after: {
234
+ readonly role: string;
235
+ readonly viaTenantId: string;
236
+ } | null;
237
+ }
238
+ /**
239
+ * The audit action one derived change writes as: the same three names a
240
+ * direct write uses. A derived row is a membership like any other, and the
241
+ * log says which ancestor it came from through `via_tenant_id` on the row
242
+ * rather than through a fourth action nobody would think to search for.
243
+ * Exported because every caller of `recomputeDerived` needs it, and three
244
+ * private copies of one switch is how the three drift apart.
245
+ */
246
+ export declare function derivedEventAction(kind: DerivedChange['kind']): EventName;
247
+ /**
248
+ * Every principal holding a direct row anywhere at or above this tenant: the
249
+ * set whose derived rows can move when the tree does. Used by
250
+ * `attachInTx`/`detachInTx` (`./tree-writes.js`), which change the shape
251
+ * rather than one person's role and so cannot narrow the recomputation to a
252
+ * principal they already know.
253
+ */
254
+ export declare function principalsAtOrAbove(tx: DbOrTx, binding: StoreBinding, tenantId: string): Promise<string[]>;
255
+ /** A direct membership in a detached child that a holder of a derived role granted while the link stood. */
256
+ export interface DetachReportRow {
257
+ readonly principalId: string;
258
+ readonly role: string;
259
+ readonly grantedBy: string | null;
260
+ readonly createdAt: Date;
261
+ }
262
+ /**
263
+ * What a reseller's staff left behind: every direct membership in this child
264
+ * that was granted, while the link stood, by someone whose own authority
265
+ * there was derived from the parent (D19's sixth rule). Nothing is removed;
266
+ * the child's owner gets the list and decides.
267
+ *
268
+ * Read before `detachInTx` (`./tree-writes.js`), because it asks who
269
+ * currently holds a derived row, and detaching is what takes those rows
270
+ * away.
271
+ */
272
+ export declare function detachReport(tx: DbOrTx, binding: StoreBinding, childId: string, since: Date): Promise<DetachReportRow[]>;