@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
package/README.md CHANGED
@@ -9,15 +9,366 @@ credentials, break-glass, the boot checks — and arrived at the same one. Of th
9
9
  lagging the other or a stale comment. This package is where that code goes so it
10
10
  exists once.
11
11
 
12
- `/Users/william.falch/Documents/dev/authz/docs/shared-persistence-map.md` is the
12
+ [docs/shared-persistence-map.md](../../docs/shared-persistence-map.md) is the
13
13
  map: what moves here, what stays per-application, and why this is a second
14
14
  package rather than a subpath on the engine.
15
15
 
16
16
  ## Status
17
17
 
18
- **Unadopted, unpublished, private.** Moved so far: credential secrets, the
19
- `ScopedDb` seam, every table definition, and `ownerCoverage`, the first module
20
- that reads rows. Neither application depends on this yet.
18
+ **Published.** `@wtfalch/authz-store` is on npm at 0.2.1. "Depend" here means a
19
+ pin in that host's `package.json`, not a verified deployment: Boule (`manage`)
20
+ pins 0.2.1, `people` pins 0.2.1, and otf's `web/` pins 0.2.0. archon still carries its own copy of
21
+ this layer, unmoved.
22
+
23
+ Moved so far: credential secrets, the `ScopedDb` seam, every table definition,
24
+ `ownerCoverage`, wave 1 (authz#83): `StoreBinding` (the `applicationId`,
25
+ `platformId` and `catalogue` a host now passes in explicitly rather than the
26
+ store importing its own), `isCustomRoleKey`, the shared `types` (`Principal`,
27
+ `TenantRow`, `Access`, `Result`), `policyResource`, `loadAccess` and the rest
28
+ of `policy-access` (`loadPolicyState`, `requirePolicySchema`, `permits`,
29
+ `permitsPlatform`, `policyTarget`, `refreshAccess`, `policyRole`,
30
+ `policyAssignment`, `restriction`), and `denial`'s `explain`; and wave 2:
31
+ `auditedReadGate`, `permitsRead` and `permitsPlatformRead` (now in
32
+ `policy-access.ts`, reaching `audit.ts`'s `record()` through a dynamic import
33
+ to avoid a static import cycle), `assignments` (`identityWhere`,
34
+ `primaryRoleKey`, `primaryAssignment`, `roleAssignmentRefusal`,
35
+ `writePrimaryAssignment`, `roleById`, `writeParticipationPolicy`), and `audit`
36
+ (`record`, `recordAs`, `writeServiceEvent`, `securityLogFor`) — the write path
37
+ is Boule's own: validated rows straight into `authz_events`, not
38
+ `@wtfalch/audit`'s `ledger.sign()` (decided 2026-09-29, authz#94); and wave 3:
39
+ `policy.ts`'s `definePolicy`, which builds a `StorePolicy` (a `StoreBinding`
40
+ plus `catalogue`, `builtInRoles`, `starterRoles`, `elevated`,
41
+ `requiresCoApproval`, `defaultCeiling`, `maxDepth`, `offered`) from a host's
42
+ own policy-catalogue JSON — generic over that JSON's shape (`PolicyData`),
43
+ since only the function that builds a catalogue from it was identical between
44
+ hosts; `roles` (`roleDef`, `assignableRoles`, `rolesForEditor`,
45
+ `canEditDefinition`, `createRole`, `updateRole`, `deleteRole`); and `boot`
46
+ (`ensureBuiltInRoles`, `seedStarterRoles`, `checkSystemRoles`,
47
+ `orphanedRoleKeys`, `content`) — ADR 0016 kept exactly: `builtInRoles` names
48
+ only `self_service`, resynced on every boot; every other `systemRoles` entry
49
+ is a starter role, seeded once by `seedStarterRoles`. Not moved:
50
+ `DENIAL_EVENTS`, keyed to one host's own permission ids and staying in the
51
+ hosts for that reason.
52
+
53
+ Wave 4a (authz#83) moves the tenant tree's read and query half, from
54
+ `tree.ts`: `nodeById`, `lockTenantsForUpdate`, `lockSubtreeForUpdate`,
55
+ `lockAttachScope`, `ancestorsOf`, `childrenOf`, `descendantsOf`,
56
+ `depthAbove`, `heightBelow`, `attachProblem` (now takes `policy: StorePolicy`
57
+ for `maxDepth`/`offered`, in place of the host's own imported constants),
58
+ `expectedDerivedFor`, `customRoleBlockers`, `attachCustomRoleBlockers`,
59
+ `derivedEventAction`, `principalsAtOrAbove` and `detachReport`.
60
+
61
+ Wave 4b (authz#83) moves the write and inheritance-propagation half, from
62
+ `tree-writes.ts`: `recomputeDerived` (the one writer that makes a tenant's
63
+ derived rows match the derivation rule), and the two callers that change the
64
+ tree's shape, `attachInTx` and `detachInTx`. `DerivedCustomRoleError` is
65
+ thrown, never returned, when a caller reaches `recomputeDerived` without
66
+ having checked `customRoleBlockers` first (D19: a derived membership never
67
+ carries a custom role).
68
+
69
+ Wave 5a (authz#83) moves `propagate.ts`, `reconcile.ts` and
70
+ `install-owner.ts`:
71
+
72
+ - `propagate.ts`: `propagateMembershipChange`, the one place every direct
73
+ membership write (`memberships.ts`, `invitations.ts`, `install-owner.ts`,
74
+ `credentials.ts`, all still host-side except the second) recomputes the
75
+ derived rows it can have moved and writes one audit row per change.
76
+ `writerFromAccess`/`writerFromPrincipal` build the `PropagationWriter` it
77
+ signs the rows with, for a write that already has an `Access` and one
78
+ (invitation acceptance) that does not yet.
79
+ - `reconcile.ts`: `reconcileDerived`, D19's drift check — every `inherited`
80
+ membership row compared, read-only, against what `expectedDerivedFor`
81
+ (`tree.ts`) says the tree calls for.
82
+ - `install-owner.ts`: `installOwner`, the one exception to D8's rule that no
83
+ session or standing operator reach ever changes tenant membership. Boule's
84
+ copy read the host's own `profiles` table directly for two things — whether
85
+ the target person has ever signed in, and the tenant's current owners'
86
+ `lastSeenAt` — and that table is the host's, not the store's, so both
87
+ become an `OwnerActivityLookup` the host passes in: `personKnown(id)` and
88
+ `lastSeenAt(ids)`, batched over a tenant's direct owners rather than one
89
+ join. Every host adopting this wave implements that interface over its own
90
+ `profiles` (or equivalent) table.
91
+
92
+ Every function in this wave that used to read `db` from a host's own
93
+ `@/lib/db/root` now takes it as an explicit `db: DbOrTx` parameter, the same
94
+ as `securityLogFor` and `createRole`/`updateRole`/`deleteRole` in earlier
95
+ waves.
96
+
97
+ Wave 5b (authz#83) moves `break-glass.ts`: `startBreakGlass`, `endBreakGlass`,
98
+ `endSessionsFor`, `activeSession`, `sessionsFor` — the one place a support
99
+ session opens, ends or force-ends. The break-glass support role is found by
100
+ `isCustomRoleKey`, not `.builtIn` (ADR 0016: a starter role is `built_in:
101
+ false` but still an application-defined role, never a tenant-authored one).
102
+ `db` is again an explicit parameter rather than a host import, the same as
103
+ wave 5a.
104
+
105
+ Wave 6 (authz#83) moves `nesting.ts`, the consent flow over the tree:
106
+ `proposeAttach`, `acceptAttach`, `declineAttach`, `detach`, and the three
107
+ pending-proposal readers (`pendingProposalsForChild`,
108
+ `pendingProposalsForParent`, `proposalsFromParent`). `proposeAttach` and
109
+ `acceptAttach` take `policy: Pick<StorePolicy, 'maxDepth' | 'offered'>`
110
+ explicitly, since `attachProblem` (wave 4a) needs it and the store has no
111
+ `StorePolicy` of its own in scope. Unique-violation detection on the
112
+ proposal's partial index (authz#96) uses `@wtfalch/db`'s `sqlState()`, the
113
+ same helper otf already used, in place of the `(error as {code?}).code ===
114
+ '23505'` duck-typing Boule's and archon's copies both wrote — declared as a
115
+ peer dependency, matching `drizzle-orm`'s role (a host-supplied instance),
116
+ pinned exact at `0.4.0` (the estate's `@wtfalch/*` pin rule), plus a matching
117
+ exact `devDependency` for this package's own build and test.
118
+
119
+ Wave 7 (authz#83, authz#95 decision 2, authz#98) moves `credentials.ts`
120
+ (`mintCredential`, `revokeCredential`, `credentialsFor`) and `bootstrap.ts`
121
+ (`bootstrapOperator`).
122
+
123
+ Minting and revoking cut over from this package's own `credential-secret.ts`
124
+ scheme to a host's `@wtfalch/keys` issuer: `mintCredential` and
125
+ `revokeCredential` now take an `issuer` parameter, typed by the store's own
126
+ `CredentialIssuerPort` (`issue`/`revoke`, in exactly the shapes the two
127
+ functions call them), with no default. `@wtfalch/keys` is a `devDependency`
128
+ only, pinned exact at `0.4.2`; a type test in `credentials.test.ts` proves
129
+ `CredentialIssuer<StoreKeyGrant>` from `@wtfalch/keys/issued` satisfies the
130
+ port, and nothing outside a test imports `@wtfalch/keys` from this package.
131
+ `credential-secret.ts` is unchanged and stays: it verifies every row minted
132
+ before a host adopts this wave, and a host's own bearer path keeps checking
133
+ against it for those rows. Migration `0004_credential_keys_issued_id.sql`
134
+ loosens `credentials.secret_hash` to nullable, adds `keys_issued_id`, and
135
+ widens `credentials_secret_prefix_check` to 64 characters, matching
136
+ `@wtfalch/keys`'s own bound. `credentialsFor` no longer joins a host's own
137
+ `profiles` table; it takes an `IssuerNames` port instead
138
+ (`personNames(ids)`, batched over every human issuer on the page), a host's
139
+ display name falling back to email the same way the join order did.
140
+
141
+ `bootstrapOperator` no longer reads `AUTHZ_BOOTSTRAP_OPERATOR` or imports
142
+ `@wtfalch/auth`: a host resolves its own bootstrap subject and passes it as
143
+ `options.subject`, along with `options.ensureProfile`, generic over the
144
+ host's own `User` type. Boule's order is unchanged (`ensureProfile` first,
145
+ then the tenant row lock, the owner count read only after the lock) and so
146
+ is its `seedStarterRoles` call, which otf's copy of this function had
147
+ dropped — a bug this move does not repeat.
148
+
149
+ Wave 8a (authz#83, issue #98 decision 3) moves the sending and management
150
+ half of `invitations.ts`: `generateInvitationToken`, `invite`,
151
+ `markInvitationMail`, `resendInvitation`, `revokeInvitation`,
152
+ `pendingInvitations`, `invitationPreview` and `invitationTenantId`.
153
+ `INVITATION_CAP_PER_HOUR` is no longer a package constant; `invite` and
154
+ `resendInvitation` take `capPerHour` as an option. The store never
155
+ hard-codes an acceptance route either: `invite` takes a `link: (token:
156
+ string) => string` builder, and Boule's own `invitationLink` (`/org/invite/
157
+ ${token}`) stays in the host, unmoved, as its caller. `acceptInvitation`
158
+ is wave 8b, not this wave: it needs `acceptInvitationForUser`'s host-only
159
+ `requireUser`/`ensureProfile`/`principalFromUser`, which stay in Boule as a
160
+ non-goal (see wave 8b below for the boundary).
161
+
162
+ Wave 8b (authz#83, issue #98 decisions 3–5) moves `acceptInvitation`,
163
+ `AcceptInvitationResult`, `AttachRefusal` and the born-attach savepoint
164
+ (`attemptBornAttach`, `AttachAttemptRefused`), staying in `invitations.ts`
165
+ alongside wave 8a. `acceptInvitationForUser` does **not** move: it is the
166
+ one caller of Boule's own `requireUser`, `ensureProfile` and
167
+ `principalFromUser` from `access.ts`, none of which this package can reach,
168
+ so it stays in Boule as this wave's one host-only function. `acceptInvitation`
169
+ takes `binding`, `policy: Pick<StorePolicy, 'maxDepth' | 'offered'>` (for the
170
+ born-attach path's `attachProblem`) and `profiles: Pick<OwnerActivityLookup,
171
+ 'personKnown'>` (install-owner.ts's own port, reused rather than a second one
172
+ for the same "has this id ever signed in" question) as explicit parameters.
173
+ `@wtfalch/people` is not imported: it depends on this package, so importing
174
+ it back would be a cycle; the membership-row half of its `grantMembership` is
175
+ inlined directly in `acceptInvitation` instead, with the role assignment and
176
+ participation-policy writes staying this file's own, exactly as Boule's
177
+ comment already described the split.
178
+
179
+ Wave 9a (authz#83, issue #99 decision 4) moves the member-side writes and
180
+ reads from `tenants.ts`: `createTenant`, `renameTenant`, `setSelfDenied`,
181
+ `setTenantState`, `setCeiling`, `tenantById`, `tenantBySlug`, `allTenants`.
182
+ `TENANT_CREATION` and `TENANT_CREATION_CAP_PER_DAY` are no longer package
183
+ constants: a host passes both through `CreateTenantConfig` (decision 3), the
184
+ same option shape as every other wave's config parameter. `defaultCeiling`
185
+ and `offered` come off the `policy: StorePolicy` (or the narrower
186
+ `Pick<StorePolicy, 'defaultCeiling' | 'offered'>` where that is all a
187
+ function needs) a caller passes in, rather than an import (decision 4). The
188
+ person check `createTenant` needs before it lets someone self-serve a
189
+ tenant reuses `OwnerActivityLookup.personKnown` (wave 5a's own port), the
190
+ same "has this id ever signed in" question, rather than a second port for
191
+ it. A slug race is read the same way wave 6's proposal collision is: a real
192
+ unique-violation, via `@wtfalch/db`'s `sqlState()`, not
193
+ `(error as {code?}).code` duck-typing.
194
+
195
+ Wave 9b (authz#83, issue #99 decision 4) moves the operator side from the
196
+ same file: `createTenantAsOperator`, `deleteJustCreatedTenant`,
197
+ `archiveTenant`. `createTenantAsOperator` takes a `link: (token: string) =>
198
+ string` builder for its pending owner invitation, the same shape wave 8a's
199
+ `invite` takes, so the store never hard-codes an acceptance route.
200
+ `deleteJustCreatedTenant` moves as archon's compensating rollback for its
201
+ own second, non-transactional write (issue #40) — a plain exported
202
+ function only archon calls, operator-only by its own guards, with no hook
203
+ or extension interface, since nothing else needs one. `openBearerScope`
204
+ does **not** move: it only wraps the host's own db and tenant tables with
205
+ the store's `openScopedDb`/`scopeColumnOf`, so it has nothing this package
206
+ doesn't already export, and stays in archon.
207
+
208
+ Wave 10a (authz#83) moves the grants compiler from Boule's own
209
+ `src/lib/authz/grants.ts` (496 lines): `personRecordFor`, the person page's
210
+ read of every grant a human principal holds in one organisation, with its
211
+ provenance and the teams they participate in, and `teamRecordFor`, the team
212
+ page's own read of one team, its place in the hierarchy, who is in it, and
213
+ its scoped access. Both land in a new `src/person-records.ts`, not this
214
+ package's existing `src/grants.ts` — that file is the `authz_grants` table
215
+ and `guestGrants`, a different thing, and stays untouched. Boule's one
216
+ `z.uuid()` check becomes the store's own `isUuid` from `types.ts`; the store
217
+ gained no zod dependency. `APPLICATION_ID`, `PLATFORM_ID` and `catalogue`
218
+ become `access.binding`, the same as every other moved module. `profiles`
219
+ becomes a `PeopleDirectory` port, reused for both the grant-provenance name
220
+ lookup (`resolveActorNames`) and `personRecordFor`'s own display fields —
221
+ Boule's inner join against `profiles`, which excluded a member with no
222
+ profile row from the result, becomes the directory's map coming up empty for
223
+ that id, read the same way.
224
+
225
+ Wave 10b (authz#83, William 2026-09-29) moves Boule's own
226
+ `src/lib/authz/memberships.ts` (407 lines) into a new `src/memberships.ts`:
227
+ `membersOf`, `selfStanding`, `guardStaysHeld`, `grantMembership`,
228
+ `changeRole` and `removeMembership`, keeping Boule's own names. The
229
+ row-level work four of those six delegated to `@wtfalch/people` 0.2.3 —
230
+ `grantMembership`, `removeMembership`, `changeRole` and `guardStaysHeld`
231
+ from its `membership.js`, and `membersOf` from its `roster.js` — is copied
232
+ into a new `src/membership-rows.ts` instead of imported, because
233
+ `@wtfalch/people` depends on this package and importing it back would be a
234
+ cycle; a later issue in the people repo makes it reuse this copy rather than
235
+ carry its own. Every row helper is exported under a name distinct from its
236
+ policy-layer counterpart (`insertMembershipRow`, `deleteMembershipRow`,
237
+ `changeRoleRow`, `guardStaysHeldRow`, `membersOfRow`), since the two layers
238
+ would otherwise claim the same five names on one package surface. As of
239
+ 0.4.0 (https://github.com/wtfalch/people/issues/16) all five are exported
240
+ from the main entry, so `@wtfalch/people` can call this copy instead of
241
+ carrying its own — see the 0.4.0 note below. `identityWhere` is not duplicated: people's
242
+ own copy tests the same three columns as `src/assignments.ts`'s, in a
243
+ different argument order `and()` doesn't care about, so `membership-rows.ts`
244
+ imports that one. `roster.js`'s `profiles` left join (a member with no
245
+ profile row is kept, with null display fields) becomes the same
246
+ `PeopleDirectory` port wave 10a introduced, read the same left-join way —
247
+ a human principal missing from the directory keeps its row, a non-human one
248
+ never reaches the directory at all, same as the original join's predicate.
249
+
250
+ Wave 11a (authz#83) moves Boule's own `src/lib/authz/resource-access.ts`
251
+ (814 lines) into a new `src/resource-access.ts`: `resourceOverview`,
252
+ `saveAppRole`, `assignAppRole`, `removeAppAssignment`, `deleteAppRole`,
253
+ `saveTeam`, `archiveTeam`, `changeTeamParticipant`, `scopedAuditLog`, and
254
+ the shared `resourceAccessFor` request-time evaluator every one of them
255
+ reads through. Boule's copy was the newest of the two hosts' — it alone
256
+ has `resourceOverview`'s `canReadPeople` field, which archon and otf never
257
+ grew — and is the one kept. `resourceAccessFor` itself reloads access on
258
+ every call, a deliberate re-read Boule's own code already made, not a
259
+ redundancy introduced here; it now takes the same `tx`/`db`/`scopeColumn`
260
+ triple `loadAccess` does, since the store has no root handle of its own to
261
+ default it onto the way Boule's `db` import did. Every exported function
262
+ gained `db: DbOrTx` and `scopeColumn: Scope['column']` parameters for the
263
+ same reason, the same convention every earlier wave followed.
264
+
265
+ `appRolePermissionsSchema` was a module-level zod schema closed over
266
+ Boule's own `catalogue` constant; it is now a function of
267
+ `ResourceCatalogue` (`access.binding.catalogue`), as is the private
268
+ `roleInputSchema` built from it — every validation rule inside both is
269
+ unchanged. This is the store's first zod dependency: `zod` is now a peer
270
+ and dev dependency of `@wtfalch/authz-store`, `^4.1.13`/`^4.5.4`, the same
271
+ pins `@wtfalch/authz` itself carries. Boule's `import { db } from
272
+ '@/lib/db/root'` and `import 'server-only'` are gone, the same as every
273
+ earlier wave; its `asc`, `memberships` (table), `tenants` (table),
274
+ `TenantRow` and `policyAssignment` imports — five names in all — were
275
+ already unused in the original file and were not carried over.
276
+
277
+ At 814 lines of source, this module is over the 800-line PR-size limit
278
+ with its tests added, but it stays one PR: its functions share private
279
+ helpers (`writer`, `resourceAccessFor`, `lookupResource`, `delegable`,
280
+ `auditClause`, `target`) too tightly to split without duplicating them or
281
+ introducing a cross-file dependency this wave's split would then have to
282
+ undo.
283
+
284
+ Wave 11b (authz#83) moves Boule's own `src/lib/authz/activations.ts` (694
285
+ lines, ADR 0016 "No standing authority") into a new `src/activations.ts`:
286
+ `needsCoApproval`, `requestActivation`, `approveActivation`,
287
+ `selfApproveActivation` and `endActivation`. `elevated` and
288
+ `requiresCoApproval` — ADR 0016 sets, not part of `StoreBinding` — come off a
289
+ `policy: Pick<StorePolicy, ...>` parameter each function needs, the same
290
+ shape wave 6's `proposeAttach`/`acceptAttach` and wave 9a's tenant writes
291
+ take theirs; unlike Boule's own module-level constants, `StorePolicy.elevated`
292
+ and `.requiresCoApproval` are already `ReadonlySet<string>`, so no widening
293
+ cast is needed. The tenant-only `select ... for update` Boule's three
294
+ write functions each repeated right after `refreshAccess` is dropped: the
295
+ store's own `refreshAccess` already takes that lock inside itself. The two
296
+ new tables (`authz_activations`, `authz_activation_approvals`) land in
297
+ `migrations/0005_activations.sql`; `authz_roles.updated_by` and the
298
+ `'activation'` `authz_assignments.source` value, and all six `activation.*`
299
+ names in `authz_events_action_check`, were already in `0001_baseline.sql`
300
+ before this wave. No FK from `principal_id`/`approver_id` to a host's own
301
+ person table, the same as `break_glass_sessions.operator_id`. No audit event
302
+ is written anywhere in this file, kept exactly as Boule had it, even though
303
+ this package's own `authz_events_action_check` and pinned `@wtfalch/authz`
304
+ already accept every name Boule's own comment said its installed version
305
+ did not yet — turning the writes on is a behaviour change this move does
306
+ not make on its own judgement.
307
+
308
+ Wave 11c (authz#83) moves three of Boule's own modules into new files here,
309
+ keeping every guard and comment: `src/lib/authz/export.ts` (464 lines) into
310
+ `src/export.ts` (`exportTenant`), `src/lib/authz/events.ts` (189 lines) into
311
+ `src/events.ts` (`eventsFor`), and `src/lib/authz/platform.ts` (71 lines)
312
+ into `src/platform.ts` (`platformFrozen`, `setPlatformFrozen`). All three
313
+ copies of `export.ts` (archon, Boule, otf) differed only cosmetically; Boule's
314
+ was the source for all three. `exportTenant` takes this package's own
315
+ `DbOrTx` instead of Boule's root `Db`; its own hand-built `tenant.exported`
316
+ row (never through `record`/`recordAs`, since its caller has no full `Access`
317
+ to build one from) is unchanged. `eventsFor` takes `db: DbOrTx` explicitly,
318
+ with `APPLICATION_ID`/`PLATFORM_ID` now `access.binding`'s and its
319
+ `auditedReadGate` call now this package's own (`policy-access.ts`, which
320
+ takes `(access, db, permission, allowed)`). `platformFrozen` and
321
+ `setPlatformFrozen` both take `db: DbOrTx`; `setPlatformFrozen` also takes
322
+ `scopeColumn: Scope['column']` for the `refreshAccess` call inside its
323
+ transaction, the same shape `tenants.ts` uses. otf's throw-based, host-bound
324
+ `setPlatformFrozen` (it wraps a session-bound `requireOperator`) stays in
325
+ otf, not moved.
326
+
327
+ Wave 11d (authz#83) moves Boule's own `src/lib/authz/erase.ts`, `alerts.ts`
328
+ and `startup.ts` into `src/erase.ts`, `src/alerts.ts` and `src/startup.ts`.
329
+ `erase.ts` moves `pseudonymFor` and `erasePerson`, the one sanctioned write
330
+ to `authz_events`; the SQL function it calls (`authz_erase_person`, `SECURITY
331
+ DEFINER`, a pinned `search_path`) ships as `migrations/0006_erase_person.sql`,
332
+ sweeping `authz_events` and `invitations` the way Boule's
333
+ `drizzle/0008_erasure_and_boot.sql` did. Boule's own function also scrubbed
334
+ `profiles` and called a reporting package's `reporting_erase_person`; both
335
+ are host tables/packages, so they become one `ErasureHost` port
336
+ (`emailOf`, `eraseHostRecords`) `erasePerson` calls inside its transaction,
337
+ after the sweep and before the `person.erased` row, the same order rule
338
+ Boule's own comment states. `otf`'s own `eraseOperatorPerson`, which sweeps
339
+ otf's own tables, stays in otf and calls the store's `erasePerson`. `alerts.ts`
340
+ moves the four D9 checks (`checkOrphanMemberships`, `checkStuckInvitations`,
341
+ `checkBreakGlassBurst`, `checkOrphanedRoleKeys`), `reportAlert` and
342
+ `runAlerts`; Boule's `@/lib/reporting/core` `reporting` becomes a structural
343
+ `AuthzReporting` port. `startup.ts` moves `runStartupChecks` and
344
+ `registerHousekeeping`; Boule's own version returned early with no
345
+ `DATABASE_URL` to check, which this version drops — a host with no database
346
+ to check simply does not call it, rather than this framework-neutral package
347
+ reading `process.env` to decide the same thing for itself — and its
348
+ `housekeeping` becomes a second structural port, `HousekeepingRegistry`.
349
+
350
+ 0.4.0 (authz#83 round 2, files#112): `loadAccess`'s credential-chain walk in
351
+ `src/policy-access.ts` now selects only `issuerId`, `issuerClass`, `expiresAt`
352
+ and `revokedAt` off `credentials`, instead of every column. files and ai
353
+ dropped `secret_hash`, `secret_prefix` and `keys_issued_id` on purpose, and a
354
+ plain `select()` there named them anyway, failing every service-credential
355
+ request with "column does not exist". `export.ts`, `person-records.ts` and
356
+ `credentials.ts` already select explicit column lists and were left as they
357
+ are: each genuinely needs the secret/keys column it names (an export row's
358
+ `secretPrefix`, a revoke's `keysIssuedId`, `credentialsFor`'s display
359
+ `secretPrefix`), so narrowing further would break them, not fix them.
360
+
361
+ 0.4.0 also renames the SQL function `migrations/0006_erase_person.sql`
362
+ creates, from `erase_person` to `authz_erase_person`. The old name collided
363
+ with each host's own erasure function of the same name and signature, which
364
+ also scrubs a host's `profiles`; `migrateStore`'s `CREATE OR REPLACE FUNCTION`
365
+ replaced a host's function outright, silently dropping that scrub. 0.3.0
366
+ published an hour before this fix and no persistent database had applied
367
+ 0006 yet, so this is a same-file rename, not a new numbered migration.
368
+ `src/erase.ts` calls `authz_erase_person`.
369
+
370
+ The rest of the host layer has not moved — tracked in
371
+ https://github.com/wtfalch/authz/issues/83.
21
372
 
22
373
  New here, not moved: `authz_grants`, grants stored per person rather than
23
374
  compiled from a role, and `guestGrants`, which reads a tenant's guest grants
@@ -31,6 +382,43 @@ the tables from the drizzle definitions. `src/migrate.test.ts` applies the
31
382
  shipped SQL instead and fails when the two disagree on a column, a type,
32
383
  nullability or an index name.
33
384
 
385
+ ## 0.4.0
386
+
387
+ `StoreBinding` (`src/binding.ts`) gains `hostEvents?: readonly string[]`: a host's own audit
388
+ event names (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/integrations:
389
+ `mail.*`, `storage.*`, `integrations.*`), beyond `@wtfalch/authz`'s core events, so a host writes
390
+ its own events through the same `record`/`recordAs`/`writeServiceEvent` path core events use,
391
+ never a second one of its own (authz#83, archon#72). `definePolicy(data, binding)` accepts
392
+ `hostEvents` in `binding` and carries it onto the returned `StorePolicy`; a `hostEvents` name that
393
+ repeats a core event name is a configuration error and `definePolicy` throws. `recordAs` and
394
+ `writeServiceEvent` each gain an optional trailing `binding` parameter, since neither had one to
395
+ read `hostEvents` off before.
396
+
397
+ `EventInput.action` (on `record`, `recordAs` and `writeServiceEvent`) is now `EventName |
398
+ (string & {})`: core names still autocomplete, and any other string is accepted only when it is
399
+ listed in the binding's `hostEvents`; anything else throws before the row is built any further,
400
+ naming the action. **The store's own baseline `authz_events_action_check` constraint still lists
401
+ core events only** — this package's migrations are unchanged. A host that adopts `hostEvents` must
402
+ widen that CHECK in its own migration first (hosts that don't adopt keep their existing
403
+ constraint as-is).
404
+
405
+ 0.4.0 (authz#83 round 2) adds a `./policy` subpath export
406
+ (https://github.com/wtfalch/boule/pull/162): `definePolicy` plus `StorePolicy`,
407
+ `StoreBinding`, `PolicyData` and `PolicyRoleTemplate`, and nothing else, for a
408
+ client component that only needs the policy catalogue for permission labels.
409
+ The main entry (`.`) still exports everything it does today — this is
410
+ additive — but it also drags in `migrateStore`, which imports
411
+ `node:fs/promises` and breaks a production client build; `./policy`'s import
412
+ graph is proved free of any `node:` module, `drizzle-orm` or a database handle
413
+ by `scripts/tests/package-contract.test.mjs`. 0.4.0 also exports the five
414
+ membership row helpers from `src/membership-rows.ts` off the main entry —
415
+ `insertMembershipRow`, `deleteMembershipRow`, `changeRoleRow`,
416
+ `guardStaysHeldRow`, `membersOfRow` — so `@wtfalch/people` can call this
417
+ package's copy instead of carrying its own
418
+ (https://github.com/wtfalch/people/issues/16). No behaviour change: each
419
+ helper writes an audit row only when a `MembershipRowAuditOptions` writer is
420
+ passed, exactly as before.
421
+
34
422
  ## Migrations
35
423
 
36
424
  The package ships its own SQL in `migrations/`, and `migrateStore(db)` applies
@@ -68,7 +456,7 @@ The engine has no runtime dependencies, and a test scans its whole published
68
456
  imports `node:crypto`, which is precisely what that scan rejects. The separation
69
457
  is asserted, not intended: see `the store package exports exactly what it
70
458
  promises, and is Node-only by nature` in
71
- `/Users/william.falch/Documents/dev/authz/scripts/tests/package-contract.test.mjs`.
459
+ `scripts/tests/package-contract.test.mjs`.
72
460
 
73
461
  ## A note for a Next.js host
74
462
 
@@ -0,0 +1,60 @@
1
+ import type { StorePolicy } from './policy.js';
2
+ import type { DbOrTx, Scope } from './scoped.js';
3
+ import { type Access, type Result } from './types.js';
4
+ /** Whether a requested set of permissions needs a second approver at all (ADR 0016). */
5
+ export declare function needsCoApproval(permissions: readonly string[], policy: Pick<StorePolicy, 'requiresCoApproval'>): boolean;
6
+ export interface RequestActivationOptions {
7
+ readonly permissions: readonly string[];
8
+ readonly reason: string;
9
+ readonly reference: string;
10
+ /** Defaults to 60; a working session, not a fixed package ceiling. */
11
+ readonly minutes?: number;
12
+ }
13
+ /**
14
+ * Opens an activation. Every static check runs before the transaction, the
15
+ * same ordering `startBreakGlass` uses, so a malformed request never reaches
16
+ * the lock. `principalClass` is always `'human'`: agents and services cannot
17
+ * open one (no inbox for the self-approve-wait email, nothing eligible to
18
+ * approve on their behalf), and a credential is not a principal at all
19
+ * under D10.
20
+ */
21
+ export declare function requestActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'elevated' | 'requiresCoApproval'>, options: RequestActivationOptions): Promise<Result<{
22
+ id: string;
23
+ status: 'pending' | 'active';
24
+ expiresAt: Date;
25
+ }>>;
26
+ export interface ApproveActivationOptions {
27
+ readonly activationId: string;
28
+ readonly decision: 'approved' | 'denied';
29
+ readonly reason?: string;
30
+ }
31
+ /**
32
+ * A second person's decision on someone else's pending activation. Refuses
33
+ * a same-principal row unconditionally, in every branch -- there is no path
34
+ * here that lets a requester sign their own activation; that is
35
+ * `selfApproveActivation`, a different action (ADR, "The two-person
36
+ * approval is Boule's own check").
37
+ */
38
+ export declare function approveActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'requiresCoApproval'>, options: ApproveActivationOptions): Promise<Result<{
39
+ status: 'active' | 'denied';
40
+ }>>;
41
+ /**
42
+ * The solo path: the requester approves their own activation once no
43
+ * Aged-and-Independent person besides them is eligible, and only after a
44
+ * 24-hour wait re-checked inside this locked transaction (ADR, "The
45
+ * self-approve wait, enforced lazily like everything else here"). A
46
+ * manufactured second account never counts toward "another eligible
47
+ * approver exists" here, because `eligibleApprovers` already excludes
48
+ * anyone whose eligibility traces back to this same requester.
49
+ */
50
+ export declare function selfApproveActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'requiresCoApproval'>, activationId: string): Promise<Result<{
51
+ status: 'active';
52
+ }>>;
53
+ /**
54
+ * Ends one's own activation, pending or active -- a safety valve, not a
55
+ * power, the same framing `endBreakGlass` gives ending your own session.
56
+ * The ADR names no permission for ending someone *else's* (unlike
57
+ * `break_glass:end-any`), so that branch does not exist here; see this
58
+ * wave's report.
59
+ */
60
+ export declare function endActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], activationId: string): Promise<Result<void>>;