@fabrika/app 0.3.3 → 0.4.1

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