@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,272 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import { identityWhere, primaryAssignment, roleAssignmentRefusal, writeParticipationPolicy, writePrimaryAssignment, } from './assignments.js';
3
+ import { record } from './audit.js';
4
+ import { endSessionsFor } from './break-glass.js';
5
+ import { changeRoleRow, deleteMembershipRow, guardStaysHeldRow, insertMembershipRow, membersOfRow, } from './membership-rows.js';
6
+ import { loadAccess, permits, permitsRead, policyAssignment, policyRole, refreshAccess, } from './policy-access.js';
7
+ import { propagateMembershipChange, writerFromAccess } from './propagate.js';
8
+ import { isCustomRoleKey } from './role-keys.js';
9
+ import { roleDef } from './roles.js';
10
+ import { memberships } from './schema.js';
11
+ import { tenantById } from './tenants.js';
12
+ import { childrenOf } from './tree.js';
13
+ import { done, refused, tenantTag } from './types.js';
14
+ /**
15
+ * The roster read, delegated to this package's own `membersOfRow` (same
16
+ * query shape this file used to run itself, ported into `membership-rows.ts`
17
+ * from `@wtfalch/people`'s `membersOf`). Its `role`/`roleLabel` come back
18
+ * `null` where this file's own `primaryRoleKey` subquery and `names` lookup
19
+ * used to read `''`/`'Unavailable role'` for "no current primary
20
+ * assignment" — mapped back to those exact values here so every caller of
21
+ * `Member` keeps reading the same two fallbacks it always has.
22
+ */
23
+ export async function membersOf(access, db, directory) {
24
+ if (!(await permitsRead(access, db, 'members:read')))
25
+ return null;
26
+ const rows = await membersOfRow(db, access.tenant.id, directory, access.binding);
27
+ return rows.map((r) => ({
28
+ ...r,
29
+ role: r.role ?? '',
30
+ roleLabel: r.roleLabel ?? 'Unavailable role',
31
+ }));
32
+ }
33
+ /**
34
+ * The viewer's own membership record in this organisation -- when they
35
+ * joined, and whether their standing here came from this organisation
36
+ * directly or from a parent. The organisation's home page is gated by
37
+ * requiring tenant membership alone (D2: "which one this is, and what the
38
+ * person looking at it is to it"), not by a permission, and reading your
39
+ * own row is not a further permission decision either -- the same
40
+ * reasoning that lets `access.role` itself go unchecked.
41
+ *
42
+ * **`null` when there is no row to read**, chiefly a break-glass session:
43
+ * `loadAccess` widens `Access` for an operator's support session without
44
+ * inserting a `memberships` row, so there is nothing here for that case --
45
+ * not a refusal, an absence, and a break-glass banner already explains why.
46
+ */
47
+ export async function selfStanding(access, db) {
48
+ const [membership] = await db
49
+ .select({
50
+ source: memberships.source,
51
+ viaTenantId: memberships.viaTenantId,
52
+ joinedAt: memberships.createdAt,
53
+ })
54
+ .from(memberships)
55
+ .where(and(eq(memberships.tenantId, access.tenant.id), eq(memberships.principalId, access.actor.id), eq(memberships.principalClass, access.actor.class)))
56
+ .limit(1);
57
+ if (!membership)
58
+ return null;
59
+ const viaTenantName = membership.viaTenantId
60
+ ? ((await tenantById(db, membership.viaTenantId))?.name ?? 'a parent organisation')
61
+ : null;
62
+ return { joinedAt: membership.joinedAt, source: membership.source, viaTenantName };
63
+ }
64
+ const noAuthority = () => refused('no_permission', 'You no longer have permission to change this membership.');
65
+ /**
66
+ * Protected roles cannot lose their last independent recovery owner.
67
+ * Delegated to `membership-rows.ts`'s `guardStaysHeldRow`, which runs the
68
+ * exact same coverage count this file used to run itself via `./owners.js`'
69
+ * `ownerCoverage`/`ownerSeatCovered`.
70
+ */
71
+ export async function guardStaysHeld(tx, binding, tenantId, roles, principal) {
72
+ const guards = new Set(roles.flatMap((r) => r.guards));
73
+ return guardStaysHeldRow(tx, binding, tenantId, principal, guards);
74
+ }
75
+ export async function grantMembership(access, db, scopeColumn, profiles, options) {
76
+ const principal = {
77
+ id: options.principalId,
78
+ class: options.principalClass ?? 'human',
79
+ };
80
+ if (principal.class !== 'human')
81
+ return refused('not_a_person', 'Create a credential with its own issuer and secret.');
82
+ if (access.context === 'break_glass')
83
+ return refused('break_glass', 'Support sessions cannot change access.');
84
+ return db.transaction(async (tx) => {
85
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
86
+ if (!fresh || fresh.context === 'break_glass')
87
+ return noAuthority();
88
+ const [existing] = await tx
89
+ .select()
90
+ .from(memberships)
91
+ .where(identityWhere(fresh.tenant.id, principal))
92
+ .limit(1);
93
+ if (existing)
94
+ return refused(existing.source === 'inherited' ? 'inherited' : 'already_member', 'This person already participates here.');
95
+ const role = await roleDef(tx, fresh.binding, fresh.tenant, options.role);
96
+ if (!role)
97
+ return refused('unknown_role', 'This role no longer exists.');
98
+ // Not `!role.builtIn`: a starter role (ADR 0016) is `built_in: false` for
99
+ // a tenant created after that change but is still app-defined, not
100
+ // tenant-authored, so it can still cross organisation boundaries.
101
+ if (isCustomRoleKey(role.key) && (await childrenOf(tx, fresh.tenant.id)).length)
102
+ return refused('custom_role_with_children', 'Custom roles cannot be inherited by another organisation.');
103
+ const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal), 'members:grant');
104
+ if (reason)
105
+ return refused(reason, 'You cannot delegate that role to this person.');
106
+ if (!(await profiles.personKnown(principal.id)))
107
+ return refused('unknown_person', 'This person has never signed in here.');
108
+ // The row insert itself is `membership-rows.ts`'s `insertMembershipRow`;
109
+ // the role assignment and participation-policy writes it deliberately
110
+ // leaves to the host (people's own doc comment: "out of this package's
111
+ // scope") run from `onGranted`, inside the same row-insert transaction
112
+ // as before.
113
+ const granted = await insertMembershipRow(tx, {
114
+ tenantId: fresh.tenant.id,
115
+ tenantName: fresh.tenant.name,
116
+ principal,
117
+ grantedBy: fresh.actor.id,
118
+ onGranted: async () => {
119
+ await writePrimaryAssignment(tx, fresh.binding, role, principal, {
120
+ createdBy: fresh.actor.id,
121
+ });
122
+ await writeParticipationPolicy(tx, fresh.binding, fresh.tenant.id, principal);
123
+ },
124
+ });
125
+ // Unreachable in practice: `existing` above already checked this inside
126
+ // the same transaction. Guarded rather than asserted, since a throw here
127
+ // would surface as an unhandled 500 instead of the refusal this action
128
+ // already has a message for.
129
+ if (!granted.ok)
130
+ return refused('already_member', 'This person already participates here.');
131
+ await record(tx, fresh, {
132
+ action: 'membership.created',
133
+ tenantId: fresh.tenant.id,
134
+ targetType: 'membership',
135
+ targetId: principal.id,
136
+ after: { roleId: role.id, role: role.key, principalClass: principal.class },
137
+ });
138
+ const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, principal.id);
139
+ return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
140
+ });
141
+ }
142
+ export async function changeRole(access, db, scopeColumn, options) {
143
+ const principal = {
144
+ id: options.principalId,
145
+ class: options.principalClass ?? 'human',
146
+ };
147
+ if (access.context === 'break_glass')
148
+ return refused('break_glass', 'Support sessions cannot change access.');
149
+ return db.transaction(async (tx) => {
150
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
151
+ if (!fresh || fresh.context === 'break_glass')
152
+ return noAuthority();
153
+ const member = fresh.policyState.memberRows.find((m) => m.principalId === principal.id && m.principalClass === principal.class);
154
+ if (!member)
155
+ return refused('not_found', 'This principal does not participate here.');
156
+ if (member.source !== 'direct')
157
+ return refused('inherited', 'Manage this membership at its parent organisation.');
158
+ const previous = fresh.policyState.assignmentRows.find((a) => a.primary && a.principalId === principal.id && a.principalClass === principal.class);
159
+ const before = previous
160
+ ? fresh.policyState.roleRows.find((r) => r.id === previous.roleId)
161
+ : undefined;
162
+ const role = await roleDef(tx, fresh.binding, fresh.tenant, options.role);
163
+ if (!role)
164
+ return refused('unknown_role', 'This role no longer exists.');
165
+ // Not `!role.builtIn`: a starter role (ADR 0016) is `built_in: false` for
166
+ // a tenant created after that change but is still app-defined, not
167
+ // tenant-authored, so it can still cross organisation boundaries.
168
+ if (isCustomRoleKey(role.key) && (await childrenOf(tx, fresh.tenant.id)).length)
169
+ return refused('custom_role_with_children', 'Custom roles cannot be inherited by another organisation.');
170
+ const self = principal.id === fresh.actor.id && principal.class === fresh.actor.class;
171
+ if (before && previous && !self) {
172
+ const reason = roleAssignmentRefusal(fresh, policyRole(before), policyAssignment(previous), 'members:revoke');
173
+ if (reason)
174
+ return refused(reason, 'You cannot remove that role.');
175
+ }
176
+ const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal, previous?.expiresAt?.getTime()), self ? null : 'members:grant');
177
+ if (reason)
178
+ return refused(self ? 'self_promotion' : reason, 'You cannot delegate that role.');
179
+ if (before &&
180
+ before.id !== role.id &&
181
+ !(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, [policyRole(before)], principal)))
182
+ return refused('last_owner', 'Appoint another independent owner first.');
183
+ // The upsert itself is `membership-rows.ts`'s `changeRoleRow` (one
184
+ // `INSERT ... ON CONFLICT DO UPDATE` on `authz_assignments_primary_idx`,
185
+ // same target this file's own `writePrimaryAssignment` deleted-then-
186
+ // inserted against); `compileRoleGrants`'s validation already ran above,
187
+ // inside `roleAssignmentRefusal`, so nothing here re-checks it.
188
+ const changed = await changeRoleRow(tx, {
189
+ tenantId: fresh.tenant.id,
190
+ principal,
191
+ binding: fresh.binding,
192
+ roleId: role.id,
193
+ expiresAt: previous?.expiresAt,
194
+ createdBy: fresh.actor.id,
195
+ });
196
+ // Unreachable in practice: `member` above already confirmed this
197
+ // principal's direct row exists inside the same transaction.
198
+ if (!changed.ok)
199
+ return refused('not_found', 'This principal does not participate here.');
200
+ await record(tx, fresh, {
201
+ action: 'membership.role_changed',
202
+ tenantId: fresh.tenant.id,
203
+ targetType: 'membership',
204
+ targetId: principal.id,
205
+ before: { roleId: before?.id ?? null },
206
+ after: { roleId: role.id, role: role.key },
207
+ });
208
+ if (fresh.tenant.kind === 'operator' && principal.class === 'human') {
209
+ const afterAccess = await loadAccess(tx, fresh.binding, db, scopeColumn, { ...principal, display: principal.id, email: null, emailVerified: false }, fresh.tenant.id, false);
210
+ if (!afterAccess || !permits(afterAccess, 'break_glass:start'))
211
+ await endSessionsFor(tx, principal.id, 'the operator no longer holds support access');
212
+ }
213
+ const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, principal.id);
214
+ return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
215
+ });
216
+ }
217
+ export async function removeMembership(access, db, scopeColumn, options) {
218
+ const principal = {
219
+ id: options.principalId,
220
+ class: options.principalClass ?? 'human',
221
+ };
222
+ if (access.context === 'break_glass')
223
+ return refused('break_glass', 'Support sessions cannot change access.');
224
+ return db.transaction(async (tx) => {
225
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
226
+ if (!fresh || fresh.context === 'break_glass')
227
+ return noAuthority();
228
+ const member = fresh.policyState.memberRows.find((m) => m.principalId === principal.id && m.principalClass === principal.class);
229
+ if (!member)
230
+ return refused('not_found', 'This principal does not participate here.');
231
+ if (member.source !== 'direct')
232
+ return refused('inherited', 'Manage this membership at its parent organisation.');
233
+ const self = principal.id === fresh.actor.id && principal.class === fresh.actor.class;
234
+ if (!self && !permits(fresh, 'members:revoke'))
235
+ return noAuthority();
236
+ const assignments = fresh.policyState.assignmentRows.filter((a) => a.principalId === principal.id && a.principalClass === principal.class);
237
+ const assignedRoles = assignments.map((assignment) => {
238
+ const row = fresh.policyState.roleRows.find((r) => r.id === assignment.roleId);
239
+ if (!row)
240
+ throw new Error('Assignment refers to an unknown role');
241
+ return { assignment, role: policyRole(row) };
242
+ });
243
+ const roles = assignedRoles.map((item) => item.role);
244
+ if (!self &&
245
+ assignedRoles.some(({ assignment, role }) => roleAssignmentRefusal(fresh, role, policyAssignment(assignment), 'members:revoke')))
246
+ return refused('not_covered', 'You cannot remove this principal’s assignments.');
247
+ if (!(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, roles, principal)))
248
+ return refused('last_owner', 'Appoint another independent owner first.');
249
+ // The row delete itself is `membership-rows.ts`'s `deleteMembershipRow`;
250
+ // the `authz_assignments` FK to `memberships` is `ON DELETE CASCADE`
251
+ // (`scripts/authz-v2/store-adopt.mjs`'s adopted shape), so the primary
252
+ // and every secondary assignment go with it, same as the plain delete
253
+ // this replaced.
254
+ const removed = await deleteMembershipRow(tx, { tenantId: fresh.tenant.id, principal });
255
+ // Unreachable in practice: `member` above already confirmed this
256
+ // principal's direct row exists inside the same transaction.
257
+ if (!removed.ok)
258
+ return refused('not_found', 'This principal does not participate here.');
259
+ await record(tx, fresh, {
260
+ action: 'membership.ended',
261
+ tenantId: fresh.tenant.id,
262
+ targetType: 'membership',
263
+ targetId: principal.id,
264
+ before: { roleIds: roles.map((r) => r.id), principalClass: principal.class },
265
+ after: null,
266
+ });
267
+ if (fresh.tenant.kind === 'operator')
268
+ await endSessionsFor(tx, principal.id, 'the operator membership was removed');
269
+ const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, principal.id);
270
+ return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
271
+ });
272
+ }
@@ -0,0 +1,124 @@
1
+ import type { StorePolicy } from './policy.js';
2
+ import type { DbOrTx, Scope } from './scoped.js';
3
+ import { type DetachReportRow } from './tree.js';
4
+ import type { Access, Result } from './types.js';
5
+ /**
6
+ * The consent flow over `tree.ts`'s mechanism (D19, D22, X6 M5 and M6):
7
+ * propose, accept, decline, detach, and the two reads a members page and a
8
+ * console page need. Every write here locks the tenant rows it is about,
9
+ * refuses under a break-glass context (D8: no session ever changes who
10
+ * belongs or what holds what), and records exactly one `tenant.*` audit row
11
+ * plus one `membership.*` row per derived change `tree.ts` actually made.
12
+ *
13
+ * This file never reimplements the derived rule: `attachProblem`,
14
+ * `customRoleBlockers` and `recomputeDerived` (via `attachInTx`/`detachInTx`)
15
+ * are `tree.ts`'s and `tree-writes.ts`'s, called here, not copied.
16
+ *
17
+ * `db` and `scopeColumn` are the host's root handle and tenant-table
18
+ * registry (wave 1), taken as parameters rather than imported, the same as
19
+ * every other write module this campaign has moved. `policy: Pick<StorePolicy,
20
+ * 'maxDepth' | 'offered'>` is `attachProblem`'s own parameter (wave 4a),
21
+ * threaded through here for the two functions that call it.
22
+ */
23
+ /** The closed set a proposal's `reason` is drawn from. `other` exists so the console is never stuck without a word for it. */
24
+ export type AttachReason = 'reseller' | 'reorganisation' | 'onboarding' | 'other';
25
+ /** Exported for a page's reason picker, so the closed set is declared once. */
26
+ export declare const ATTACH_REASONS: readonly AttachReason[];
27
+ export interface ProposeAttachOptions {
28
+ readonly childId: string;
29
+ readonly reason: string;
30
+ readonly reference: string;
31
+ /**
32
+ * Which tenant is proposed as the parent. Only meaningful when
33
+ * `access.tenant.kind` is `'operator'`: an operator's own tenant can never
34
+ * be the parent (`attachProblem`'s job to refuse), so an operator must name
35
+ * one from the console. Ignored for every other caller, whose own tenant is
36
+ * the parent (D19: "the actor's tenant is the parent").
37
+ */
38
+ readonly parentId?: string;
39
+ }
40
+ /**
41
+ * Proposes that `access.tenant` (or, for an operator, `options.parentId`)
42
+ * hold `options.childId`. Two refusals beyond the ordinary ones apply only to
43
+ * an operator actor (M5): an operator's own tenant can never be the parent,
44
+ * caught by `attachProblem` the same as any other pairing; and an operator
45
+ * who personally holds a direct role at the proposed parent may not propose
46
+ * it, because accepting would hand that same operator a standing derived
47
+ * role in the child the moment it goes through, exactly the reach into
48
+ * membership D8 forbids operators otherwise.
49
+ */
50
+ export declare function proposeAttach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, options: ProposeAttachOptions): Promise<Result<{
51
+ id: string;
52
+ }>>;
53
+ /**
54
+ * Accepts a pending proposal. `access` must already be resolved against the
55
+ * child (the child's own owner is who may accept), so a proposal for a
56
+ * different tenant is refused as `wrong_tenant` rather than acted on.
57
+ * Re-runs `attachProblem` inside the lock, because the ceiling, the depth or
58
+ * the parentage can all have moved since the proposal was written, and this
59
+ * second run is the one that actually decides. `customRoleBlockers` runs
60
+ * against the ancestry the attach would create, which only exists once the
61
+ * parent link is written, so that write happens here (not through
62
+ * `attachInTx`, which would also recompute in the same step) and is undone by
63
+ * throwing out of the transaction if a blocker turns up, rather than by
64
+ * `recomputeDerived` itself refusing partway through the subtree (D19: no
65
+ * partial propagation).
66
+ */
67
+ export declare function acceptAttach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, proposalId: string): Promise<Result<void>>;
68
+ /** Declines a pending proposal. Same authority as `acceptAttach`; nothing is written to either tenant. */
69
+ export declare function declineAttach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], proposalId: string): Promise<Result<void>>;
70
+ export interface DetachOptions {
71
+ readonly childId: string;
72
+ }
73
+ export interface DetachResult {
74
+ readonly grantedByDerivedHolders: readonly DetachReportRow[];
75
+ }
76
+ /**
77
+ * Detaches a child from its parent. Allowed to the child's own owner, to the
78
+ * parent (the child among its own children), or to an operator, all through
79
+ * `tenants:detach`. The report is read before `detachInTx`, on purpose: it
80
+ * asks who currently holds a derived row in the child, and detaching is
81
+ * exactly what takes those rows away, so reading it after would see nothing
82
+ * left to report.
83
+ */
84
+ export declare function detach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: DetachOptions): Promise<Result<DetachResult>>;
85
+ export interface AttachProposalForChild {
86
+ readonly id: string;
87
+ readonly parentId: string;
88
+ readonly parentName: string;
89
+ readonly reason: AttachReason;
90
+ readonly reference: string;
91
+ readonly proposedBy: string;
92
+ readonly createdAt: Date;
93
+ }
94
+ /** `tenants:attach` or `members:read`; every pending proposal to hold this organisation, for the page beside pending invitations. */
95
+ export declare function pendingProposalsForChild(access: Access, db: DbOrTx): Promise<readonly AttachProposalForChild[]>;
96
+ export interface AttachProposalFromParent {
97
+ readonly id: string;
98
+ readonly childId: string;
99
+ readonly childName: string;
100
+ readonly status: 'pending_parent' | 'pending' | 'accepted' | 'declined';
101
+ readonly reason: AttachReason;
102
+ readonly reference: string;
103
+ readonly createdAt: Date;
104
+ readonly decidedAt: Date | null;
105
+ }
106
+ /** `tenants:attach` or `members:read`; every proposal this organisation has sent as a parent, pending or decided, for its own console. */
107
+ /** An operator's draft waiting on this organisation to agree to hold another one. */
108
+ export interface AttachProposalForParent {
109
+ readonly id: string;
110
+ readonly childId: string;
111
+ readonly childName: string;
112
+ readonly reason: AttachReason;
113
+ readonly reference: string;
114
+ readonly proposedBy: string;
115
+ readonly createdAt: Date;
116
+ }
117
+ /**
118
+ * What an operator has proposed that this organisation hold, and nobody has
119
+ * agreed to yet. The parent answers these before the child ever sees them
120
+ * (M5): accepting hands this organisation's own members authority inside
121
+ * another one, which the console does not get to decide on its behalf.
122
+ */
123
+ export declare function pendingProposalsForParent(access: Access, db: DbOrTx): Promise<readonly AttachProposalForParent[]>;
124
+ export declare function proposalsFromParent(access: Access, db: DbOrTx): Promise<readonly AttachProposalFromParent[]>;