@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,485 @@
1
+ // Scopes, members and share links on the caller's behalf (ADR-0012 §6). The framework asks IAM
2
+ // through `@fabrika/auth`'s `IamMembership`, with the caller's verified token and the delegation grant the proxy
3
+ // obtained for this request; IAM checks the caller's live permissions, generates every value and issues every key.
4
+ // The framework writes no grant and mints no credential.
5
+
6
+ import type { IamMembership, MembershipRequestCaller } from '@fabrika/auth'
7
+ import type {
8
+ EndpointDirectory,
9
+ InvitationItem,
10
+ InvitationOptions,
11
+ IssueLinkResult,
12
+ MemberItem,
13
+ MembershipFailure,
14
+ PrincipalAffiliation,
15
+ Scope,
16
+ } from '@fabrika/auth-core'
17
+ import type { CapabilityAuthority, RefGrant } from '@fabrika/db/capability'
18
+ import { joinPath } from '../contract/defaults'
19
+ import { ConflictError, ForbiddenError, HttpError, NotFoundError, UnauthorizedError } from '../errors'
20
+ import { CapabilityRefusedError, type Ref } from './capability'
21
+ import { ServerFault } from './faults'
22
+
23
+ /** Who a membership is about: an address IAM invites, or a principal it already has. */
24
+ export type MemberTarget = { readonly email: string } | { readonly principalId: string }
25
+
26
+ export type AssignedMember =
27
+ | { readonly assigned: { readonly principalId: string; readonly role: string } }
28
+ | { readonly invited: { readonly invitationId: string; readonly role: string; readonly expiresAt: number } }
29
+
30
+ export interface MemberList {
31
+ readonly members: readonly MemberItem[]
32
+ readonly invitations: readonly InvitationItem[]
33
+ }
34
+
35
+ /** `ctx.members` of an endpoint whose action is its dimension's `members.manage`: one write per request. */
36
+ export interface MemberManagement<D extends string, A extends string, R extends string> {
37
+ /** The members of the value; IAM requires the dimension's `members.list` there. */
38
+ list(ref: Ref<D, A>): Promise<MemberList>
39
+ /**
40
+ * Gives `role` in the value: an existing principal gets the grant, an address an invitation. Repeating it answers the
41
+ * same and sends nothing. For an address, `options` names the company (`label`, at most 200 characters on one line)
42
+ * and a personal `message` (at most 1000 characters of plain text) the e-mail carries, and `resend` gives an open
43
+ * invitation a new link and a new 7 days and sends it again; the old link stops working.
44
+ */
45
+ assign(ref: Ref<D, A>, member: MemberTarget, role: R, options?: InvitationOptions): Promise<AssignedMember>
46
+ /** Removes the member's roles there (only `role` when given), or cancels an invitation. Repeating it answers the same. */
47
+ revoke(ref: Ref<D, A>, member: { readonly principalId: string; readonly role?: R } | { readonly invitationId: string }): Promise<void>
48
+ }
49
+
50
+ /** One member of the target, as a member directory lists it. */
51
+ export interface DirectoryMember<L extends string | null = string | null> {
52
+ readonly principalId: string
53
+ /**
54
+ * The principal's display label (a user's spelling of its address): `string` where the directory's dimension declares
55
+ * `directory: 'labels'`, since IAM then answers it to every caller; elsewhere null unless the caller manages its members.
56
+ */
57
+ readonly label: L
58
+ /** Its membership roles in the target. */
59
+ readonly roles: readonly string[]
60
+ readonly disabled: boolean
61
+ /** `external`: a person from outside the organization (ADR-0051), for a staff UI to show. */
62
+ readonly affiliation: PrincipalAffiliation
63
+ }
64
+
65
+ /** One member a lookup found: never its email, and its label only where the directory shows labels. */
66
+ export interface Member<L extends string | null = string | null> {
67
+ readonly principalId: string
68
+ /** The principal's display label, as `DirectoryMember.label`. */
69
+ readonly label: L
70
+ readonly disabled: boolean
71
+ readonly affiliation: PrincipalAffiliation
72
+ }
73
+
74
+ /**
75
+ * `ctx.members` of an endpoint on or in a dimension below a member directory (its own dimension's, or the nearest one
76
+ * containing it): the members of the value its object lies in. IAM checks the endpoint's own action on that value.
77
+ */
78
+ export interface MemberLookup<L extends string | null = string | null> {
79
+ /** The principal, when it is a member of the directory's value; null otherwise, an unknown id included. */
80
+ get(principalId: string): Promise<Member<L> | null>
81
+ }
82
+
83
+ /**
84
+ * `ctx.members` of an endpoint whose action is the directory's `members.list`: also every member, read from IAM, which
85
+ * checks that action live on the directory's value. No emails and no invitations: those stay with `members.manage`.
86
+ */
87
+ export interface MemberDirectory<L extends string | null = string | null> extends MemberLookup<L> {
88
+ list(): Promise<readonly DirectoryMember<L>[]>
89
+ }
90
+
91
+ /** One role a person holds at the application: globally (`scope: null`), or in one value. */
92
+ export interface ApplicationMemberRole {
93
+ readonly role: string
94
+ readonly scope: Scope | null
95
+ }
96
+
97
+ /** One of the application's own people, as its directory lists them. */
98
+ export interface ApplicationMember {
99
+ readonly principalId: string
100
+ /** The principal's display label; null unless the vocabulary declares `directory: 'labels'` or the caller manages the people. */
101
+ readonly label: string | null
102
+ /** The person's address; null unless the caller holds the application's `members.manage` globally. */
103
+ readonly email: string | null
104
+ /** Unix seconds of the person's last sign-in to the application; null for one who never did, and as `email`. */
105
+ readonly lastSignInAt: number | null
106
+ /** Every role the person holds at the application, a global one first. */
107
+ readonly roles: readonly ApplicationMemberRole[]
108
+ readonly disabled: boolean
109
+ readonly affiliation: PrincipalAffiliation
110
+ }
111
+
112
+ export interface ApplicationMemberPage {
113
+ readonly members: readonly ApplicationMember[]
114
+ /** The `after` of the following page; null on the last one. */
115
+ readonly next: string | null
116
+ }
117
+
118
+ /**
119
+ * `ctx.members` of an `app: true` endpoint whose action is the application's `members.list`: every principal holding a
120
+ * role of the application, globally or in a value, a page of at most 100 at a time. IAM checks the action live.
121
+ */
122
+ export interface ApplicationDirectory {
123
+ list(options?: { readonly after?: string | undefined }): Promise<ApplicationMemberPage>
124
+ }
125
+
126
+ /**
127
+ * The application's own directory, read from IAM with the caller's token. A runtime without an IAM membership transport
128
+ * still boots, and only a call fails.
129
+ */
130
+ export function applicationDirectory(iam: IamMembership | null, caller: MembershipRequestCaller, endpoint: string): ApplicationDirectory {
131
+ return Object.freeze({
132
+ async list(options?: { readonly after?: string | undefined }): Promise<ApplicationMemberPage> {
133
+ if (iam === null) {
134
+ throw new ServerFault(
135
+ 'facility_unbound',
136
+ `'${endpoint}' reads the application's people, and the runtime adapter supplies no IAM membership transport`,
137
+ 503,
138
+ )
139
+ }
140
+ const after = options?.after
141
+ if (after !== undefined && (typeof after !== 'string' || after === '')) {
142
+ throw new HttpError(400, 'invalid_request', "after is the next of a previous page of the application's people")
143
+ }
144
+ const listed = await ask(() => iam.listAppMembers(caller, after), "list the application's people")
145
+ return Object.freeze({
146
+ members: Object.freeze(
147
+ listed.members.map((member) =>
148
+ Object.freeze({
149
+ principalId: member.principalId,
150
+ label: member.label,
151
+ email: member.email,
152
+ lastSignInAt: member.lastSignInAt,
153
+ roles: Object.freeze(member.roles.map((held) => Object.freeze({ role: held.role, scope: held.scope }))),
154
+ disabled: member.disabled,
155
+ affiliation: member.affiliation,
156
+ })
157
+ ),
158
+ ),
159
+ next: listed.next,
160
+ })
161
+ },
162
+ })
163
+ }
164
+
165
+ export interface IssuedLink {
166
+ readonly id: string
167
+ /** `<prefix>/#<key>`: the key travels in the fragment, which never reaches a server or a `Referer`. */
168
+ readonly url: string
169
+ readonly expiresAt: number
170
+ }
171
+
172
+ /** `ctx.links` of a site that declares the link mount it issues. */
173
+ export interface LinkIssuing<D extends string, A extends string, M extends string> {
174
+ issue(ref: Ref<D, A>, options: { readonly mount: M; readonly days: number }): Promise<IssuedLink>
175
+ /**
176
+ * Ends the link `id` (`IssuedLink.id`) at IAM: its issuer or a holder of `members.manage` on its chain may; a job only
177
+ * a link it issued. Repeating it answers the same. A token minted from it still works until it expires, at most 300 s.
178
+ */
179
+ revoke(id: string): Promise<void>
180
+ }
181
+
182
+ /** One request's authority at IAM: the caller's token, the proxy's request id and the delegation grant it obtained. */
183
+ export interface MembershipRequest {
184
+ readonly iam: IamMembership
185
+ readonly caller: MembershipRequestCaller
186
+ readonly delegation: string | null
187
+ /** The endpoint's own access: the dimension a `Ref` passed to IAM must name, and its action. */
188
+ readonly dimension: string
189
+ readonly action: string
190
+ /** The runtime's authority, which the `Ref` passed to IAM must resolve with. */
191
+ readonly capabilities: CapabilityAuthority
192
+ }
193
+
194
+ /** IAM's decided refusal, as the answer the caller gets. A refusal IAM could not decide never arrives here. */
195
+ function refusal(failure: MembershipFailure): Error {
196
+ switch (failure.reason) {
197
+ case 'missing_token':
198
+ case 'invalid_token':
199
+ case 'unknown_principal':
200
+ case 'disabled':
201
+ return new UnauthorizedError('IAM does not accept the caller for this operation')
202
+ case 'not_allowed':
203
+ return new ForbiddenError('The caller may not do this here; IAM checked its live permissions')
204
+ case 'invalid_delegation':
205
+ return new ForbiddenError('IAM refused the delegation of this request; one request makes one membership change')
206
+ case 'not_found':
207
+ return new NotFoundError('Not found')
208
+ case 'last_creator':
209
+ return new ConflictError('The last holder of the creator role stays')
210
+ case 'invitations_disabled':
211
+ return new HttpError(409, 'invitations_disabled', 'This installation sends no email, so it cannot invite an address; assign an existing principal')
212
+ case 'delivery_failed':
213
+ return new ServerFault('iam_unavailable', 'IAM could not deliver the invitation', 503)
214
+ case 'internal_mailbox':
215
+ return new HttpError(
216
+ 409,
217
+ 'internal_mailbox',
218
+ 'The organization names this address, so IAM never creates it as an external person; it signs in through the organization first',
219
+ )
220
+ case 'invalid_request':
221
+ return new HttpError(400, 'invalid_request', failure.message)
222
+ }
223
+ }
224
+
225
+ /** Runs one IAM call; a thrown transport failure is a 503 that names nothing, a decided refusal is the caller's answer. */
226
+ async function ask<T extends { readonly ok: true }>(work: () => Promise<T | MembershipFailure>, operation: string): Promise<T> {
227
+ let result: T | MembershipFailure
228
+ try {
229
+ result = await work()
230
+ } catch {
231
+ throw new ServerFault('iam_unavailable', `IAM could not be asked to ${operation}`, 503)
232
+ }
233
+ if (!result.ok) {
234
+ throw refusal(result)
235
+ }
236
+ return result
237
+ }
238
+
239
+ function delegationOf(request: MembershipRequest): string {
240
+ if (request.delegation === null) {
241
+ throw new ServerFault('delegation_missing', "the request reached a membership endpoint without the proxy's delegation grant", 503)
242
+ }
243
+ return request.delegation
244
+ }
245
+
246
+ /** The grant of a `Ref` this request minted for the endpoint's own dimension and action; anything else is refused. */
247
+ function refGrant(request: MembershipRequest, ref: unknown): RefGrant {
248
+ const grant = request.capabilities.resolve(ref)
249
+ if (grant.kind !== 'ref' || grant.dimension !== request.dimension || grant.action !== request.action) {
250
+ throw new CapabilityRefusedError('wrong_target', `pass the ctx.target of this request: a Ref of '${request.dimension}' for '${request.action}'`)
251
+ }
252
+ return grant
253
+ }
254
+
255
+ function scopeOf(grant: RefGrant): Scope {
256
+ return { type: grant.dimension, value: grant.id }
257
+ }
258
+
259
+ /** `creates`: IAM generates the value and grants the caller the dimension's creator role, atomically. */
260
+ export async function createScope(request: MembershipRequest): Promise<string> {
261
+ const created = await ask(() => request.iam.createScope(request.caller, delegationOf(request), request.dimension), 'create a scope')
262
+ if (created.scope.type !== request.dimension) {
263
+ throw new ServerFault('iam_answer_mismatch', `IAM created a '${created.scope.type}' for a request to create a '${request.dimension}'`)
264
+ }
265
+ return created.scope.value
266
+ }
267
+
268
+ export function memberManagement(request: MembershipRequest): MemberManagement<string, string, string> {
269
+ return Object.freeze({
270
+ async list(ref: unknown): Promise<MemberList> {
271
+ const listed = await ask(() => request.iam.listMembers(request.caller, scopeOf(refGrant(request, ref))), 'list members')
272
+ return Object.freeze({ members: listed.members, invitations: listed.invitations })
273
+ },
274
+ async assign(ref: unknown, member: MemberTarget, role: string, options?: InvitationOptions): Promise<AssignedMember> {
275
+ const scope = scopeOf(refGrant(request, ref))
276
+ const target = 'email' in member ? { email: member.email } : { principalId: member.principalId }
277
+ const assigned = await ask(
278
+ () => request.iam.assignMember(request.caller, delegationOf(request), scope, role, target, invitationOptionsOf(options)),
279
+ 'assign a member',
280
+ )
281
+ return 'assigned' in assigned ? { assigned: assigned.assigned } : { invited: assigned.invited }
282
+ },
283
+ async revoke(ref: unknown, member: { readonly principalId: string; readonly role?: string } | { readonly invitationId: string }): Promise<void> {
284
+ const scope = scopeOf(refGrant(request, ref))
285
+ const target = 'invitationId' in member ? { invitationId: member.invitationId } : { principalId: member.principalId, role: member.role ?? null }
286
+ await ask(() => request.iam.revokeMember(request.caller, delegationOf(request), scope, target), 'revoke a member')
287
+ },
288
+ })
289
+ }
290
+
291
+ /** The invitation options as IAM takes them: only the fields given, each of its own type, so a handler's extra keys stay here. */
292
+ function invitationOptionsOf(options: InvitationOptions | undefined): InvitationOptions | undefined {
293
+ if (options === undefined) return undefined
294
+ return {
295
+ ...(options.label === undefined ? {} : { label: options.label }),
296
+ ...(options.message === undefined ? {} : { message: options.message }),
297
+ ...(options.resend === undefined ? {} : { resend: options.resend }),
298
+ }
299
+ }
300
+
301
+ /** The directory an endpoint reads (`endpointDirectory`), and whether its dimension declares `directory: 'labels'`. */
302
+ export interface ReadDirectory extends EndpointDirectory {
303
+ readonly labels: boolean
304
+ }
305
+
306
+ /** The directory an endpoint reads, with the endpoint a fault names. */
307
+ export interface DirectoryReach extends ReadDirectory {
308
+ readonly endpoint: string
309
+ }
310
+
311
+ /**
312
+ * The member directory of the request's own target, `target`: its dimension's when it declares members, else the
313
+ * nearest container's, at the value the target's chain holds. A runtime without an IAM membership transport still
314
+ * boots, and only a call fails.
315
+ */
316
+ export function memberDirectory(request: MembershipRequest | null, target: unknown, reach: DirectoryReach): MemberLookup | MemberDirectory {
317
+ const directoryOf = (): { readonly request: MembershipRequest; readonly scope: Scope } => {
318
+ if (request === null) {
319
+ throw new ServerFault('facility_unbound', `'${reach.endpoint}' reads members, and the runtime adapter supplies no IAM membership transport`, 503)
320
+ }
321
+ const grant = refGrant(request, target)
322
+ const value = grant.dimension === reach.dimension ? grant.id : grant.chain.get(reach.dimension)
323
+ if (value === undefined) {
324
+ throw new ServerFault(
325
+ 'directory_unlocated',
326
+ `'${reach.endpoint}' reads the members of ${reach.dimension}, and no table locates the ${grant.dimension}'s ${reach.dimension}`,
327
+ )
328
+ }
329
+ return { request, scope: { type: reach.dimension, value } }
330
+ }
331
+ // IAM answers every member's label under a labels directory, and the type promises one: a null there is a fault.
332
+ const labelOf = (label: string | null): string | null => {
333
+ if (reach.labels && label === null) {
334
+ throw new ServerFault('iam_answer_unlabelled', `IAM answered a member of ${reach.dimension} without a label, and its directory shows labels`)
335
+ }
336
+ return label
337
+ }
338
+ const lookup: MemberLookup = {
339
+ async get(principalId: string): Promise<Member | null> {
340
+ const { request, scope } = directoryOf()
341
+ if (typeof principalId !== 'string' || principalId === '') {
342
+ return null
343
+ }
344
+ const found = await ask(() => request.iam.findMember(request.caller, scope, principalId, request.action), 'look up a member')
345
+ if (found.member === null) {
346
+ return null
347
+ }
348
+ if (found.member.principalId !== principalId) {
349
+ throw new ServerFault('iam_answer_mismatch', `IAM answered '${found.member.principalId}' for a lookup of another principal`)
350
+ }
351
+ return Object.freeze({
352
+ principalId: found.member.principalId,
353
+ label: labelOf(found.member.label),
354
+ disabled: found.member.disabled,
355
+ affiliation: found.member.affiliation,
356
+ })
357
+ },
358
+ }
359
+ if (!reach.lists) {
360
+ return Object.freeze(lookup)
361
+ }
362
+ return Object.freeze({
363
+ ...lookup,
364
+ async list(): Promise<readonly DirectoryMember[]> {
365
+ const { request, scope } = directoryOf()
366
+ const listed = await ask(() => request.iam.listMembers(request.caller, scope), 'list members')
367
+ return Object.freeze(
368
+ listed.members.map((member) =>
369
+ Object.freeze({
370
+ principalId: member.principalId,
371
+ label: labelOf(member.label),
372
+ roles: Object.freeze([...member.roles]),
373
+ disabled: member.disabled,
374
+ affiliation: member.affiliation,
375
+ })
376
+ ),
377
+ )
378
+ },
379
+ })
380
+ }
381
+
382
+ /** A link mount an endpoint may issue for: its dimension and issuing action are the endpoint's. */
383
+ export interface IssuableLink {
384
+ readonly name: string
385
+ readonly prefix: string
386
+ }
387
+
388
+ /** The object of `grant` and every value containing it, outward: what IAM binds a link to. */
389
+ function chainOf(grant: RefGrant, containers: readonly string[]): Scope[] {
390
+ const chain: Scope[] = [scopeOf(grant)]
391
+ for (const container of containers) {
392
+ const value = grant.chain.get(container)
393
+ if (value !== undefined) {
394
+ chain.push({ type: container, value })
395
+ }
396
+ }
397
+ return chain
398
+ }
399
+
400
+ function issuedLink(issued: IssueLinkResult & { readonly ok: true }, prefix: string): IssuedLink {
401
+ return Object.freeze({ id: issued.id, url: `${joinPath(prefix, '')}#${issued.key}`, expiresAt: issued.expiresAt })
402
+ }
403
+
404
+ /**
405
+ * `ctx.links`: IAM issues an anonymous key bound to the object's whole containment chain, read from the `Ref`, so a
406
+ * link whose object moves stops working. `containers` is the object's dimension's containers, nearest first.
407
+ */
408
+ export function linkIssuing(
409
+ request: MembershipRequest,
410
+ mounts: readonly IssuableLink[],
411
+ containers: readonly string[],
412
+ ): LinkIssuing<string, string, string> {
413
+ return Object.freeze({
414
+ async issue(ref: unknown, options: { readonly mount: string; readonly days: number }): Promise<IssuedLink> {
415
+ const grant = refGrant(request, ref)
416
+ const mount = mounts.find((candidate) => candidate.name === options.mount)
417
+ if (mount === undefined) {
418
+ throw new ServerFault(
419
+ 'link_mount',
420
+ `'${options.mount}' is not a link mount this endpoint issues (allowed: ${mounts.map((m) => m.name).join(', ')})`,
421
+ )
422
+ }
423
+ const chain = chainOf(grant, containers)
424
+ const issued = await ask(() => request.iam.issueLink(request.caller, mount.name, chain, options.days), 'issue a share link')
425
+ return issuedLink(issued, mount.prefix)
426
+ },
427
+ async revoke(id: string): Promise<void> {
428
+ await ask(() => request.iam.revokeLink(request.caller, id), 'revoke a share link')
429
+ },
430
+ })
431
+ }
432
+
433
+ /** The link mount a cron job declares it `issues`, as the artifact states it. */
434
+ export interface JobLinkMount {
435
+ readonly name: string
436
+ readonly prefix: string
437
+ readonly dimension: string
438
+ readonly issuedBy: string
439
+ }
440
+
441
+ /** A job's authority at IAM: its link mount, and the token IAM signs from its key now. */
442
+ export interface JobLinkRequest {
443
+ readonly iam: IamMembership
444
+ readonly job: string
445
+ readonly mount: JobLinkMount
446
+ /** The mount dimension's containers, nearest first. */
447
+ readonly containers: readonly string[]
448
+ /** The job's current token and this attempt's id; it throws a 503 when IAM signs none. */
449
+ caller(): Promise<MembershipRequestCaller>
450
+ /** Throws once the attempt has ended. It runs synchronously right before each IAM operation, after every await. */
451
+ assertLive(): void
452
+ readonly capabilities: CapabilityAuthority
453
+ }
454
+
455
+ /**
456
+ * `ctx.links` of a job that declares `issues`. IAM admits the job's token on `issueLink` and `revokeLink` only while the
457
+ * approved schema says the job issues this mount, so a job holds no more than its declaration; it revokes only its own.
458
+ */
459
+ export function jobLinkIssuing(request: JobLinkRequest): LinkIssuing<string, string, string> {
460
+ const { mount } = request
461
+ return Object.freeze({
462
+ async issue(ref: unknown, options: { readonly mount: string; readonly days: number }): Promise<IssuedLink> {
463
+ if (options.mount !== mount.name) {
464
+ throw new ServerFault('link_mount', `'${options.mount}' is not the link mount job '${request.job}' issues (issues: '${mount.name}')`)
465
+ }
466
+ const grant = request.capabilities.resolve(ref)
467
+ if (grant.kind !== 'ref' || grant.dimension !== mount.dimension || grant.action !== mount.issuedBy) {
468
+ throw new CapabilityRefusedError(
469
+ 'wrong_target',
470
+ `a link of '${mount.name}' is issued for ctx.ref('${mount.issuedBy}', '${mount.dimension}', id) of this attempt`,
471
+ )
472
+ }
473
+ const chain = chainOf(grant, request.containers)
474
+ const caller = await request.caller()
475
+ request.assertLive()
476
+ const issued = await ask(() => request.iam.issueLink(caller, mount.name, chain, options.days), 'issue a share link')
477
+ return issuedLink(issued, mount.prefix)
478
+ },
479
+ async revoke(id: string): Promise<void> {
480
+ const caller = await request.caller()
481
+ request.assertLive()
482
+ await ask(() => request.iam.revokeLink(caller, id), 'revoke a share link')
483
+ },
484
+ })
485
+ }
@@ -0,0 +1,99 @@
1
+ // Who called: the caller kind a verified token presents, and the principal a handler sees for it.
2
+
3
+ import { type AccessTokenClaims, APP_CALLER_PREFIX, type Caller, callerOf, matchAction, OAUTH_CALLER, type PermissionEntry } from '@fabrika/auth-core'
4
+ import type { PageCaller } from '../client/caller'
5
+
6
+ /** A person: the IAM session, or a `px_` key bound to a user principal. */
7
+ export interface UserPrincipal {
8
+ readonly kind: 'user'
9
+ readonly id: string
10
+ /** What IAM shows for the person: the address as it was spelled. Display it; never mail it. */
11
+ readonly label: string
12
+ /** The mailbox IAM recorded for the person, lower-cased: the one to mail, not proof of possession. Only a person has one. */
13
+ readonly email: string
14
+ }
15
+
16
+ /** A machine: a `px_` key bound to a service principal. */
17
+ export interface ServicePrincipal {
18
+ readonly kind: 'service'
19
+ readonly id: string
20
+ readonly label: string
21
+ }
22
+
23
+ /** The anonymous holder of a share link; the id is the link's credential, not a person. */
24
+ export interface LinkHolder {
25
+ readonly kind: 'link'
26
+ readonly credentialId: string
27
+ }
28
+
29
+ /** Another application, through its connector key. */
30
+ export interface AppPrincipal<Id extends string = string> {
31
+ readonly kind: 'app'
32
+ readonly app: Id
33
+ readonly id: string
34
+ }
35
+
36
+ export type Principal = UserPrincipal | ServicePrincipal | LinkHolder | AppPrincipal
37
+
38
+ export type PrincipalOf<C> = C extends 'human' ? UserPrincipal
39
+ : C extends 'machine' ? ServicePrincipal
40
+ : C extends 'link' ? LinkHolder
41
+ : C extends `app:${infer Id}` ? AppPrincipal<Id>
42
+ : never
43
+
44
+ export interface Presented {
45
+ readonly caller: Caller
46
+ readonly principal: Principal
47
+ }
48
+
49
+ /**
50
+ * Why a verified token presents no principal: it is no caller of an application (`no_caller`), or a person's token
51
+ * from an IAM that does not mint the mailbox (`no_mailbox`), which a handler could not read.
52
+ */
53
+ export type Unpresented = 'no_caller' | 'no_mailbox'
54
+
55
+ /** The caller a verified application token presents, by `callerOf` (the proxy reads the same), and its principal. */
56
+ export function presentedBy(claims: AccessTokenClaims): Presented | Unpresented {
57
+ const caller = callerOf(claims)
58
+ switch (caller) {
59
+ case 'human':
60
+ if (claims.email === undefined) return 'no_mailbox'
61
+ return { caller, principal: { kind: 'user', id: claims.sub, label: claims.label ?? claims.sub, email: claims.email } }
62
+ case 'machine':
63
+ return { caller, principal: { kind: 'service', id: claims.sub, label: claims.label ?? claims.sub } }
64
+ case 'link':
65
+ return { caller, principal: { kind: 'link', credentialId: claims.sub } }
66
+ // An OAuth token reaches only an installation entry, which no application serves.
67
+ case OAUTH_CALLER:
68
+ case null:
69
+ return 'no_caller'
70
+ default:
71
+ return { caller, principal: { kind: 'app', app: caller.slice(APP_CALLER_PREFIX.length), id: claims.sub } }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The caller document of an SPA mount: the person and the contract's actions each of the token's entries covers, a
77
+ * pattern listed as those actions, global entries apart from each scope value's. Built from the token alone.
78
+ */
79
+ export function pageCallerOf(principal: UserPrincipal, perms: readonly PermissionEntry[], actions: readonly string[]): PageCaller {
80
+ const covered = (pattern: string) => actions.filter((action) => matchAction(pattern, action))
81
+ const app = new Set<string>()
82
+ const scoped = new Map<string, { readonly type: string; readonly value: string; readonly actions: Set<string> }>()
83
+ for (const entry of perms) {
84
+ const held = covered(entry.action)
85
+ if (held.length === 0) continue
86
+ if (entry.scope === null) {
87
+ for (const action of held) app.add(action)
88
+ continue
89
+ }
90
+ const key = JSON.stringify([entry.scope.type, entry.scope.value])
91
+ const value = scoped.get(key) ?? { type: entry.scope.type, value: entry.scope.value, actions: new Set<string>() }
92
+ for (const action of held) value.actions.add(action)
93
+ scoped.set(key, value)
94
+ }
95
+ const scopes = [...scoped.entries()]
96
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
97
+ .map(([, value]) => ({ type: value.type, value: value.value, actions: [...value.actions].sort() }))
98
+ return { label: principal.label, email: principal.email, actions: { app: [...app].sort(), scopes } }
99
+ }
@@ -0,0 +1,76 @@
1
+ // Output projection: a handler's return value reduced to the fields its output schema declares. The strict output
2
+ // schema then validates what is left, so an undeclared field is dropped rather than leaked or refused.
3
+
4
+ import { z } from 'zod/v4'
5
+
6
+ /** No branch of a union accepts the value, whatever it is reduced to. */
7
+ export class ProjectionRefusedError extends Error {
8
+ override readonly name = 'ProjectionRefusedError'
9
+ }
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
13
+ }
14
+
15
+ /**
16
+ * `value` with every object reduced to the fields `schema` declares, recursively. A union takes the first branch,
17
+ * in declaration order, whose schema accepts the value reduced to that branch. Anything else is left for
18
+ * validation.
19
+ */
20
+ export function project(schema: z.core.$ZodType, value: unknown): unknown {
21
+ if (schema instanceof z.core.$ZodObject) {
22
+ if (!isRecord(value)) {
23
+ return value
24
+ }
25
+ const declared = Object.entries(schema._zod.def.shape).filter(([name]) => Object.hasOwn(value, name))
26
+ // `fromEntries` defines own properties, so a `__proto__` key never reaches a prototype setter.
27
+ return Object.fromEntries(declared.map(([name, field]) => [name, project(field, value[name])]))
28
+ }
29
+ if (schema instanceof z.core.$ZodArray) {
30
+ const element = schema._zod.def.element
31
+ return Array.isArray(value) ? value.map((item: unknown) => project(element, item)) : value
32
+ }
33
+ if (schema instanceof z.core.$ZodTuple) {
34
+ const { items, rest } = schema._zod.def
35
+ return Array.isArray(value)
36
+ ? value.map((item: unknown, index) => {
37
+ const position = items[index] ?? rest
38
+ return position === null || position === undefined ? item : project(position, item)
39
+ })
40
+ : value
41
+ }
42
+ if (schema instanceof z.core.$ZodRecord) {
43
+ const valueType = schema._zod.def.valueType
44
+ return isRecord(value) ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, project(valueType, item)])) : value
45
+ }
46
+ if (schema instanceof z.core.$ZodOptional || schema instanceof z.core.$ZodDefault) {
47
+ return value === undefined ? value : project(schema._zod.def.innerType, value)
48
+ }
49
+ if (schema instanceof z.core.$ZodNullable) {
50
+ return value === null ? value : project(schema._zod.def.innerType, value)
51
+ }
52
+ if (schema instanceof z.core.$ZodUnion) {
53
+ for (const option of schema._zod.def.options) {
54
+ const candidate = projectOrRefuse(option, value)
55
+ if (candidate.accepted && z.safeParse(option, candidate.value).success) {
56
+ return candidate.value
57
+ }
58
+ }
59
+ throw new ProjectionRefusedError('no branch of the union accepts the value')
60
+ }
61
+ return value
62
+ }
63
+
64
+ function projectOrRefuse(
65
+ schema: z.core.$ZodType,
66
+ value: unknown,
67
+ ): { readonly accepted: true; readonly value: unknown } | { readonly accepted: false } {
68
+ try {
69
+ return { accepted: true, value: project(schema, value) }
70
+ } catch (error) {
71
+ if (error instanceof ProjectionRefusedError) {
72
+ return { accepted: false }
73
+ }
74
+ throw error
75
+ }
76
+ }