@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.
- package/README.md +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -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 +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -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 +331 -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 +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -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 +824 -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 +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -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 +760 -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 +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -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/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -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,1095 @@
|
|
|
1
|
+
// The per-request pipeline of a contract application (ADR-0012 §5): canonical path and endpoint selection, the
|
|
2
|
+
// endpoint the proxy applied (ADR-0022 rule 5), the token, the caller kind, the requirement, admission, strict input, the access form, the handler, output
|
|
3
|
+
// projection. The caller-kind, requirement and access checks are authorization; the same-origin and content-type
|
|
4
|
+
// checks are admission. Neither is the proxy's decision: the proxy has already decided that the request may reach us.
|
|
5
|
+
// The anonymous kinds (health, form, webhook, the share SPA) read no token and take their own fixed steps.
|
|
6
|
+
|
|
7
|
+
import type { IamMembership, MembershipRequestCaller } from '@fabrika/auth'
|
|
8
|
+
import {
|
|
9
|
+
type CompiledEndpointTable,
|
|
10
|
+
type CredentialedEndpoint,
|
|
11
|
+
DELEGATION_HEADER,
|
|
12
|
+
ENDPOINT_HEADER,
|
|
13
|
+
type EndpointPlan,
|
|
14
|
+
type EndpointRow,
|
|
15
|
+
type ExportDescription,
|
|
16
|
+
isAttachmentShape,
|
|
17
|
+
type LinkTokenClaims,
|
|
18
|
+
parseEndpointHeader,
|
|
19
|
+
type PermissionEntry,
|
|
20
|
+
permits,
|
|
21
|
+
planEndpoint,
|
|
22
|
+
PROXY_SECRET_HEADER,
|
|
23
|
+
PROXY_TOKEN_HEADER,
|
|
24
|
+
rawRequestTarget,
|
|
25
|
+
satisfies,
|
|
26
|
+
scopedValues,
|
|
27
|
+
sha256Hex,
|
|
28
|
+
} from '@fabrika/auth-core'
|
|
29
|
+
import { type CapabilityAuthority, type ContainerGrant, MAX_WITHIN_VALUES } from '@fabrika/db/capability'
|
|
30
|
+
import type { DataAccess, FrameworkTables } from '@fabrika/db/framework'
|
|
31
|
+
import type { DataKind } from '@fabrika/db/tables'
|
|
32
|
+
import type { TokenVerifier } from '@fabrika/jwks'
|
|
33
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
34
|
+
import { z } from 'zod/v4'
|
|
35
|
+
import { BROWSER_CONFIG_FILE, joinPath, PAGE_CALLER_FILE, WEBHOOK_BINDING_PARAMETER } from '../contract/defaults'
|
|
36
|
+
import type { Schema } from '../contract/define'
|
|
37
|
+
import {
|
|
38
|
+
BadRequestError,
|
|
39
|
+
ForbiddenError,
|
|
40
|
+
HttpError,
|
|
41
|
+
isStructuralError,
|
|
42
|
+
jsonResponse,
|
|
43
|
+
NotFoundError,
|
|
44
|
+
toErrorResponse,
|
|
45
|
+
UnauthorizedError,
|
|
46
|
+
} from '../errors'
|
|
47
|
+
import type { RequestExecutionContext } from '../fetch'
|
|
48
|
+
import type { FileCollections } from '../files/collections'
|
|
49
|
+
import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
|
|
50
|
+
import { bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
|
|
51
|
+
import { attachmentResponse } from './attachment'
|
|
52
|
+
import { handlerSignal } from './bounded'
|
|
53
|
+
import type { BrowserConfig } from './browser-config'
|
|
54
|
+
import { CapabilityLimitError, type CapabilityMint, RequestFrame, runInRequest } from './capability'
|
|
55
|
+
import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
|
|
56
|
+
import { connectContext, type ImportWire } from './connect'
|
|
57
|
+
import type { FailureReports } from './failures'
|
|
58
|
+
import { ServerFault } from './faults'
|
|
59
|
+
import { ACCEPTED_BUCKET, clientBucket, type SubmissionFields, submissionOf } from './form-admission'
|
|
60
|
+
import { type HeaderPolicy, withHeaderPolicy } from './headers'
|
|
61
|
+
import type { ContractDatabase } from './implement'
|
|
62
|
+
import { endpointData, Invocation, noFileCollections, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
|
|
63
|
+
import {
|
|
64
|
+
applicationDirectory,
|
|
65
|
+
createScope,
|
|
66
|
+
type IssuableLink,
|
|
67
|
+
linkIssuing,
|
|
68
|
+
memberDirectory,
|
|
69
|
+
memberManagement,
|
|
70
|
+
type MembershipRequest,
|
|
71
|
+
type ReadDirectory,
|
|
72
|
+
} from './membership'
|
|
73
|
+
import { pageCallerOf, presentedBy, type Principal } from './principal'
|
|
74
|
+
import { project, ProjectionRefusedError } from './project'
|
|
75
|
+
import { deliveryKey, WEBHOOK_REPLAY_RETENTION_MS, type WebhookPrincipal, webhookSubject } from './webhook'
|
|
76
|
+
import type { WorkloadCredential } from './workload'
|
|
77
|
+
|
|
78
|
+
/** SHA-256 of a proxy secret: 32 bytes whatever the secret's length, as `timingSafeEqual` needs. */
|
|
79
|
+
export function proxySecretDigest(secret: string): Uint8Array {
|
|
80
|
+
return createHash('sha256').update(secret).digest()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Serves the files of an SPA mount. The runtime adapter supplies it: Workers Assets, or the build output on disk. */
|
|
84
|
+
export interface SpaAssets {
|
|
85
|
+
serve(mount: { readonly prefix: string; readonly dir: string }, request: Request): Promise<Response>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a procedure's context carries beyond its access form, decided once from the artifact. */
|
|
89
|
+
export interface Facilities {
|
|
90
|
+
/**
|
|
91
|
+
* `ctx.members`: management where the action is the dimension's `members.manage`; otherwise the member directory of the
|
|
92
|
+
* nearest dimension, the endpoint's own or one containing it, that declares members (`endpointDirectory`); for an
|
|
93
|
+
* `app` endpoint of the application's `members.list`, its own people (`endpointAppDirectory`).
|
|
94
|
+
*/
|
|
95
|
+
readonly members: 'manage' | ReadDirectory | 'application' | null
|
|
96
|
+
/** The link mounts whose dimension and issuing action are the endpoint's. */
|
|
97
|
+
readonly links: readonly IssuableLink[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const NO_FACILITIES: Facilities = Object.freeze({ members: null, links: Object.freeze([]) })
|
|
101
|
+
|
|
102
|
+
/** A staged-kit site: its handler reaches data through `ctx.db`, in the invocation's transaction. */
|
|
103
|
+
export interface ServedSite {
|
|
104
|
+
/** A query and an export read only, in a `READ ONLY` transaction; a mutation also writes. */
|
|
105
|
+
readonly kind: DataKind
|
|
106
|
+
readonly declaration: SiteDeclaration
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ServedEndpoint =
|
|
110
|
+
| {
|
|
111
|
+
readonly kind: 'procedure'
|
|
112
|
+
readonly row: CredentialedEndpoint
|
|
113
|
+
readonly input: Schema | null
|
|
114
|
+
readonly output: Schema
|
|
115
|
+
readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }) => unknown
|
|
116
|
+
readonly facilities: Facilities
|
|
117
|
+
/** An export procedure: its request may only read. */
|
|
118
|
+
readonly readOnly?: true
|
|
119
|
+
/** `ctx.connect`, for an endpoint an import's `usedBy` names. */
|
|
120
|
+
readonly connect?: readonly ImportWire[]
|
|
121
|
+
/** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
|
|
122
|
+
readonly site?: ServedSite
|
|
123
|
+
}
|
|
124
|
+
/** What an export's consumer vendors, behind the export's own callers. */
|
|
125
|
+
| { readonly kind: 'description'; readonly row: CredentialedEndpoint; readonly description: ExportDescription }
|
|
126
|
+
| { readonly kind: 'spa'; readonly row: CredentialedEndpoint; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
|
|
127
|
+
/** An SPA mount's bare prefix (`/admin`): the proxy admitted it under the mount's access; it redirects to `/admin/`. */
|
|
128
|
+
| { readonly kind: 'spa-prefix'; readonly row: EndpointRow; readonly prefix: string }
|
|
129
|
+
/** An SPA anyone opens: anonymous, served without a token. */
|
|
130
|
+
| { readonly kind: 'public-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
|
|
131
|
+
| { readonly kind: 'health'; readonly row: EndpointRow }
|
|
132
|
+
| {
|
|
133
|
+
/** A file collection's upload or download, served by `src/files` once the access form minted its target. */
|
|
134
|
+
readonly kind: 'file'
|
|
135
|
+
readonly row: CredentialedEndpoint
|
|
136
|
+
readonly serve: (request: Request, target: unknown) => Promise<Response | FileDownload>
|
|
137
|
+
/**
|
|
138
|
+
* A link holder's download: the `file:<name>` dimension whose `:fileId` must lie beneath the link's object. Its
|
|
139
|
+
* row is `on` the owner's dimension, the link's object; the file is resolved under it.
|
|
140
|
+
*/
|
|
141
|
+
readonly beneath?: string
|
|
142
|
+
}
|
|
143
|
+
/** The shell of a link mount's share SPA: anonymous, and served with the fragment bootstrap. */
|
|
144
|
+
| { readonly kind: 'link-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string }
|
|
145
|
+
| {
|
|
146
|
+
readonly kind: 'form'
|
|
147
|
+
readonly row: EndpointRow
|
|
148
|
+
readonly form: string
|
|
149
|
+
readonly input: Schema
|
|
150
|
+
readonly admission: FormAdmission
|
|
151
|
+
/** The job each accepted submission kicks once its row committed; `null` without `then`. */
|
|
152
|
+
readonly then: string | null
|
|
153
|
+
}
|
|
154
|
+
| {
|
|
155
|
+
readonly kind: 'webhook'
|
|
156
|
+
readonly row: EndpointRow
|
|
157
|
+
readonly webhook: string
|
|
158
|
+
readonly dimension: string
|
|
159
|
+
readonly actions: readonly string[]
|
|
160
|
+
readonly input: Schema
|
|
161
|
+
readonly handler: (args: { readonly input: unknown; readonly ctx: object }) => unknown
|
|
162
|
+
/** The webhook's key: its refs carry only what IAM signs for it. */
|
|
163
|
+
readonly credential: WorkloadCredential
|
|
164
|
+
/** A staged-kit webhook's declaration: its delivery runs in one transaction, through `ctx.db`. */
|
|
165
|
+
readonly site?: SiteDeclaration
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What a form admits, decided once at boot from the approved artifact and the application's variables. */
|
|
169
|
+
export interface FormAdmission {
|
|
170
|
+
/** Requests per minute from one client, admitted before the body is read. */
|
|
171
|
+
readonly perClient: number
|
|
172
|
+
/** Submissions per minute the form accepts in all: only one that passed its honeypot and its challenge counts. */
|
|
173
|
+
readonly perForm: number
|
|
174
|
+
readonly fields: SubmissionFields
|
|
175
|
+
/** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
|
|
176
|
+
readonly challenge: ChallengeVerifier | null
|
|
177
|
+
/** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's; `null` otherwise. */
|
|
178
|
+
readonly repeatableToken: string | null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface RuntimeParts {
|
|
182
|
+
readonly table: CompiledEndpointTable
|
|
183
|
+
readonly endpoints: ReadonlyMap<string, ServedEndpoint>
|
|
184
|
+
/** Endpoint id → `requirementDigest` of its row: what the proxy's `X-Fabrika-Endpoint` must name. */
|
|
185
|
+
readonly digests: ReadonlyMap<string, string>
|
|
186
|
+
readonly verifier: TokenVerifier
|
|
187
|
+
/** The token audience: this application's IAM id in this environment, `<app>@<env>`. */
|
|
188
|
+
readonly audience: string
|
|
189
|
+
/** `proxySecretDigest` of the proxy secret every request must carry; `null` where the hop is a service binding. */
|
|
190
|
+
readonly proxySecretDigest: Uint8Array | null
|
|
191
|
+
readonly policy: HeaderPolicy
|
|
192
|
+
readonly database: ContractDatabase | null
|
|
193
|
+
/** Dimensions a table owns, so `in` and `on` must find the object. */
|
|
194
|
+
readonly ownedDimensions: ReadonlySet<string>
|
|
195
|
+
/** Each dimension's containers, nearest first; a grant on one reaches every value inside it. */
|
|
196
|
+
readonly containers: ReadonlyMap<string, readonly string[]>
|
|
197
|
+
readonly assets: SpaAssets | null
|
|
198
|
+
/** IAM's membership operations; `boot` refuses a contract that needs them when the adapter supplies none. */
|
|
199
|
+
readonly membership: IamMembership | null
|
|
200
|
+
/** The framework's own writes (inboxes, admissions, webhook tables); `boot` refuses a form or webhook without them. */
|
|
201
|
+
readonly framework: FrameworkTables | null
|
|
202
|
+
/** This runtime's capability mint, which `boot` alone holds, and the authority its stores are bound to. */
|
|
203
|
+
readonly mint: CapabilityMint
|
|
204
|
+
readonly capabilities: CapabilityAuthority
|
|
205
|
+
/** The mini-ORM of the bound `scoped(tables)` layer; `boot` refuses a site without one. */
|
|
206
|
+
readonly data: DataAccess | null
|
|
207
|
+
/** The contract's file collections, behind a staged-kit site's `ctx.files`; `null` when it declares none. */
|
|
208
|
+
readonly files: FileCollections | null
|
|
209
|
+
/** Where a 5xx goes: the server log, and Operations when the application has a DSN. */
|
|
210
|
+
readonly failures: FailureReports
|
|
211
|
+
/** Kicks a contract job under its own key, in the background (`BoundJobs.kick`); it never rejects. */
|
|
212
|
+
readonly kick: (job: string) => Promise<void>
|
|
213
|
+
/** The contract's actions, sorted: what a caller document lists a pattern grant as. */
|
|
214
|
+
readonly actions: readonly string[]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface Caller {
|
|
218
|
+
readonly principal: Principal
|
|
219
|
+
/** The principal id a capability is granted to: the token's subject. */
|
|
220
|
+
readonly subject: string
|
|
221
|
+
readonly perms: PermissionEntry[]
|
|
222
|
+
/** The verified token, which IAM resolves the caller from on a membership request. */
|
|
223
|
+
readonly token: string
|
|
224
|
+
/** The mount and chain a share-link token is bound to. */
|
|
225
|
+
readonly link: LinkTokenClaims | null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The proxy's id of this request (proxy-core's `REQUEST_ID_HEADER`); a delegation grant is bound to it. */
|
|
229
|
+
const REQUEST_ID_HEADER = 'X-Request-Id'
|
|
230
|
+
/** A binding id is a UUID; anything else names no binding and reaches no query. */
|
|
231
|
+
const BINDING_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
232
|
+
|
|
233
|
+
interface Access {
|
|
234
|
+
readonly context: Readonly<Record<string, unknown>>
|
|
235
|
+
readonly input: Readonly<Record<string, unknown>>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
239
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The media type of a `Content-Type` value, without parameters, lower-cased. */
|
|
243
|
+
function mediaType(header: string | null): string | null {
|
|
244
|
+
const type = header?.split(';')[0]?.trim().toLowerCase()
|
|
245
|
+
return type === undefined || type === '' ? null : type
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function describeRequirement(row: CredentialedEndpoint): string {
|
|
249
|
+
return row.requirement.kind === 'member' ? 'a permission in this application' : `'${row.requirement.action}' in some scope`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class ContractRuntime {
|
|
253
|
+
readonly #parts: RuntimeParts
|
|
254
|
+
|
|
255
|
+
constructor(parts: RuntimeParts) {
|
|
256
|
+
this.#parts = parts
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Every answer of a download endpoint, a denial included, carries the file treatment; every other one the policy.
|
|
261
|
+
* `exec` keeps a failure's report alive past the response, where the runtime would otherwise end it.
|
|
262
|
+
*/
|
|
263
|
+
async fetch(request: Request, exec?: RequestExecutionContext): Promise<Response> {
|
|
264
|
+
const plan = planEndpoint(this.#parts.table, request.method, rawRequestTarget(request.url).pathname)
|
|
265
|
+
const answer = await this.#answer(request, plan, exec)
|
|
266
|
+
if (answer instanceof FileDownload) {
|
|
267
|
+
return fileResponse(answer)
|
|
268
|
+
}
|
|
269
|
+
return plan.outcome === 'endpoint' && plan.endpoint.kind === 'download' ? withFileTreatment(answer) : withHeaderPolicy(answer, this.#parts.policy)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #answer(request: Request, plan: EndpointPlan, exec: RequestExecutionContext | undefined): Promise<Response | FileDownload> {
|
|
273
|
+
let where = 'request'
|
|
274
|
+
try {
|
|
275
|
+
const served = plan.outcome === 'endpoint' ? this.#parts.endpoints.get(plan.endpoint.id) : undefined
|
|
276
|
+
// Before any other answer: a direct dial learns nothing, not even whether its path exists.
|
|
277
|
+
if (served?.kind !== 'health' && !this.#throughProxy(request)) {
|
|
278
|
+
throw new ForbiddenError('This application answers only its proxy')
|
|
279
|
+
}
|
|
280
|
+
if (plan.outcome === 'non-canonical') {
|
|
281
|
+
throw new BadRequestError('The request path is not canonical')
|
|
282
|
+
}
|
|
283
|
+
if (plan.outcome !== 'endpoint' || served === undefined) {
|
|
284
|
+
throw new NotFoundError(`No endpoint serves ${request.method} ${rawRequestTarget(request.url).pathname}`)
|
|
285
|
+
}
|
|
286
|
+
where = served.row.id
|
|
287
|
+
this.#checkEndpoint(served, request)
|
|
288
|
+
switch (served.kind) {
|
|
289
|
+
case 'health':
|
|
290
|
+
return jsonResponse({ status: 'ok' }, 200)
|
|
291
|
+
case 'spa': {
|
|
292
|
+
const caller = await this.#authorize(served.row, request)
|
|
293
|
+
return await this.#spaFile(served, request, caller)
|
|
294
|
+
}
|
|
295
|
+
case 'public-spa':
|
|
296
|
+
return await this.#spaFile(served, request, null)
|
|
297
|
+
case 'spa-prefix':
|
|
298
|
+
return new Response(null, { status: 308, headers: { Location: `${served.prefix}/${new URL(request.url).search}` } })
|
|
299
|
+
case 'link-spa':
|
|
300
|
+
if (rawRequestTarget(request.url).pathname === bootstrapPath(served.prefix)) {
|
|
301
|
+
return bootstrapResponse()
|
|
302
|
+
}
|
|
303
|
+
return await withBootstrap(await this.#assets().serve({ prefix: served.prefix, dir: served.dir }, request), served.prefix)
|
|
304
|
+
case 'procedure':
|
|
305
|
+
return await this.#procedure(served, request)
|
|
306
|
+
case 'file':
|
|
307
|
+
return await this.#file(served, request, plan.params)
|
|
308
|
+
case 'form':
|
|
309
|
+
return await this.#form(served, request, exec)
|
|
310
|
+
case 'webhook':
|
|
311
|
+
return await this.#webhook(served, request, plan.params)
|
|
312
|
+
case 'description':
|
|
313
|
+
await this.#authorize(served.row, request)
|
|
314
|
+
await readInput({ row: served.row, input: null }, request)
|
|
315
|
+
return jsonResponse({ result: served.description }, 200)
|
|
316
|
+
}
|
|
317
|
+
} catch (error) {
|
|
318
|
+
const response = errorResponse(error, where)
|
|
319
|
+
if (response.status >= 500) {
|
|
320
|
+
const failure = { endpoint: where, requestId: request.headers.get(REQUEST_ID_HEADER), status: response.status, error }
|
|
321
|
+
const delivery = this.#parts.failures.record(failure)
|
|
322
|
+
exec?.waitUntil(delivery)
|
|
323
|
+
}
|
|
324
|
+
return response
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* An SPA mount's files, at `<prefix>/__fabrika-config.json` its browser config (`browser-config.ts`), and at
|
|
330
|
+
* `<prefix>/__fabrika-me.json` the caller document of the person the mount admitted. A public mount admits no one:
|
|
331
|
+
* the proxy forwards no token there, so it cannot say whether the visitor is signed in.
|
|
332
|
+
*/
|
|
333
|
+
async #spaFile(
|
|
334
|
+
served: { readonly prefix: string; readonly dir: string; readonly config: BrowserConfig },
|
|
335
|
+
request: Request,
|
|
336
|
+
caller: Caller | null,
|
|
337
|
+
): Promise<Response> {
|
|
338
|
+
const path = rawRequestTarget(request.url).pathname
|
|
339
|
+
if (path === joinPath(served.prefix, BROWSER_CONFIG_FILE)) {
|
|
340
|
+
return jsonResponse(served.config, 200)
|
|
341
|
+
}
|
|
342
|
+
if (path === joinPath(served.prefix, PAGE_CALLER_FILE)) {
|
|
343
|
+
if (caller === null || caller.principal.kind !== 'user') {
|
|
344
|
+
throw new UnauthorizedError('A public page carries no caller')
|
|
345
|
+
}
|
|
346
|
+
return jsonResponse(pageCallerOf(caller.principal, caller.perms, this.#parts.actions), 200)
|
|
347
|
+
}
|
|
348
|
+
return await this.#assets().serve({ prefix: served.prefix, dir: served.dir }, request)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The proxy proves it sent the request with the secret of this application environment (ADR-0022 rule 5). The
|
|
353
|
+
* fixed-size digests are compared in constant time, so neither the length nor a prefix of the secret leaks.
|
|
354
|
+
*/
|
|
355
|
+
#throughProxy(request: Request): boolean {
|
|
356
|
+
const expected = this.#parts.proxySecretDigest
|
|
357
|
+
if (expected === null) {
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
const presented = request.headers.get(PROXY_SECRET_HEADER) ?? ''
|
|
361
|
+
return presented !== '' && timingSafeEqual(proxySecretDigest(presented), expected)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The proxy names the endpoint it selected and the digest of the rules it applied. Any disagreement with this
|
|
366
|
+
* version's own selection or rules is a 503, so no request is served under rules the proxy did not apply. A
|
|
367
|
+
* health endpoint answers a constant and is what the platform's liveness probe dials directly, so it alone may
|
|
368
|
+
* arrive without the header.
|
|
369
|
+
*/
|
|
370
|
+
#checkEndpoint(served: ServedEndpoint, request: Request): void {
|
|
371
|
+
const header = request.headers.get(ENDPOINT_HEADER)
|
|
372
|
+
if (header === null && served.kind === 'health') {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
const named = header === null ? null : parseEndpointHeader(header)
|
|
376
|
+
if (named === null) {
|
|
377
|
+
throw new ServerFault('endpoint_decision_missing', `'${served.row.id}' was reached without the proxy's endpoint decision`, 503)
|
|
378
|
+
}
|
|
379
|
+
if (named.id !== served.row.id || named.digest !== this.#parts.digests.get(served.row.id)) {
|
|
380
|
+
throw new ServerFault(
|
|
381
|
+
'endpoint_decision_mismatch',
|
|
382
|
+
`the proxy applied '${named.id}' with other rules than this version serves '${served.row.id}' with`,
|
|
383
|
+
503,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#assets(): SpaAssets {
|
|
389
|
+
const assets = this.#parts.assets
|
|
390
|
+
if (assets === null) {
|
|
391
|
+
throw new ServerFault('facility_unbound', 'no asset server serves the SPA')
|
|
392
|
+
}
|
|
393
|
+
return assets
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Token, caller kind, requirement, then admission. */
|
|
397
|
+
async #authorize(row: CredentialedEndpoint, request: Request): Promise<Caller> {
|
|
398
|
+
const token = request.headers.get(PROXY_TOKEN_HEADER)?.trim() ?? ''
|
|
399
|
+
if (token === '') {
|
|
400
|
+
throw new UnauthorizedError('This endpoint needs a credential, and the request carries none')
|
|
401
|
+
}
|
|
402
|
+
const verified = await this.#parts.verifier.verify(token, this.#parts.audience)
|
|
403
|
+
if (!verified.ok) {
|
|
404
|
+
if (verified.reason === 'unavailable') {
|
|
405
|
+
throw new ServerFault('key_set_unavailable', 'the IAM key set could not be fetched; nothing was checked', 503)
|
|
406
|
+
}
|
|
407
|
+
throw new UnauthorizedError('The credential does not verify')
|
|
408
|
+
}
|
|
409
|
+
const presented = presentedBy(verified.claims)
|
|
410
|
+
if (presented === 'no_mailbox') {
|
|
411
|
+
throw new UnauthorizedError('The credential names a person without an address')
|
|
412
|
+
}
|
|
413
|
+
if (presented === 'no_caller' || !row.callers.includes(presented.caller)) {
|
|
414
|
+
const caller = presented === 'no_caller' ? 'resource' : presented.caller
|
|
415
|
+
throw new ForbiddenError(`A ${caller} caller cannot call '${row.id}' (callers: ${row.callers.join(', ')})`)
|
|
416
|
+
}
|
|
417
|
+
if (!satisfies(row.requirement, verified.claims.perms)) {
|
|
418
|
+
throw new ForbiddenError(`'${row.id}' needs ${describeRequirement(row)}, and the caller holds none`)
|
|
419
|
+
}
|
|
420
|
+
admit(row, request)
|
|
421
|
+
return {
|
|
422
|
+
principal: presented.principal,
|
|
423
|
+
subject: verified.claims.sub,
|
|
424
|
+
perms: verified.claims.perms,
|
|
425
|
+
token,
|
|
426
|
+
link: verified.claims.link ?? null,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* A staged-kit site runs in one transaction: its statements commit after the handler returned, the output passed
|
|
432
|
+
* validation and an attachment was encoded, and roll back on a throw, a refused output, a file over its limit or a
|
|
433
|
+
* cancelled request. So an audited export keeps its log row only when its file goes out.
|
|
434
|
+
*/
|
|
435
|
+
async #procedure(served: Extract<ServedEndpoint, { kind: 'procedure' }>, request: Request): Promise<Response> {
|
|
436
|
+
const caller = await this.#authorize(served.row, request)
|
|
437
|
+
const input = await readInput(served, request)
|
|
438
|
+
const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.readOnly === true, endpoint: served.row.id })
|
|
439
|
+
const site = served.site
|
|
440
|
+
const invocation = site === undefined ? null : new Invocation(frame, this.#data(), site.kind === 'query' || served.readOnly === true)
|
|
441
|
+
const signal = handlerSignal(request)
|
|
442
|
+
try {
|
|
443
|
+
let result: unknown
|
|
444
|
+
try {
|
|
445
|
+
result = await runInRequest(frame, async () => {
|
|
446
|
+
const membership = this.#membershipRequest(served.row, caller, request, frame)
|
|
447
|
+
const access = await this.#access(served.row, caller, input, frame, membership)
|
|
448
|
+
const ctx = Object.freeze({
|
|
449
|
+
principal: caller.principal,
|
|
450
|
+
requestId: frame.id,
|
|
451
|
+
signal,
|
|
452
|
+
...access.context,
|
|
453
|
+
...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
|
|
454
|
+
credential: caller.token,
|
|
455
|
+
requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
|
|
456
|
+
}),
|
|
457
|
+
...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
|
|
458
|
+
...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context)),
|
|
459
|
+
// An export's consumer reaches its rows only, never the files of their owners.
|
|
460
|
+
...(site === undefined || invocation === null || served.readOnly === true
|
|
461
|
+
? {}
|
|
462
|
+
: { files: this.#files(served.row.id, frame, invocation, site.kind) }),
|
|
463
|
+
})
|
|
464
|
+
return await served.handler({ input: access.input, ctx })
|
|
465
|
+
})
|
|
466
|
+
} finally {
|
|
467
|
+
frame.end()
|
|
468
|
+
}
|
|
469
|
+
const answer = this.#answerOf(served, result)
|
|
470
|
+
await invocation?.commit(signal)
|
|
471
|
+
return answer
|
|
472
|
+
} finally {
|
|
473
|
+
await invocation?.rollback()
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The output as JSON, projected and validated whole; or an attachment's file, whose rows are projected, validated
|
|
479
|
+
* and encoded one at a time, so a result over a limit stops before it is copied.
|
|
480
|
+
*/
|
|
481
|
+
#answerOf(served: Extract<ServedEndpoint, { kind: 'procedure' }>, result: unknown): Response {
|
|
482
|
+
const file = served.row.output !== null && isAttachmentShape(served.row.output) ? served.row.output : null
|
|
483
|
+
if (file !== null) {
|
|
484
|
+
return attachmentResponse(served.row.id, file, served.output, result)
|
|
485
|
+
}
|
|
486
|
+
const output = z.safeParse(served.output, projectOutput(served, result))
|
|
487
|
+
if (!output.success) {
|
|
488
|
+
throw new ServerFault('output_refused', `'${served.row.id}' returned a value its output schema refuses`)
|
|
489
|
+
}
|
|
490
|
+
return jsonResponse({ result: output.data }, 200)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `ctx.files` of a staged-kit site, in its invocation's transaction. */
|
|
494
|
+
#files(endpoint: string, frame: RequestFrame, invocation: Invocation, kind: DataKind): object {
|
|
495
|
+
const where = `'${endpoint}'`
|
|
496
|
+
return this.#parts.files === null ? noFileCollections(where) : this.#parts.files.site({ frame, ...invocation.fileSite(where, kind) })
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#data(): DataAccess {
|
|
500
|
+
const data = this.#parts.data
|
|
501
|
+
if (data === null) {
|
|
502
|
+
throw new ServerFault('facility_unbound', 'a site reaches data through ctx.db, and boot bound no @fabrika/db layer')
|
|
503
|
+
}
|
|
504
|
+
return data
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The access form reads its value from the path parameter it names: `:owner` of an upload, `:fileId` of a download.
|
|
509
|
+
* A link holder's row names none: its owner is the link's object, checked as a link procedure's is.
|
|
510
|
+
*/
|
|
511
|
+
async #file(
|
|
512
|
+
served: Extract<ServedEndpoint, { kind: 'file' }>,
|
|
513
|
+
request: Request,
|
|
514
|
+
params: Readonly<Record<string, string>>,
|
|
515
|
+
): Promise<Response | FileDownload> {
|
|
516
|
+
const caller = await this.#authorize(served.row, request)
|
|
517
|
+
const access = served.row.requirement.kind === 'action' ? served.row.requirement.access : null
|
|
518
|
+
if (access?.form === 'on' && access.from === null && served.row.requirement.kind === 'action') {
|
|
519
|
+
return await this.#linkFile(served, request, params, caller, served.row.requirement.action, access.dimension)
|
|
520
|
+
}
|
|
521
|
+
const from = access?.form === 'on' ? access.from : null
|
|
522
|
+
const raw = from === null ? undefined : params[from]
|
|
523
|
+
if (from === null || raw === undefined) {
|
|
524
|
+
throw new ServerFault('contract_inconsistent', `'${served.row.id}' names no path parameter for its access form`)
|
|
525
|
+
}
|
|
526
|
+
const frame = new RequestFrame(crypto.randomUUID())
|
|
527
|
+
try {
|
|
528
|
+
return await runInRequest(frame, async () => {
|
|
529
|
+
const { context } = await this.#access(served.row, caller, { [from]: decodePathParameter(raw) }, frame, null)
|
|
530
|
+
return await served.serve(request, context['target'])
|
|
531
|
+
})
|
|
532
|
+
} finally {
|
|
533
|
+
frame.end()
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* A link holder's upload or download. The link's mount and its chain are checked as `#linkTarget` checks them; a
|
|
539
|
+
* download then resolves `:fileId` beneath that object, and a file of any other object is indistinguishable from a
|
|
540
|
+
* missing one.
|
|
541
|
+
*/
|
|
542
|
+
async #linkFile(
|
|
543
|
+
served: Extract<ServedEndpoint, { kind: 'file' }>,
|
|
544
|
+
request: Request,
|
|
545
|
+
params: Readonly<Record<string, string>>,
|
|
546
|
+
caller: Caller,
|
|
547
|
+
action: string,
|
|
548
|
+
dimension: string,
|
|
549
|
+
): Promise<Response | FileDownload> {
|
|
550
|
+
const frame = new RequestFrame(crypto.randomUUID())
|
|
551
|
+
try {
|
|
552
|
+
return await runInRequest(frame, async () => {
|
|
553
|
+
const owner = await this.#linkTarget(served.row, caller, action, dimension, {}, frame)
|
|
554
|
+
const beneath = served.beneath
|
|
555
|
+
if (beneath === undefined) {
|
|
556
|
+
return await served.serve(request, owner.context['target'])
|
|
557
|
+
}
|
|
558
|
+
const raw = params['fileId']
|
|
559
|
+
const fileId = raw === undefined ? '' : decodePathParameter(raw)
|
|
560
|
+
const [object, ...containers] = caller.link?.chain ?? []
|
|
561
|
+
const chain = fileId === '' ? null : await this.#chain(beneath, fileId)
|
|
562
|
+
const ownedHere = chain !== null && object !== undefined && chain.get(dimension) === object.value
|
|
563
|
+
&& containers.every((scope) => chain.get(scope.type) === scope.value)
|
|
564
|
+
if (chain === null || object === undefined || !ownedHere) {
|
|
565
|
+
throw new NotFoundError('Not found')
|
|
566
|
+
}
|
|
567
|
+
return await served.serve(request, this.#parts.mint.ref(frame, beneath, action, caller.subject, fileId, chain))
|
|
568
|
+
})
|
|
569
|
+
} finally {
|
|
570
|
+
frame.end()
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The caller's authority at IAM for this request, when the endpoint's access form names a dimension and an action. */
|
|
575
|
+
#membershipRequest(row: CredentialedEndpoint, caller: Caller, request: Request, frame: RequestFrame): MembershipRequest | null {
|
|
576
|
+
const iam = this.#parts.membership
|
|
577
|
+
if (iam === null || row.requirement.kind !== 'action' || row.requirement.access.form === 'app') {
|
|
578
|
+
return null
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
iam,
|
|
582
|
+
caller: { credential: caller.token, requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id },
|
|
583
|
+
delegation: request.headers.get(DELEGATION_HEADER),
|
|
584
|
+
dimension: row.requirement.access.dimension,
|
|
585
|
+
action: row.requirement.action,
|
|
586
|
+
capabilities: this.#parts.capabilities,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** `ctx.members` and `ctx.links`, for exactly the endpoints the artifact gives them. */
|
|
591
|
+
#facilities(
|
|
592
|
+
facilities: Facilities,
|
|
593
|
+
row: CredentialedEndpoint,
|
|
594
|
+
membership: MembershipRequest | null,
|
|
595
|
+
target: unknown,
|
|
596
|
+
caller: MembershipRequestCaller,
|
|
597
|
+
): Readonly<Record<string, unknown>> {
|
|
598
|
+
if (facilities.members === 'application') {
|
|
599
|
+
return { members: applicationDirectory(this.#parts.membership, caller, row.id) }
|
|
600
|
+
}
|
|
601
|
+
const reads = facilities.members === null || facilities.members === 'manage' || row.requirement.kind !== 'action' ? null : facilities.members
|
|
602
|
+
const directory = reads === null ? {} : { members: memberDirectory(membership, target, { endpoint: row.id, ...reads }) }
|
|
603
|
+
if (facilities.members !== 'manage' && facilities.links.length === 0) {
|
|
604
|
+
return directory
|
|
605
|
+
}
|
|
606
|
+
if (membership === null) {
|
|
607
|
+
throw new ServerFault('facility_unbound', `'${row.id}' manages members or links, and boot bound no IAM membership transport`)
|
|
608
|
+
}
|
|
609
|
+
const members = facilities.members === 'manage' ? { members: memberManagement(membership) } : directory
|
|
610
|
+
const links = facilities.links.length === 0
|
|
611
|
+
? {}
|
|
612
|
+
: { links: linkIssuing(membership, facilities.links, this.#parts.containers.get(membership.dimension) ?? []) }
|
|
613
|
+
return { ...members, ...links }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async #access(
|
|
617
|
+
row: CredentialedEndpoint,
|
|
618
|
+
caller: Caller,
|
|
619
|
+
input: Readonly<Record<string, unknown>>,
|
|
620
|
+
frame: RequestFrame,
|
|
621
|
+
membership: MembershipRequest | null,
|
|
622
|
+
): Promise<Access> {
|
|
623
|
+
if (row.requirement.kind === 'member') {
|
|
624
|
+
return { context: {}, input }
|
|
625
|
+
}
|
|
626
|
+
const { action, access } = row.requirement
|
|
627
|
+
switch (access.form) {
|
|
628
|
+
case 'over': {
|
|
629
|
+
const direct = scopedValues(caller.perms, action, access.dimension)
|
|
630
|
+
if (direct === null) {
|
|
631
|
+
return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, 'all') }, input }
|
|
632
|
+
}
|
|
633
|
+
const held = this.#heldContainers(caller.perms, action, access.dimension)
|
|
634
|
+
if (direct.length === 0 && held.length === 0) {
|
|
635
|
+
throw new ForbiddenError(`The caller holds '${action}' in no ${access.dimension}`)
|
|
636
|
+
}
|
|
637
|
+
return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, direct, held) }, input }
|
|
638
|
+
}
|
|
639
|
+
case 'in': {
|
|
640
|
+
const { value, rest } = takeField(input, access.from)
|
|
641
|
+
const direct = permits(caller.perms, action, { type: access.dimension, value })
|
|
642
|
+
// Without a grant on the value or on a container of its dimension, nothing is read: 403 whether it exists or not.
|
|
643
|
+
if (!direct && this.#heldContainers(caller.perms, action, access.dimension).length === 0) {
|
|
644
|
+
throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
|
|
645
|
+
}
|
|
646
|
+
const chain = await this.#chain(access.dimension, value)
|
|
647
|
+
if (!holdsOnChain(caller.perms, action, access.dimension, value, chain ?? new Map())) {
|
|
648
|
+
throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
|
|
649
|
+
}
|
|
650
|
+
if (chain === null) {
|
|
651
|
+
throw new NotFoundError('Not found')
|
|
652
|
+
}
|
|
653
|
+
return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
|
|
654
|
+
}
|
|
655
|
+
case 'on': {
|
|
656
|
+
if (access.from === null) {
|
|
657
|
+
return await this.#linkTarget(row, caller, action, access.dimension, input, frame)
|
|
658
|
+
}
|
|
659
|
+
const { value, rest } = takeField(input, access.from)
|
|
660
|
+
const chain = await this.#chain(access.dimension, value)
|
|
661
|
+
// An object the caller may not act on is indistinguishable from a missing one.
|
|
662
|
+
if (chain === null || !holdsOnChain(caller.perms, action, access.dimension, value, chain)) {
|
|
663
|
+
throw new NotFoundError('Not found')
|
|
664
|
+
}
|
|
665
|
+
return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
|
|
666
|
+
}
|
|
667
|
+
case 'creates': {
|
|
668
|
+
if (membership === null) {
|
|
669
|
+
throw new ServerFault('facility_unbound', `'${row.id}' creates a scope, and boot bound no IAM membership transport`)
|
|
670
|
+
}
|
|
671
|
+
const value = await createScope(membership)
|
|
672
|
+
return { context: { created: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, new Map()) }, input }
|
|
673
|
+
}
|
|
674
|
+
case 'app': {
|
|
675
|
+
// `#authorize` ran `satisfies`, which the app form meets only with a global grant of the action.
|
|
676
|
+
return { context: { app: this.#parts.mint.app(frame, action, caller.subject) }, input }
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* A link procedure acts on the object its key is bound to. The key's mount must be this endpoint's, and the chain
|
|
683
|
+
* it was issued for must still be where the object lies: a link for a note moved since stops working.
|
|
684
|
+
*/
|
|
685
|
+
async #linkTarget(
|
|
686
|
+
row: CredentialedEndpoint,
|
|
687
|
+
caller: Caller,
|
|
688
|
+
action: string,
|
|
689
|
+
dimension: string,
|
|
690
|
+
input: Readonly<Record<string, unknown>>,
|
|
691
|
+
frame: RequestFrame,
|
|
692
|
+
): Promise<Access> {
|
|
693
|
+
const link = caller.link
|
|
694
|
+
if (link === null || link.mount !== row.source.name) {
|
|
695
|
+
throw new ForbiddenError(`'${row.id}' admits only a key of the link mount '${row.source.name}'`)
|
|
696
|
+
}
|
|
697
|
+
const [object, ...containers] = link.chain
|
|
698
|
+
if (object === undefined || object.type !== dimension || !permits(caller.perms, action, object)) {
|
|
699
|
+
throw new ForbiddenError(`The link does not grant '${action}' on a ${dimension}`)
|
|
700
|
+
}
|
|
701
|
+
const chain = await this.#chain(dimension, object.value)
|
|
702
|
+
const unchanged = chain !== null && chain.size === containers.length && containers.every((scope) => chain.get(scope.type) === scope.value)
|
|
703
|
+
if (chain === null || !unchanged) {
|
|
704
|
+
throw new NotFoundError('This link no longer reaches its object')
|
|
705
|
+
}
|
|
706
|
+
return { context: { target: this.#parts.mint.ref(frame, dimension, action, caller.subject, object.value, chain) }, input }
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* A form appends one row of its declared fields to its inbox and answers a constant; it reads nothing. In order: the
|
|
711
|
+
* client's request bucket (before the body is read), the input, the honeypot (a filled one is answered as accepted
|
|
712
|
+
* and writes nothing), the challenge, then the form's bucket of accepted submissions with the row, in one
|
|
713
|
+
* transaction, so nothing refused or failed can empty it. Only after that commit does `then` kick its job, which the
|
|
714
|
+
* answer neither waits for nor depends on.
|
|
715
|
+
*/
|
|
716
|
+
async #form(served: Extract<ServedEndpoint, { kind: 'form' }>, request: Request, exec: RequestExecutionContext | undefined): Promise<Response> {
|
|
717
|
+
admitContentType(served.row, request)
|
|
718
|
+
const { admission } = served
|
|
719
|
+
const admissions = this.#framework().forms
|
|
720
|
+
if (!(await admissions.take(served.form, clientBucket(request), admission.perClient))) {
|
|
721
|
+
throw formRateLimited(served.row)
|
|
722
|
+
}
|
|
723
|
+
const body = await readBoundedBody(request, maxBytesOf(served.row))
|
|
724
|
+
const submission = submissionOf(strictInput(served.row, served.input, parseJson(body)), admission.fields)
|
|
725
|
+
if (submission.trapped) {
|
|
726
|
+
return jsonResponse({ result: { received: true } }, 200)
|
|
727
|
+
}
|
|
728
|
+
if (admission.challenge !== null) {
|
|
729
|
+
await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
|
|
730
|
+
}
|
|
731
|
+
// One transaction: a row that fails to insert gives its place in the form's bucket back.
|
|
732
|
+
if (!(await this.#framework().appendToInbox(served.form, submission.row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }))) {
|
|
733
|
+
throw formRateLimited(served.row)
|
|
734
|
+
}
|
|
735
|
+
if (served.then !== null) {
|
|
736
|
+
// Without an execution context the kick runs detached; it never rejects.
|
|
737
|
+
const kicked = this.#parts.kick(served.then)
|
|
738
|
+
exec?.waitUntil(kicked)
|
|
739
|
+
}
|
|
740
|
+
return jsonResponse({ result: { received: true } }, 200)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The token verifies for this host and this form, and was not accepted before unless it is the `repeatable` one: a
|
|
745
|
+
* failure is 400, an unreachable provider 503.
|
|
746
|
+
*/
|
|
747
|
+
async #challenge(form: string, verifier: ChallengeVerifier, token: string | null, request: Request, repeatable: string | null): Promise<void> {
|
|
748
|
+
const outcome = token === null ? 'failed' : await verifier.verify({ token, hostname: new URL(request.url).hostname, action: form })
|
|
749
|
+
if (outcome === 'unavailable') {
|
|
750
|
+
throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${form}' could not be verified`, 503)
|
|
751
|
+
}
|
|
752
|
+
if (outcome === 'misconfigured') {
|
|
753
|
+
throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${form}' refused the application's secret`, 503)
|
|
754
|
+
}
|
|
755
|
+
const refused = token === null || outcome === 'failed'
|
|
756
|
+
|| (token !== repeatable
|
|
757
|
+
&& !(await this.#framework().forms.recordChallenge(form, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)))
|
|
758
|
+
if (refused) {
|
|
759
|
+
throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* A GitHub delivery, in order: the binding the path names, the signature over the raw body with that binding's
|
|
765
|
+
* secret, the account the payload names, the input, then the delivery key — the hash of the binding and the
|
|
766
|
+
* signed body, so a replay is ignored whatever its unsigned headers say. The handler gets a `Ref` of the bound
|
|
767
|
+
* value for each declared action and the key, and runs at least once: a failed run is forgotten and redelivered.
|
|
768
|
+
*/
|
|
769
|
+
async #webhook(served: Extract<ServedEndpoint, { kind: 'webhook' }>, request: Request, params: Readonly<Record<string, string>>): Promise<Response> {
|
|
770
|
+
admitContentType(served.row, request)
|
|
771
|
+
const body = await readBoundedBody(request, maxBytesOf(served.row))
|
|
772
|
+
const tables = this.#framework().webhooks
|
|
773
|
+
const bindingId = params[WEBHOOK_BINDING_PARAMETER] ?? ''
|
|
774
|
+
const binding = BINDING_ID.test(bindingId) ? await tables.binding(served.webhook, bindingId) : null
|
|
775
|
+
if (binding === null) {
|
|
776
|
+
throw new NotFoundError('Not found')
|
|
777
|
+
}
|
|
778
|
+
if (!(await verifyGithubSignature(request.headers.get('X-Hub-Signature-256'), binding.secret, body))) {
|
|
779
|
+
throw new UnauthorizedError('The delivery signature does not verify with this binding')
|
|
780
|
+
}
|
|
781
|
+
const payload = parseJson(body)
|
|
782
|
+
if (!githubAccounts(payload).includes(binding.account)) {
|
|
783
|
+
throw new ForbiddenError('The delivery names another account than its binding accepts')
|
|
784
|
+
}
|
|
785
|
+
const input = strictInput(served.row, served.input, stripped(served, payload))
|
|
786
|
+
if (binding.scope.type !== served.dimension) {
|
|
787
|
+
throw new ServerFault(
|
|
788
|
+
'webhook_binding_mismatch',
|
|
789
|
+
`binding '${binding.id}' reaches a '${binding.scope.type}', and '${served.webhook}' is bound to '${served.dimension}'`,
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
const chain = await this.#chain(binding.scope.type, binding.scope.value)
|
|
793
|
+
if (chain === null) {
|
|
794
|
+
throw new NotFoundError('The bound value no longer exists')
|
|
795
|
+
}
|
|
796
|
+
// Asked before the delivery is recorded: a delivery that cannot run is not one GitHub must not redeliver.
|
|
797
|
+
const authority = await served.credential.authority()
|
|
798
|
+
if (!authority.ok) {
|
|
799
|
+
throw new ServerFault('workload_unauthorized', `the key of webhook '${served.webhook}' gives it no authority: ${authority.reason}`, 503)
|
|
800
|
+
}
|
|
801
|
+
const unheld = served.actions.find((action) => !permits([...authority.perms], action))
|
|
802
|
+
if (unheld !== undefined) {
|
|
803
|
+
throw new ServerFault('workload_unauthorized', `the token IAM signed for the key of webhook '${served.webhook}' does not carry '${unheld}'`, 503)
|
|
804
|
+
}
|
|
805
|
+
const key = await deliveryKey(binding.id, body)
|
|
806
|
+
const now = Date.now()
|
|
807
|
+
if (!(await tables.recordDelivery(binding.id, key, now, now - WEBHOOK_REPLAY_RETENTION_MS))) {
|
|
808
|
+
return jsonResponse({ result: { received: true } }, 200)
|
|
809
|
+
}
|
|
810
|
+
const frame = new RequestFrame(crypto.randomUUID())
|
|
811
|
+
const site = served.site
|
|
812
|
+
// A staged-kit delivery is one transaction; the delivery record above stays outside it, so a failure forgets it.
|
|
813
|
+
const invocation = site === undefined ? null : new Invocation(frame, this.#data(), false)
|
|
814
|
+
try {
|
|
815
|
+
try {
|
|
816
|
+
await runInRequest(frame, async () => {
|
|
817
|
+
const subject = webhookSubject(binding.id)
|
|
818
|
+
const refs = Object.freeze(
|
|
819
|
+
Object.fromEntries(
|
|
820
|
+
served.actions.map((action) => [action, this.#parts.mint.ref(frame, served.dimension, action, subject, binding.scope.value, chain)]),
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
|
|
824
|
+
const delivery = Object.freeze({ key })
|
|
825
|
+
const data = site === undefined || invocation === null
|
|
826
|
+
? {}
|
|
827
|
+
: {
|
|
828
|
+
db: siteData(invocation.handles('mutation', site, subject)),
|
|
829
|
+
sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
|
|
830
|
+
}
|
|
831
|
+
const signal = handlerSignal(request)
|
|
832
|
+
await served.handler({ input, ctx: Object.freeze({ principal, requestId: frame.id, signal, refs, delivery, ...data }) })
|
|
833
|
+
})
|
|
834
|
+
} finally {
|
|
835
|
+
frame.end()
|
|
836
|
+
}
|
|
837
|
+
await invocation?.commit(null)
|
|
838
|
+
} catch (error) {
|
|
839
|
+
await invocation?.rollback()
|
|
840
|
+
// The provider redelivers a failed delivery; forgetting its key lets the redelivery run. The unsigned
|
|
841
|
+
// delivery header is the caller's word, so the log names only the endpoint.
|
|
842
|
+
console.error(`fabrika: ${served.row.id}: delivery_failed; the provider's redelivery runs it again`)
|
|
843
|
+
await tables.forgetDelivery(binding.id, key)
|
|
844
|
+
throw error
|
|
845
|
+
}
|
|
846
|
+
return jsonResponse({ result: { received: true } }, 200)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
#framework(): FrameworkTables {
|
|
850
|
+
const framework = this.#parts.framework
|
|
851
|
+
if (framework === null) {
|
|
852
|
+
throw new ServerFault('facility_unbound', 'no data layer serves the framework tables')
|
|
853
|
+
}
|
|
854
|
+
return framework
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** The object's containers, read now, so a Ref carries where it lies today; `null` for a missing object a table owns. */
|
|
858
|
+
async #chain(dimension: string, value: string): Promise<ReadonlyMap<string, string> | null> {
|
|
859
|
+
const database = this.#parts.database
|
|
860
|
+
if (database === null || !this.#parts.ownedDimensions.has(dimension)) {
|
|
861
|
+
return new Map()
|
|
862
|
+
}
|
|
863
|
+
return await database.locate(dimension, value)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** The container values of `dimension` in which the caller holds `action`. */
|
|
867
|
+
#heldContainers(perms: PermissionEntry[], action: string, dimension: string): ContainerGrant[] {
|
|
868
|
+
return (this.#parts.containers.get(dimension) ?? []).flatMap((container) => {
|
|
869
|
+
const values = scopedValues(perms, action, container) ?? []
|
|
870
|
+
return values.length === 0 ? [] : [{ dimension: container, values }]
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** `ctx.db` (by the access form) and `ctx.sql` of a staged-kit endpoint: the declared tag, or one that refuses. */
|
|
876
|
+
function siteContext(
|
|
877
|
+
row: CredentialedEndpoint,
|
|
878
|
+
site: ServedSite,
|
|
879
|
+
invocation: Invocation,
|
|
880
|
+
principal: string,
|
|
881
|
+
access: Readonly<Record<string, unknown>>,
|
|
882
|
+
): Readonly<Record<string, unknown>> {
|
|
883
|
+
const form = row.requirement.kind === 'member' ? 'member' : row.requirement.access.form
|
|
884
|
+
const db = endpointData(invocation.handles(site.kind, site.declaration, principal), form, {
|
|
885
|
+
target: access['target'],
|
|
886
|
+
within: access['within'],
|
|
887
|
+
created: access['created'],
|
|
888
|
+
app: access['app'],
|
|
889
|
+
})
|
|
890
|
+
return { db, sql: site.declaration.sql === null ? undeclaredSql(row.id, site.kind) : invocation.sql(site.declaration.sql) }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Whether the caller holds `action` on the value or on any container of its chain; IAM evaluates each as a flat scope. */
|
|
894
|
+
function holdsOnChain(perms: PermissionEntry[], action: string, dimension: string, value: string, chain: ReadonlyMap<string, string>): boolean {
|
|
895
|
+
const scopes: (readonly [string, string])[] = [[dimension, value], ...chain]
|
|
896
|
+
return scopes.some(([type, scope]) => permits(perms, action, { type, value: scope }))
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function projectOutput(served: Extract<ServedEndpoint, { kind: 'procedure' }>, result: unknown): unknown {
|
|
900
|
+
try {
|
|
901
|
+
return project(served.output, result)
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if (error instanceof ProjectionRefusedError) {
|
|
904
|
+
throw new ServerFault('output_refused', `'${served.row.id}' returned a value no branch of its output union accepts`)
|
|
905
|
+
}
|
|
906
|
+
throw error
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Admission. A request that carries no `Authorization` header is authenticated by the session cookie the proxy
|
|
912
|
+
* redeemed; when it is not a `GET`, it must come from this origin. A cross-site page cannot add the header: it is
|
|
913
|
+
* not CORS-safelisted, and the application answers no preflight.
|
|
914
|
+
*/
|
|
915
|
+
function admit(row: CredentialedEndpoint, request: Request): void {
|
|
916
|
+
if (request.method !== 'GET' && !request.headers.has('Authorization') && request.headers.get('Sec-Fetch-Site') !== 'same-origin') {
|
|
917
|
+
throw new HttpError(403, 'admission', 'A session request that is not a GET must be same-origin (Sec-Fetch-Site: same-origin)')
|
|
918
|
+
}
|
|
919
|
+
admitContentType(row, request)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** The content types an endpoint admits; an anonymous endpoint has no session, so this is its whole admission. */
|
|
923
|
+
function formRateLimited(row: EndpointRow): HttpError {
|
|
924
|
+
return new HttpError(429, 'rate_limited', `'${row.id}' takes no more submissions right now; try again in a minute`)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function admitContentType(row: EndpointRow, request: Request): void {
|
|
928
|
+
if (row.admission !== null) {
|
|
929
|
+
const type = mediaType(request.headers.get('Content-Type'))
|
|
930
|
+
if (type === null || !row.admission.contentTypes.includes(type)) {
|
|
931
|
+
throw new HttpError(400, 'admission', `'${row.id}' admits Content-Type ${row.admission.contentTypes.join(' or ')}`)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** An anonymous endpoint's body limit: the framework's, because the proxy never sees the body. */
|
|
937
|
+
function maxBytesOf(row: EndpointRow): number {
|
|
938
|
+
const limit = row.admission?.maxBytes
|
|
939
|
+
if (limit === null || limit === undefined) {
|
|
940
|
+
throw new ServerFault('contract_inconsistent', `'${row.id}' declares no body limit`)
|
|
941
|
+
}
|
|
942
|
+
return limit
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function parseJson(body: Uint8Array): unknown {
|
|
946
|
+
try {
|
|
947
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body))
|
|
948
|
+
} catch {
|
|
949
|
+
throw new BadRequestError('The body is not JSON')
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** A webhook input keeps only the fields it declares: a provider sends many more, and none reaches the handler. */
|
|
954
|
+
function stripped(served: Extract<ServedEndpoint, { kind: 'webhook' }>, payload: unknown): unknown {
|
|
955
|
+
try {
|
|
956
|
+
return project(served.input, payload)
|
|
957
|
+
} catch (error) {
|
|
958
|
+
if (error instanceof ProjectionRefusedError) {
|
|
959
|
+
throw new BadRequestError(`'${served.row.id}': the delivery matches no branch of the declared input`)
|
|
960
|
+
}
|
|
961
|
+
throw error
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** `value` parsed with the endpoint's strict input schema; a refusal is a 400 naming each issue. */
|
|
966
|
+
function strictInput(row: EndpointRow, schema: Schema, value: unknown): Readonly<Record<string, unknown>> {
|
|
967
|
+
const parsed = z.safeParse(schema, value)
|
|
968
|
+
if (!parsed.success) {
|
|
969
|
+
const issues = parsed.error.issues.map((issue) => ({ path: issue.path.map(String).join('.'), code: issue.code, message: issue.message }))
|
|
970
|
+
const first = issues[0]
|
|
971
|
+
throw new HttpError(400, 'validation', first === undefined ? 'Invalid input' : `${first.path || 'input'}: ${first.message}`, { issues })
|
|
972
|
+
}
|
|
973
|
+
if (!isRecord(parsed.data)) {
|
|
974
|
+
throw new BadRequestError(`'${row.id}': the input is not an object`)
|
|
975
|
+
}
|
|
976
|
+
const unstorable = nulAt(parsed.data, [])
|
|
977
|
+
if (unstorable !== null) {
|
|
978
|
+
const path = unstorable.join('.')
|
|
979
|
+
throw new HttpError(400, 'validation', `${path || 'input'}: a string may not contain U+0000`, {
|
|
980
|
+
issues: [{ path, code: 'invalid_format', message: 'a string may not contain U+0000' }],
|
|
981
|
+
})
|
|
982
|
+
}
|
|
983
|
+
return parsed.data
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The path of the first string, or record key, holding U+0000. Postgres `text` cannot store one, so a value the schema
|
|
988
|
+
* admits would otherwise fail its statement as a 500 after the request was admitted.
|
|
989
|
+
*/
|
|
990
|
+
function nulAt(value: unknown, path: readonly string[]): readonly string[] | null {
|
|
991
|
+
if (typeof value === 'string') {
|
|
992
|
+
return value.includes('\u0000') ? path : null
|
|
993
|
+
}
|
|
994
|
+
if (Array.isArray(value)) {
|
|
995
|
+
for (const [index, item] of value.entries()) {
|
|
996
|
+
const found = nulAt(item, [...path, String(index)])
|
|
997
|
+
if (found !== null) {
|
|
998
|
+
return found
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return null
|
|
1002
|
+
}
|
|
1003
|
+
if (isRecord(value)) {
|
|
1004
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1005
|
+
const found = key.includes('\u0000') ? [...path, key] : nulAt(item, [...path, key])
|
|
1006
|
+
if (found !== null) {
|
|
1007
|
+
return found
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return null
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** The strictly parsed input; a procedure without input takes an empty body or `{}`. */
|
|
1015
|
+
async function readInput(
|
|
1016
|
+
served: { readonly row: CredentialedEndpoint; readonly input: Schema | null },
|
|
1017
|
+
request: Request,
|
|
1018
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
1019
|
+
const text = await request.text()
|
|
1020
|
+
let body: unknown = {}
|
|
1021
|
+
if (text.trim() !== '') {
|
|
1022
|
+
try {
|
|
1023
|
+
body = JSON.parse(text)
|
|
1024
|
+
} catch {
|
|
1025
|
+
throw new BadRequestError('The body is not JSON')
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (served.input === null) {
|
|
1029
|
+
if (!isRecord(body) || Object.keys(body).length > 0) {
|
|
1030
|
+
throw new BadRequestError(`'${served.row.id}' takes no input; send {} or an empty body`)
|
|
1031
|
+
}
|
|
1032
|
+
return {}
|
|
1033
|
+
}
|
|
1034
|
+
return strictInput(served.row, served.input, body)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** A canonical path's segments all decode; a value that does not is no id of anything. */
|
|
1038
|
+
function decodePathParameter(raw: string): string {
|
|
1039
|
+
let decoded: string
|
|
1040
|
+
try {
|
|
1041
|
+
decoded = decodeURIComponent(raw)
|
|
1042
|
+
} catch {
|
|
1043
|
+
throw new BadRequestError('A path parameter does not decode')
|
|
1044
|
+
}
|
|
1045
|
+
if (decoded.includes('\u0000')) {
|
|
1046
|
+
throw new BadRequestError('A path parameter may not contain U+0000')
|
|
1047
|
+
}
|
|
1048
|
+
return decoded
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Removes the `from` field: the only copy of that id the handler gets is the checked capability. */
|
|
1052
|
+
function takeField(
|
|
1053
|
+
input: Readonly<Record<string, unknown>>,
|
|
1054
|
+
field: string,
|
|
1055
|
+
): { readonly value: string; readonly rest: Readonly<Record<string, unknown>> } {
|
|
1056
|
+
const value = input[field]
|
|
1057
|
+
if (typeof value !== 'string') {
|
|
1058
|
+
throw new BadRequestError(`${field}: a string is required`)
|
|
1059
|
+
}
|
|
1060
|
+
const rest = Object.fromEntries(Object.entries(input).filter(([name]) => name !== field))
|
|
1061
|
+
return { value, rest: Object.freeze(rest) }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** The only body a 5xx carries: what failed stays on the server, because it may quote a credential. */
|
|
1065
|
+
function serverError(status: number): Response {
|
|
1066
|
+
return status === 503
|
|
1067
|
+
? jsonResponse({ error: { type: 'unavailable', message: 'Service unavailable' } }, 503)
|
|
1068
|
+
: jsonResponse({ error: { type: 'internal', message: 'Internal error' } }, status)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* One envelope, `{ error: { type, message, issues? } }`. A client error keeps its own message and issues; every
|
|
1073
|
+
* 5xx and every refused capability answers a fixed message; what made a 5xx reaches only `FailureReports`, as a code.
|
|
1074
|
+
*/
|
|
1075
|
+
export function errorResponse(error: unknown, where: string): Response {
|
|
1076
|
+
if (isStructuralError(error) && error.httpStatus < 500) {
|
|
1077
|
+
return toErrorResponse(error)
|
|
1078
|
+
}
|
|
1079
|
+
if (error instanceof CapabilityLimitError) {
|
|
1080
|
+
console.error(`fabrika: ${where}: capability_limit: the caller holds the action in more scope values than one list may carry`)
|
|
1081
|
+
return toErrorResponse(new ForbiddenError(`The caller holds the action in more scope values than one list may carry (${MAX_WITHIN_VALUES})`))
|
|
1082
|
+
}
|
|
1083
|
+
const name = error instanceof Error ? error.name : ''
|
|
1084
|
+
// `@fabrika/db` errors carry no status; they are read by name, never by a cross-package `instanceof`.
|
|
1085
|
+
if (name === 'NotFoundError') {
|
|
1086
|
+
return toErrorResponse(new NotFoundError('Not found'))
|
|
1087
|
+
}
|
|
1088
|
+
if (name === 'ConflictError') {
|
|
1089
|
+
return toErrorResponse(new HttpError(409, 'conflict', 'The object changed since it was read'))
|
|
1090
|
+
}
|
|
1091
|
+
if (error instanceof ServerFault) {
|
|
1092
|
+
return serverError(error.status)
|
|
1093
|
+
}
|
|
1094
|
+
return serverError(isStructuralError(error) ? error.httpStatus : 500)
|
|
1095
|
+
}
|