@fabrika/app 0.3.3 → 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 (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,747 @@
1
+ // The test kit's stand-in for IAM's membership transport, which every kit boots with unless a test passes its own. It
2
+ // answers a member directory (`ctx.members.list()` and `get()`) from the kit's callers, as IAM answers it from its
3
+ // grants, and the writes under IAM's rules for the caller's grants: the delegation grant, `members.manage`,
4
+ // `assignableBy`, the role's actions, a link mount's `issuedBy`, the last creator and IAM's input bounds; and its answers
5
+ // to an invitation: the open one again, a resend's new lifetime, an organization's mailbox refused for an external role.
6
+ // It records each write it allowed. A role it assigns or revokes is live, as in IAM: the directory, the stand-in's own
7
+ // checks and the kit's next token of that principal read it.
8
+
9
+ import {
10
+ API_KEY_PREFIX,
11
+ type AppMemberItem,
12
+ type AssignMemberInput,
13
+ type AssignMemberResult,
14
+ type ContractArtifact,
15
+ type CreateScopeInput,
16
+ type CreateScopeResult,
17
+ DELEGATION_GRANT_TTL_SECONDS,
18
+ directoryLookups,
19
+ type EndpointDelegation,
20
+ endpointDelegation,
21
+ type FindMemberInput,
22
+ type FindMemberResult,
23
+ type IamMembershipRpc,
24
+ invitationOptionsProblem,
25
+ invitedAddress,
26
+ type IssueLinkInput,
27
+ type IssueLinkResult,
28
+ type ListAppMembersInput,
29
+ type ListAppMembersResult,
30
+ type ListMembersInput,
31
+ type ListMembersResult,
32
+ matchAction,
33
+ MAX_APP_MEMBERS_PAGE,
34
+ MAX_LINK_CHAIN_LENGTH,
35
+ MAX_MEMBERSHIP_SCOPE_PART_LENGTH,
36
+ type MemberItem,
37
+ type MembershipCaller,
38
+ type MembershipFailure,
39
+ membershipScopeProblem,
40
+ type MemberTarget,
41
+ normalizeEmailIdentity,
42
+ type PermissionEntry,
43
+ permits,
44
+ type RequestDelegationInput,
45
+ type RequestDelegationResult,
46
+ type RevokeLinkInput,
47
+ type RevokeLinkResult,
48
+ type RevokeMemberInput,
49
+ type RevokeMemberResult,
50
+ type Scope,
51
+ } from '@fabrika/auth-core'
52
+ import type { LabelledTestCaller, TestCaller, TestScope } from './caller'
53
+
54
+ /** A role a test caller holds in one scope value, or globally (`null`). */
55
+ export interface TestMembership {
56
+ readonly role: string
57
+ readonly scope: TestScope | null
58
+ }
59
+
60
+ export interface DirectoryCallers {
61
+ /**
62
+ * The caller a membership request's credential (its token or `px_` key) belongs to, as IAM verifies it for `app`:
63
+ * none for a credential of another application or an expired token.
64
+ */
65
+ byCredential(app: string, credential: string): TestCaller | undefined
66
+ /** Every caller with its role grants. */
67
+ all(): readonly { readonly caller: TestCaller; readonly memberships: readonly TestMembership[] }[]
68
+ }
69
+
70
+ /** A write `kitMembership` allowed, by the principal that asked; the credential and the delegation grant are left out. */
71
+ export type AskedWrite =
72
+ | { readonly operation: 'createScope'; readonly principalId: string; readonly scope: Scope; readonly role: string }
73
+ | {
74
+ readonly operation: 'assignMember'
75
+ readonly principalId: string
76
+ readonly scope: Scope
77
+ readonly role: string
78
+ readonly member: MemberTarget
79
+ /** The invitation it answered for an address; `null` for an existing principal. */
80
+ readonly invitationId: string | null
81
+ }
82
+ | { readonly operation: 'revokeMember'; readonly principalId: string; readonly scope: Scope; readonly member: RevokeMemberInput['member'] }
83
+ | { readonly operation: 'issueLink'; readonly principalId: string; readonly id: string; readonly mount: string; readonly chain: readonly Scope[] }
84
+ | { readonly operation: 'revokeLink'; readonly principalId: string; readonly id: string }
85
+
86
+ /** An invitation its addressee accepted: the role it gave, in its scope, to the invited address. */
87
+ export interface AcceptedInvitation {
88
+ readonly scope: Scope
89
+ readonly role: string
90
+ readonly email: string
91
+ }
92
+
93
+ export interface KitMembership {
94
+ readonly rpc: IamMembershipRpc
95
+ /** Every write it allowed, in order. */
96
+ readonly asked: readonly AskedWrite[]
97
+ /** The permissions IAM signs for `caller` now: the test's grants, with the roles this stand-in assigned or revoked. */
98
+ permsOf(caller: TestCaller): readonly PermissionEntry[]
99
+ /**
100
+ * Closes the open invitation `invitationId` as accepted, as IAM's onboarding does, and answers what it gives. With
101
+ * `recipient`, that person holds the role from now on; without one the kit makes the new person who holds it. As IAM,
102
+ * it refuses an external recipient of a role not declared external (`not_eligible`) and an invitation whose inviter
103
+ * could no longer give the role (`not_allowed`), and then leaves the invitation open.
104
+ */
105
+ accept(invitationId: string, recipient?: TestCaller): AcceptedInvitation
106
+ /** An open invitation of `role` in `scope` to `email`, as an earlier request opened it; answers its id. */
107
+ seedInvitation(scope: Scope, role: string, email: string): string
108
+ /** A share link of `mount` bound to `chain`, as `issuer` issued it in an earlier request; answers its id. */
109
+ seedLink(issuer: string, mount: string, chain: readonly Scope[]): string
110
+ /** The link a key it issued is bound to, as IAM resolves a link key; `undefined` for any other key and a revoked link. */
111
+ linkOfKey(key: string): IssuedLinkKey | undefined
112
+ /** The roles `caller` holds now: the test's grants, with the ones this stand-in assigned or revoked. */
113
+ membershipsOf(caller: TestCaller): readonly TestMembership[]
114
+ /** Every invitation it opened, the seeded ones included, in the order they were first opened. */
115
+ invitations(): readonly SentInvitation[]
116
+ }
117
+
118
+ /** A share link the stand-in issued, as its key resolves. */
119
+ export interface IssuedLinkKey {
120
+ readonly id: string
121
+ readonly mount: string
122
+ readonly chain: readonly Scope[]
123
+ }
124
+
125
+ /** The invitation e-mail IAM would have sent: to whom, for which role in which scope, and where it stands now. */
126
+ export interface SentInvitation {
127
+ readonly id: string
128
+ readonly email: string
129
+ readonly role: string
130
+ readonly scope: Scope
131
+ readonly expiresAt: number
132
+ readonly status: 'open' | 'accepted' | 'cancelled' | 'expired'
133
+ }
134
+
135
+ /**
136
+ * The addresses the organization names, as IAM's configuration does (its human e-mails, its e-mail domains): IAM never
137
+ * invites one to an external role (`internal_mailbox`).
138
+ */
139
+ export interface OrganizationMailboxes {
140
+ readonly emails: readonly string[]
141
+ readonly domains: readonly string[]
142
+ }
143
+
144
+ const NO_ORGANIZATION: OrganizationMailboxes = { emails: [], domains: [] }
145
+
146
+ /** An invitation the stand-in opened, open until it is cancelled, accepted or its lifetime ends. */
147
+ interface KitInvitation {
148
+ readonly id: string
149
+ readonly scope: Scope
150
+ readonly role: string
151
+ readonly email: string
152
+ readonly expiresAt: number
153
+ readonly closed: 'cancelled' | 'accepted' | null
154
+ /** The principal that opened it, whose authority IAM checks again at acceptance; `null` for a seeded one. */
155
+ readonly inviter: string | null
156
+ }
157
+
158
+ /** A share link the stand-in issued; a revoked one is still found, so a repeated revoke answers the same. */
159
+ interface KitLink {
160
+ readonly id: string
161
+ readonly issuer: string
162
+ readonly mount: string
163
+ readonly chain: readonly Scope[]
164
+ /** The key it answered the issuer; `null` for a seeded link, which no one holds. */
165
+ readonly key: string | null
166
+ readonly revoked: boolean
167
+ }
168
+
169
+ /** A token the kit's IAM signed, for the caller it was minted for, until its `exp`. */
170
+ export interface MintedToken {
171
+ readonly caller: TestCaller
172
+ readonly expiresAt: number
173
+ }
174
+
175
+ /**
176
+ * The kit's callers as IAM resolves a membership request's credential for `app`: a `px_` key the kit issued, or a token
177
+ * it signed that has not expired; nothing for another application.
178
+ */
179
+ export function credentialCallers(
180
+ app: string,
181
+ keys: ReadonlyMap<string, TestCaller>,
182
+ tokens: ReadonlyMap<string, MintedToken>,
183
+ memberships: () => ReturnType<DirectoryCallers['all']>,
184
+ ): DirectoryCallers {
185
+ return {
186
+ byCredential(audience, credential) {
187
+ if (audience !== app) return undefined
188
+ const minted = tokens.get(credential)
189
+ if (minted === undefined) return keys.get(credential)
190
+ return minted.expiresAt > now() ? minted.caller : undefined
191
+ },
192
+ all: memberships,
193
+ }
194
+ }
195
+
196
+ const NOT_ALLOWED: MembershipFailure = { ok: false, reason: 'not_allowed' }
197
+ const NOT_FOUND: MembershipFailure = { ok: false, reason: 'not_found' }
198
+ const INVALID_DELEGATION: MembershipFailure = { ok: false, reason: 'invalid_delegation' }
199
+ const DAY_SECONDS = 24 * 60 * 60
200
+ const INVITATION_TTL_SECONDS = 7 * DAY_SECONDS
201
+
202
+ function invalid(message: string): MembershipFailure {
203
+ return { ok: false, reason: 'invalid_request', message }
204
+ }
205
+
206
+ const randomId = (): string => crypto.randomUUID().replaceAll('-', '')
207
+ const now = (): number => Math.floor(Date.now() / 1000)
208
+
209
+ /**
210
+ * The caller of a membership operation, as IAM resolves it: another application's connector calls none, a link holder
211
+ * is never a member caller, and `userOnly` refuses a script, which creates no scope and manages no member.
212
+ */
213
+ function memberCaller(callers: DirectoryCallers, input: MembershipCaller, userOnly: boolean): LabelledTestCaller | MembershipFailure {
214
+ if (input.credential === null) return { ok: false, reason: 'missing_token' }
215
+ const caller = callers.byCredential(input.app, input.credential)
216
+ if (caller === undefined || caller.kind === 'app') return { ok: false, reason: 'invalid_token' }
217
+ if (caller.disabled) return { ok: false, reason: 'disabled' }
218
+ if (caller.kind === 'link' || (userOnly && caller.kind !== 'human')) return NOT_ALLOWED
219
+ return caller
220
+ }
221
+
222
+ const isFailure = (outcome: LabelledTestCaller | MembershipFailure): outcome is MembershipFailure => 'ok' in outcome
223
+
224
+ function dimensionOf(artifact: ContractArtifact, type: string) {
225
+ return Object.hasOwn(artifact.dimensions, type) ? artifact.dimensions[type] : undefined
226
+ }
227
+
228
+ /** The membership roles held in exactly `scope`, as IAM counts a member of its dimension. */
229
+ function rolesIn(artifact: ContractArtifact, memberships: readonly TestMembership[], scope: Scope): string[] {
230
+ return memberships
231
+ .filter((held) => held.scope !== null && sameScope(held.scope, scope) && artifact.roles[held.role]?.member?.scope === scope.type)
232
+ .map(({ role }) => role)
233
+ }
234
+
235
+ function membershipsOf(callers: DirectoryCallers, principalId: string): readonly TestMembership[] {
236
+ return callers.all().find(({ caller }) => caller.id === principalId)?.memberships ?? []
237
+ }
238
+
239
+ type PermsOf = (caller: TestCaller) => readonly PermissionEntry[]
240
+
241
+ function listMembers(artifact: ContractArtifact, callers: DirectoryCallers, permsOf: PermsOf, input: ListMembersInput): ListMembersResult {
242
+ const caller = memberCaller(callers, input, false)
243
+ if (isFailure(caller)) return caller
244
+ const perms = [...permsOf(caller)]
245
+ const problem = membershipScopeProblem(input.scope)
246
+ if (problem !== null) return invalid(problem)
247
+ const dimension = dimensionOf(artifact, input.scope.type)
248
+ if (dimension?.members === null || dimension?.members === undefined) {
249
+ return invalid(`scope '${input.scope.type}' declares no members`)
250
+ }
251
+ if (!permits(perms, dimension.members.list, input.scope)) return NOT_ALLOWED
252
+ const manager = permits(perms, dimension.members.manage, input.scope)
253
+ const members: MemberItem[] = []
254
+ for (const { caller: member, memberships } of callers.all()) {
255
+ const roles = rolesIn(artifact, memberships, input.scope)
256
+ if (roles.length === 0) continue
257
+ members.push({
258
+ principalId: member.id,
259
+ label: manager || dimension.directory === 'labels' ? member.label : null,
260
+ email: manager ? member.email : null,
261
+ lastSignInAt: manager ? member.lastSignInAt : null,
262
+ roles,
263
+ disabled: member.disabled,
264
+ affiliation: member.affiliation,
265
+ })
266
+ }
267
+ return { ok: true, members, invitations: [] }
268
+ }
269
+
270
+ /**
271
+ * The application's own people, as IAM answers them: every caller holding a role of the contract, a membership role
272
+ * only in a value of its dimension, paged by id; labels under `directory: 'labels'` or to the application's
273
+ * `members.manage` held globally, which alone sees addresses and last sign-ins.
274
+ */
275
+ function listAppMembers(artifact: ContractArtifact, callers: DirectoryCallers, permsOf: PermsOf, input: ListAppMembersInput): ListAppMembersResult {
276
+ const caller = memberCaller(callers, input, false)
277
+ if (isFailure(caller)) return caller
278
+ const perms = [...permsOf(caller)]
279
+ const people = artifact.members
280
+ if (people === undefined) return invalid('the application declares no members')
281
+ if (!permits(perms, people.list)) return NOT_ALLOWED
282
+ const limit = input.limit ?? MAX_APP_MEMBERS_PAGE
283
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_APP_MEMBERS_PAGE) return invalid(`a page holds 1 to ${MAX_APP_MEMBERS_PAGE} members`)
284
+ const manager = permits(perms, people.manage)
285
+ const members: AppMemberItem[] = []
286
+ for (const { caller: member, memberships } of callers.all()) {
287
+ const roles = memberships.filter(({ role, scope }) => {
288
+ const declared = Object.hasOwn(artifact.roles, role) ? artifact.roles[role] : undefined
289
+ return declared !== undefined && (declared.member === null || declared.member.scope === scope?.type)
290
+ })
291
+ if (roles.length === 0 || member.kind === 'link' || (input.after !== undefined && member.id <= input.after)) continue
292
+ members.push({
293
+ principalId: member.id,
294
+ label: manager || artifact.directory === 'labels' ? member.label : null,
295
+ email: manager ? member.email : null,
296
+ lastSignInAt: manager ? member.lastSignInAt : null,
297
+ roles: roles.map(({ role, scope }) => ({ role, scope: scope === null ? null : { type: scope.type, value: scope.value } })),
298
+ disabled: member.disabled,
299
+ affiliation: member.affiliation,
300
+ })
301
+ }
302
+ members.sort((a, b) => (a.principalId < b.principalId ? -1 : a.principalId > b.principalId ? 1 : 0))
303
+ const page = members.slice(0, limit)
304
+ return { ok: true, members: page, next: members.length > limit ? (page.at(-1)?.principalId ?? null) : null }
305
+ }
306
+
307
+ function findMember(artifact: ContractArtifact, callers: DirectoryCallers, permsOf: PermsOf, input: FindMemberInput): FindMemberResult {
308
+ const caller = memberCaller(callers, input, false)
309
+ if (isFailure(caller)) return caller
310
+ const perms = [...permsOf(caller)]
311
+ const problem = membershipScopeProblem(input.scope)
312
+ if (problem !== null) return invalid(problem)
313
+ const dimension = dimensionOf(artifact, input.scope.type)
314
+ if (dimension?.members === null || dimension?.members === undefined) {
315
+ return invalid(`scope '${input.scope.type}' declares no members`)
316
+ }
317
+ // As IAM: one of the dimension's lookup actions, held on the directory's value itself.
318
+ if (!directoryLookups(artifact, input.scope.type).includes(input.action) || !permits(perms, input.action, input.scope)) {
319
+ return NOT_ALLOWED
320
+ }
321
+ if (input.principalId === '' || input.principalId.length > MAX_MEMBERSHIP_SCOPE_PART_LENGTH) return { ok: true, member: null }
322
+ const found = callers.all().find(({ caller: member, memberships }) =>
323
+ member.id === input.principalId && rolesIn(artifact, memberships, input.scope).length > 0
324
+ )
325
+ if (found === undefined) return { ok: true, member: null }
326
+ const manager = permits(perms, dimension.members.manage, input.scope)
327
+ return {
328
+ ok: true,
329
+ member: {
330
+ principalId: found.caller.id,
331
+ label: manager || dimension.directory === 'labels' ? found.caller.label : null,
332
+ disabled: found.caller.disabled,
333
+ affiliation: found.caller.affiliation,
334
+ },
335
+ }
336
+ }
337
+
338
+ /**
339
+ * The membership roles held after the stand-in's own scope creations, assignments and revocations, over the roles the
340
+ * test gave its callers: what IAM reads live for the assigner's roles, what a revoke removes, whether a value keeps a
341
+ * creator, the directory, and the permissions of a principal's next token.
342
+ */
343
+ function membershipChanges(artifact: ContractArtifact, callers: DirectoryCallers) {
344
+ const key = (principalId: string, role: string, scope: Scope) => JSON.stringify([principalId, role, scope.type, scope.value])
345
+ const added = new Map<string, { readonly principalId: string; readonly role: string; readonly scope: Scope }>()
346
+ const removed = new Set<string>()
347
+ const touched = new Set<string>()
348
+ const rolesOf = (principalId: string, scope: Scope): string[] => {
349
+ const held = [
350
+ ...rolesIn(artifact, membershipsOf(callers, principalId), scope),
351
+ ...[...added.values()].filter((grant) => grant.principalId === principalId && sameScope(grant.scope, scope)).map((grant) => grant.role),
352
+ ]
353
+ return [...new Set(held)].filter((role) => !removed.has(key(principalId, role, scope)))
354
+ }
355
+ /** Every role `principalId` holds now: the test's grants it was not revoked, and the ones the stand-in assigned. */
356
+ const heldBy = (principalId: string, given: readonly TestMembership[]): readonly TestMembership[] => {
357
+ if (!touched.has(principalId)) return given
358
+ const kept = given.filter((held) => held.scope === null || !removed.has(key(principalId, held.role, held.scope)))
359
+ const gained = [...added.entries()].filter(([entry, grant]) =>
360
+ grant.principalId === principalId && !removed.has(entry)
361
+ && !kept.some((held) => held.role === grant.role && held.scope !== null && sameScope(held.scope, grant.scope))
362
+ ).map(([, grant]) => ({ role: grant.role, scope: grant.scope }))
363
+ return [...kept, ...gained]
364
+ }
365
+ const principals = () => [...new Set([...callers.all().map(({ caller }) => caller.id), ...[...added.values()].map((grant) => grant.principalId)])]
366
+ return {
367
+ rolesOf,
368
+ heldBy,
369
+ touched: (principalId: string): boolean => touched.has(principalId),
370
+ othersHold: (principalId: string, role: string, scope: Scope): boolean =>
371
+ principals().some((other) => other !== principalId && rolesOf(other, scope).includes(role)),
372
+ assign(principalId: string, role: string, scope: Scope): void {
373
+ touched.add(principalId)
374
+ removed.delete(key(principalId, role, scope))
375
+ added.set(key(principalId, role, scope), { principalId, role, scope })
376
+ },
377
+ revoke(principalId: string, role: string, scope: Scope): void {
378
+ touched.add(principalId)
379
+ removed.add(key(principalId, role, scope))
380
+ },
381
+ }
382
+ }
383
+
384
+ /** The permissions a role grants in `scope`, as IAM resolves a membership. */
385
+ function roleEntries(artifact: ContractArtifact, held: TestMembership): PermissionEntry[] {
386
+ const scope = held.scope === null ? null : { type: held.scope.type, value: held.scope.value }
387
+ return (artifact.roles[held.role]?.grants ?? []).map((action) => ({ action, scope, source: 'grant' }))
388
+ }
389
+
390
+ const samePermission = (left: PermissionEntry, right: PermissionEntry): boolean =>
391
+ left.action === right.action && left.source === right.source
392
+ && (left.scope === null ? right.scope === null : right.scope !== null && sameScope(left.scope, right.scope))
393
+
394
+ const sameScope = (left: Scope, right: Scope): boolean => left.type === right.type && left.value === right.value
395
+
396
+ interface DelegationGrant {
397
+ readonly principalId: string
398
+ readonly endpoint: string
399
+ readonly requestId: string
400
+ readonly purpose: EndpointDelegation
401
+ /** The dimension the endpoint creates a value of or manages the members of. */
402
+ readonly dimension: string
403
+ readonly action: string
404
+ readonly expiresAt: number
405
+ }
406
+
407
+ /**
408
+ * The directory of the kit's callers, and every write checked against the caller's grants as IAM checks it.
409
+ * `organization` names the mailboxes IAM's configuration would; by default none.
410
+ */
411
+ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCallers, organization = NO_ORGANIZATION): KitMembership {
412
+ const asked: AskedWrite[] = []
413
+ const grants = new Map<string, DelegationGrant>()
414
+ const changes = membershipChanges(artifact, callers)
415
+ const invitations = new Map<string, KitInvitation>()
416
+ const links = new Map<string, KitLink>()
417
+ const live: DirectoryCallers = {
418
+ byCredential: (app, credential) => callers.byCredential(app, credential),
419
+ all: () => callers.all().map(({ caller, memberships }) => ({ caller, memberships: changes.heldBy(caller.id, memberships) })),
420
+ }
421
+
422
+ /** The test's permissions of `caller`, less one entry for each action of a role it lost, plus those of each role it gained. */
423
+ const permsOf = (caller: TestCaller): readonly PermissionEntry[] => {
424
+ if (!changes.touched(caller.id)) return caller.perms
425
+ const given = membershipsOf(callers, caller.id)
426
+ const held = changes.heldBy(caller.id, given)
427
+ const sameMembership = (left: TestMembership) => (right: TestMembership) =>
428
+ left.role === right.role && (left.scope === null ? right.scope === null : right.scope !== null && sameScope(left.scope, right.scope))
429
+ const perms = [...caller.perms]
430
+ for (const lost of given.filter((membership) => !held.some(sameMembership(membership)))) {
431
+ for (const entry of roleEntries(artifact, lost)) {
432
+ const at = perms.findIndex((candidate) => samePermission(candidate, entry))
433
+ if (at >= 0) perms.splice(at, 1)
434
+ }
435
+ }
436
+ const gained = held.filter((membership) => !given.some(sameMembership(membership)))
437
+ return Object.freeze([...perms, ...gained.flatMap((membership) => roleEntries(artifact, membership))])
438
+ }
439
+
440
+ const organizationMailbox = (email: string): boolean => {
441
+ const domain = email.slice(email.lastIndexOf('@') + 1)
442
+ return organization.emails.some((named) => normalizeEmailIdentity(named) === email)
443
+ || organization.domains.some((named) => named.toLowerCase() === domain)
444
+ }
445
+
446
+ /** The open invitation of `role` in `scope` to `email`; as IAM, an expired one is closed first and never answered. */
447
+ const openInvitation = (scope: Scope, role: string, email: string): KitInvitation | undefined =>
448
+ [...invitations.values()].find((invitation) =>
449
+ invitation.closed === null && invitation.expiresAt > now() && sameScope(invitation.scope, scope) && invitation.role === role
450
+ && invitation.email === email
451
+ )
452
+
453
+ const openNew = (scope: Scope, role: string, email: string, inviter: string | null): KitInvitation => {
454
+ const invitation = { id: `invitation-${randomId()}`, scope, role, email, expiresAt: now() + INVITATION_TTL_SECONDS, closed: null, inviter }
455
+ invitations.set(invitation.id, invitation)
456
+ return invitation
457
+ }
458
+
459
+ /** Spends the request's grant before its binding is compared, as IAM does, so each attempt is single use. */
460
+ const spend = (caller: TestCaller, input: { delegation: string; requestId: string }, purpose: EndpointDelegation, dimension: string) => {
461
+ const grant = grants.get(input.delegation)
462
+ grants.delete(input.delegation)
463
+ if (
464
+ grant === undefined || grant.expiresAt <= now() || grant.principalId !== caller.id || grant.requestId !== input.requestId
465
+ || grant.purpose !== purpose || grant.dimension !== dimension
466
+ ) {
467
+ return INVALID_DELEGATION
468
+ }
469
+ return grant
470
+ }
471
+
472
+ /** `members.manage` of the scope's dimension, held there: a value is checked as itself, flat. */
473
+ const manageProblem = (caller: TestCaller, scope: Scope): MembershipFailure | null => {
474
+ const members = dimensionOf(artifact, scope.type)?.members
475
+ if (members === null || members === undefined) return invalid(`scope '${scope.type}' declares no members`)
476
+ return permits([...permsOf(caller)], members.manage, scope) ? null : NOT_ALLOWED
477
+ }
478
+
479
+ /** As IAM (ADR-0051): an external principal holds only a role the contract declares external. */
480
+ const externalProblem = (principal: TestCaller, role: string): MembershipFailure | null => {
481
+ if (principal.affiliation !== 'external' || artifact.roles[role]?.external === true) return null
482
+ return invalid(`an external principal holds only a role its application declares external, and '${role}' is not one`)
483
+ }
484
+
485
+ /**
486
+ * Could `caller` give `role` in `scope`: it holds an assigning role — there, or an application-wide role globally —
487
+ * and every action the role grants there.
488
+ */
489
+ const roleProblem = (caller: TestCaller, scope: Scope, role: string): MembershipFailure | null => {
490
+ const declared = Object.hasOwn(artifact.roles, role) ? artifact.roles[role] : undefined
491
+ if (declared?.member === null || declared?.member === undefined || declared.member.scope !== scope.type) {
492
+ return invalid(`role '${role}' is not a membership role of scope '${scope.type}'`)
493
+ }
494
+ // IAM reads the assigner's live roles: its membership roles there after this stand-in's own changes, and the
495
+ // application-wide roles the test gave it there or globally, which no membership write removes.
496
+ const appWide = membershipsOf(callers, caller.id).filter((held) =>
497
+ artifact.roles[held.role]?.member === null && (held.scope === null || sameScope(held.scope, scope))
498
+ )
499
+ const held = [...changes.rolesOf(caller.id, scope), ...appWide.map((grant) => grant.role)]
500
+ if (!declared.member.assignableBy.some((assigner) => held.includes(assigner))) return NOT_ALLOWED
501
+ const perms = [...permsOf(caller)]
502
+ return declared.grants.every((action) => permits(perms, action, scope)) ? null : NOT_ALLOWED
503
+ }
504
+
505
+ const requestDelegation = (input: RequestDelegationInput): RequestDelegationResult => {
506
+ const caller = memberCaller(callers, input, true)
507
+ if (isFailure(caller)) return caller
508
+ const row = artifact.endpoints.find((candidate) => candidate.id === input.endpoint)
509
+ const purpose = row === undefined ? null : endpointDelegation(artifact, row)
510
+ if (row?.requirement?.kind !== 'action' || purpose === null || row.requirement.access.form === 'app') {
511
+ return invalid(`endpoint '${input.endpoint}' declares no membership operation`)
512
+ }
513
+ const { action, access } = row.requirement
514
+ if (!permsOf(caller).some((entry) => matchAction(entry.action, action))) return NOT_ALLOWED
515
+ const grant = `grant_${randomId()}`
516
+ const expiresAt = now() + DELEGATION_GRANT_TTL_SECONDS
517
+ grants.set(grant, { principalId: caller.id, endpoint: row.id, requestId: input.requestId, purpose, dimension: access.dimension, action, expiresAt })
518
+ return { ok: true, grant, expiresAt }
519
+ }
520
+
521
+ /** A value of a top-level dimension, for a holder of the creating endpoint's action as a global grant. */
522
+ const createScope = (input: CreateScopeInput): CreateScopeResult => {
523
+ const caller = memberCaller(callers, input, true)
524
+ if (isFailure(caller)) return caller
525
+ const spent = spend(caller, input, 'creates', input.dimension)
526
+ if ('ok' in spent) return spent
527
+ const dimension = dimensionOf(artifact, input.dimension)
528
+ if (dimension === undefined || dimension.creator === null || dimension.in !== null) {
529
+ return invalid(`scope '${input.dimension}' is not created by IAM: it needs a creator role and no containing scope`)
530
+ }
531
+ if (!permits([...permsOf(caller)], spent.action)) return NOT_ALLOWED
532
+ const external = externalProblem(caller, dimension.creator)
533
+ if (external !== null) return external
534
+ const scope: Scope = { type: input.dimension, value: crypto.randomUUID() }
535
+ changes.assign(caller.id, dimension.creator, scope)
536
+ asked.push({ operation: 'createScope', principalId: caller.id, scope, role: dimension.creator })
537
+ return { ok: true, scope, role: dimension.creator }
538
+ }
539
+
540
+ const assignMember = (input: AssignMemberInput): AssignMemberResult => {
541
+ const caller = memberCaller(callers, input, true)
542
+ if (isFailure(caller)) return caller
543
+ const spent = spend(caller, input, 'members', input.scope.type)
544
+ if ('ok' in spent) return spent
545
+ const scopeProblem = membershipScopeProblem(input.scope)
546
+ if (scopeProblem !== null) return invalid(scopeProblem)
547
+ const problem = manageProblem(caller, input.scope) ?? roleProblem(caller, input.scope, input.role)
548
+ if (problem !== null) return problem
549
+ if ('principalId' in input.member) {
550
+ if (input.invitation !== undefined) return invalid('a label, a message and a resend go with an invitation by address')
551
+ const { principalId } = input.member
552
+ const target = callers.all().find(({ caller: candidate }) => candidate.id === principalId && candidate.kind === 'human')?.caller
553
+ if (target === undefined) return NOT_FOUND
554
+ const external = externalProblem(target, input.role)
555
+ if (external !== null) return external
556
+ changes.assign(principalId, input.role, input.scope)
557
+ asked.push({ operation: 'assignMember', principalId: caller.id, scope: input.scope, role: input.role, member: input.member, invitationId: null })
558
+ return { ok: true, assigned: { principalId, role: input.role } }
559
+ }
560
+ const email = invitedAddress(input.member.email)
561
+ if (email === null) return invalid('the member email is not an address')
562
+ const wording = invitationOptionsProblem(input.invitation ?? {})
563
+ if (wording !== null) return invalid(wording)
564
+ if (artifact.roles[input.role]?.external === true && organizationMailbox(email)) return { ok: false, reason: 'internal_mailbox' }
565
+ // As IAM: a repeat answers the open invitation and sends nothing; a resend gives it a new link and 7 days.
566
+ const open = openInvitation(input.scope, input.role, email)
567
+ const invitation = open === undefined
568
+ ? openNew(input.scope, input.role, email, caller.id)
569
+ : input.invitation?.resend === true
570
+ ? { ...open, expiresAt: now() + INVITATION_TTL_SECONDS }
571
+ : open
572
+ invitations.set(invitation.id, invitation)
573
+ asked.push({
574
+ operation: 'assignMember',
575
+ principalId: caller.id,
576
+ scope: input.scope,
577
+ role: input.role,
578
+ member: { email },
579
+ invitationId: invitation.id,
580
+ })
581
+ return { ok: true, invited: { invitationId: invitation.id, role: invitation.role, expiresAt: invitation.expiresAt } }
582
+ }
583
+
584
+ /** An invitation this stand-in opened in `scope`, cancelled ones included: IAM knows no other, so any other id is `not_found`. */
585
+ const invitationOf = (id: string, scope: Scope): KitInvitation | undefined => {
586
+ const invitation = invitations.get(id)
587
+ return invitation !== undefined && sameScope(invitation.scope, scope) ? invitation : undefined
588
+ }
589
+
590
+ const revokeMember = (input: RevokeMemberInput): RevokeMemberResult => {
591
+ const caller = memberCaller(callers, input, true)
592
+ if (isFailure(caller)) return caller
593
+ const spent = spend(caller, input, 'members', input.scope.type)
594
+ if ('ok' in spent) return spent
595
+ const scopeProblem = membershipScopeProblem(input.scope)
596
+ if (scopeProblem !== null) return invalid(scopeProblem)
597
+ const manage = manageProblem(caller, input.scope)
598
+ if (manage !== null) return manage
599
+ if ('invitationId' in input.member) {
600
+ const invitation = invitationOf(input.member.invitationId, input.scope)
601
+ if (invitation === undefined) return NOT_FOUND
602
+ const problem = roleProblem(caller, input.scope, invitation.role)
603
+ if (problem !== null) return problem
604
+ asked.push({ operation: 'revokeMember', principalId: caller.id, scope: input.scope, member: input.member })
605
+ invitations.set(invitation.id, { ...invitation, closed: 'cancelled' })
606
+ return { ok: true }
607
+ }
608
+ const only = input.member.role ?? null
609
+ if (only !== null && artifact.roles[only]?.member?.scope !== input.scope.type) {
610
+ return invalid(`role '${only}' is not a membership role of scope '${input.scope.type}'`)
611
+ }
612
+ const roles = changes.rolesOf(input.member.principalId, input.scope).filter((role) => only === null || role === only)
613
+ for (const role of roles) {
614
+ const problem = roleProblem(caller, input.scope, role)
615
+ if (problem !== null) return problem
616
+ }
617
+ asked.push({ operation: 'revokeMember', principalId: caller.id, scope: input.scope, member: input.member })
618
+ // As IAM: every other role goes, and the creator role only while another principal still holds it there.
619
+ const target = input.member.principalId
620
+ const creator = dimensionOf(artifact, input.scope.type)?.creator ?? null
621
+ const lastCreator = creator !== null && roles.includes(creator) && !changes.othersHold(target, creator, input.scope)
622
+ for (const role of roles) {
623
+ if (!lastCreator || role !== creator) changes.revoke(target, role, input.scope)
624
+ }
625
+ return lastCreator ? { ok: false, reason: 'last_creator' } : { ok: true }
626
+ }
627
+
628
+ /** Why `chain` is not the object of the mount's dimension followed by each value containing it, or `null`. */
629
+ const chainProblem = (dimension: string, chain: readonly Scope[]): string | null => {
630
+ if (chain.length === 0 || chain.length > MAX_LINK_CHAIN_LENGTH) return `a link chain names 1 to ${MAX_LINK_CHAIN_LENGTH} values`
631
+ let expected: string | null = dimension
632
+ for (const scope of chain) {
633
+ const problem = membershipScopeProblem(scope)
634
+ if (problem !== null) return problem
635
+ if (scope.type !== expected) return `the link chain names '${scope.type}' where the schema contains '${expected ?? 'nothing'}'`
636
+ expected = dimensionOf(artifact, scope.type)?.in ?? null
637
+ }
638
+ return expected === null ? null : `the link chain stops before '${expected}'`
639
+ }
640
+
641
+ /** A share link, for a holder of the mount's `issuedBy` and of every action it grants on some value of the chain. */
642
+ const issueLink = (input: IssueLinkInput): IssueLinkResult => {
643
+ const caller = memberCaller(callers, input, false)
644
+ if (isFailure(caller)) return caller
645
+ const mount = Object.hasOwn(artifact.mounts, input.mount) ? artifact.mounts[input.mount] : undefined
646
+ if (mount?.kind !== 'link') return invalid(`unknown link mount '${input.mount}'`)
647
+ const problem = chainProblem(mount.dimension, input.chain)
648
+ if (problem !== null) return invalid(problem)
649
+ if (!Number.isSafeInteger(input.days) || input.days < 1 || input.days > mount.maxDays) {
650
+ return invalid(`a link of '${input.mount}' lives 1 to ${mount.maxDays} days`)
651
+ }
652
+ const perms = [...permsOf(caller)]
653
+ const heldOnChain = (action: string): boolean => input.chain.some((scope) => permits(perms, action, scope))
654
+ if (!heldOnChain(mount.issuedBy) || !mount.grants.every(heldOnChain)) return NOT_ALLOWED
655
+ const id = `link-${randomId()}`
656
+ const key = `${API_KEY_PREFIX}link_${randomId()}`
657
+ links.set(id, { id, issuer: caller.id, mount: input.mount, chain: input.chain, key, revoked: false })
658
+ asked.push({ operation: 'issueLink', principalId: caller.id, id, mount: input.mount, chain: input.chain })
659
+ return { ok: true, key, id, expiresAt: now() + input.days * DAY_SECONDS }
660
+ }
661
+
662
+ /** A link this stand-in issued, by its issuer or a holder of `members.manage` on a value of its chain. */
663
+ const revokeLink = (input: RevokeLinkInput): RevokeLinkResult => {
664
+ const caller = memberCaller(callers, input, false)
665
+ if (isFailure(caller)) return caller
666
+ const link = links.get(input.id)
667
+ if (link === undefined) return NOT_FOUND
668
+ const manager = link.chain.some((scope) => {
669
+ const members = dimensionOf(artifact, scope.type)?.members
670
+ return members !== null && members !== undefined && permits([...permsOf(caller)], members.manage, scope)
671
+ })
672
+ if (link.issuer !== caller.id && !manager) return NOT_ALLOWED
673
+ links.set(link.id, { ...link, revoked: true })
674
+ asked.push({ operation: 'revokeLink', principalId: caller.id, id: input.id })
675
+ return { ok: true }
676
+ }
677
+
678
+ const rpc: IamMembershipRpc = {
679
+ listMembers: (input) => Promise.resolve(listMembers(artifact, live, permsOf, input)),
680
+ listAppMembers: (input) => Promise.resolve(listAppMembers(artifact, live, permsOf, input)),
681
+ findMember: (input) => Promise.resolve(findMember(artifact, live, permsOf, input)),
682
+ requestDelegation: (input) => Promise.resolve(requestDelegation(input)),
683
+ createScope: (input) => Promise.resolve(createScope(input)),
684
+ assignMember: (input) => Promise.resolve(assignMember(input)),
685
+ revokeMember: (input) => Promise.resolve(revokeMember(input)),
686
+ issueLink: (input) => Promise.resolve(issueLink(input)),
687
+ revokeLink: (input) => Promise.resolve(revokeLink(input)),
688
+ }
689
+ const seedInvitation = (scope: Scope, role: string, email: string): string => {
690
+ const address = invitedAddress(email)
691
+ if (address === null) throw new Error(`a seeded invitation goes to an address, not '${email}'`)
692
+ return (openInvitation(scope, role, address) ?? openNew(scope, role, address, null)).id
693
+ }
694
+ const seedLink = (issuer: string, mount: string, chain: readonly Scope[]): string => {
695
+ const id = `link-${randomId()}`
696
+ links.set(id, { id, issuer, mount, chain, key: null, revoked: false })
697
+ return id
698
+ }
699
+ const linkOfKey = (key: string): IssuedLinkKey | undefined => {
700
+ const link = [...links.values()].find((candidate) => candidate.key === key)
701
+ return link === undefined || link.revoked ? undefined : { id: link.id, mount: link.mount, chain: link.chain }
702
+ }
703
+ const invitationsSent = (): readonly SentInvitation[] =>
704
+ [...invitations.values()].map(({ id, email, role, scope, expiresAt, closed }) => ({
705
+ id,
706
+ email,
707
+ role,
708
+ scope,
709
+ expiresAt,
710
+ status: closed ?? (expiresAt > now() ? 'open' : 'expired'),
711
+ }))
712
+ const accept = (invitationId: string, recipient?: TestCaller): AcceptedInvitation => {
713
+ const invitation = invitations.get(invitationId)
714
+ if (invitation === undefined || invitation.closed !== null || invitation.expiresAt <= now()) {
715
+ throw new Error(`no open invitation ${invitationId}: the kit's IAM opened none by that id, or it was cancelled, accepted or expired`)
716
+ }
717
+ if (recipient !== undefined && externalProblem(recipient, invitation.role) !== null) {
718
+ throw new Error(`not_eligible: an external person accepts only a role the contract declares external, and '${invitation.role}' is not one`)
719
+ }
720
+ // As IAM: the inviter's authority now, its live roles and permissions, not what it held when it invited.
721
+ const inviter = invitation.inviter === null ? undefined : callers.all().find(({ caller }) => caller.id === invitation.inviter)?.caller
722
+ const authority = inviter === undefined || inviter.disabled
723
+ ? NOT_ALLOWED
724
+ : (manageProblem(inviter, invitation.scope) ?? roleProblem(inviter, invitation.scope, invitation.role))
725
+ if (authority !== null) {
726
+ throw new Error(
727
+ invitation.inviter === null
728
+ ? `not_allowed: ${invitationId} is a seeded invitation, which has no inviter to accept it under`
729
+ : `not_allowed: the inviter can no longer give '${invitation.role}' in that ${invitation.scope.type}`,
730
+ )
731
+ }
732
+ invitations.set(invitation.id, { ...invitation, closed: 'accepted' })
733
+ if (recipient !== undefined) changes.assign(recipient.id, invitation.role, invitation.scope)
734
+ return { scope: invitation.scope, role: invitation.role, email: invitation.email }
735
+ }
736
+ return Object.freeze({
737
+ rpc,
738
+ asked,
739
+ permsOf,
740
+ accept,
741
+ seedInvitation,
742
+ seedLink,
743
+ linkOfKey,
744
+ membershipsOf: (caller: TestCaller) => changes.heldBy(caller.id, membershipsOf(callers, caller.id)),
745
+ invitations: invitationsSent,
746
+ })
747
+ }