@fabrika/app 0.3.2 → 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.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- 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
|
+
}
|