@fabrika/app 0.6.0 → 0.7.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/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -2,10 +2,12 @@
|
|
|
2
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
3
|
// projection. The caller-kind, requirement and access checks are authorization; the same-origin and content-type
|
|
4
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,
|
|
5
|
+
// The anonymous kinds (health, public, webhook, the share SPA) read no token and take their own fixed steps.
|
|
6
6
|
|
|
7
7
|
import type { IamMembership, MembershipRequestCaller } from '@fabrika/auth'
|
|
8
8
|
import {
|
|
9
|
+
answersFile,
|
|
10
|
+
type CapabilitiesRequirement,
|
|
9
11
|
type CompiledEndpointTable,
|
|
10
12
|
type CredentialedEndpoint,
|
|
11
13
|
DELEGATION_HEADER,
|
|
@@ -13,8 +15,10 @@ import {
|
|
|
13
15
|
type EndpointPlan,
|
|
14
16
|
type EndpointRow,
|
|
15
17
|
type ExportDescription,
|
|
16
|
-
|
|
18
|
+
FORM_URLENCODED_TYPE,
|
|
17
19
|
isAttachmentShape,
|
|
20
|
+
isFileShape,
|
|
21
|
+
isRedirectShape,
|
|
18
22
|
type LinkTokenClaims,
|
|
19
23
|
parseEndpointHeader,
|
|
20
24
|
type PermissionEntry,
|
|
@@ -23,23 +27,30 @@ import {
|
|
|
23
27
|
PROXY_SECRET_HEADER,
|
|
24
28
|
PROXY_TOKEN_HEADER,
|
|
25
29
|
rawRequestTarget,
|
|
30
|
+
type RedirectShape,
|
|
26
31
|
satisfies,
|
|
32
|
+
type SchemaShape,
|
|
33
|
+
type ScopeBinding,
|
|
27
34
|
scopedValues,
|
|
28
35
|
sha256Hex,
|
|
36
|
+
type WebhookScheme,
|
|
29
37
|
} from '@fabrika/auth-core'
|
|
30
38
|
import { type CapabilityAuthority, type ContainerGrant, MAX_WITHIN_VALUES } from '@fabrika/db/capability'
|
|
31
|
-
import type { DataAccess, FrameworkTables } from '@fabrika/db/framework'
|
|
39
|
+
import type { DataAccess, FileBinding, FrameworkTables } from '@fabrika/db/framework'
|
|
32
40
|
import type { DataKind } from '@fabrika/db/tables'
|
|
33
41
|
import type { TokenVerifier } from '@fabrika/jwks'
|
|
42
|
+
import { applyAnswerCache, withResponseHeaders } from '@fabrika/proxy-core/headers'
|
|
34
43
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
35
44
|
import { z } from 'zod/v4'
|
|
45
|
+
import { fileFieldsOf } from '../contract/build'
|
|
36
46
|
import {
|
|
37
47
|
BROWSER_CONFIG_FILE,
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
CHALLENGE_FIELD,
|
|
49
|
+
FILE_INPUT_PART,
|
|
40
50
|
FORM_MULTIPART_TYPE,
|
|
41
51
|
joinPath,
|
|
42
52
|
PAGE_CALLER_FILE,
|
|
53
|
+
PROCEDURE_LIMITS,
|
|
43
54
|
WEBHOOK_BINDING_PARAMETER,
|
|
44
55
|
} from '../contract/defaults'
|
|
45
56
|
import type { Schema } from '../contract/define'
|
|
@@ -54,21 +65,30 @@ import {
|
|
|
54
65
|
UnauthorizedError,
|
|
55
66
|
} from '../errors'
|
|
56
67
|
import type { RequestExecutionContext } from '../fetch'
|
|
57
|
-
import
|
|
68
|
+
import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
|
|
58
69
|
import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
|
|
59
|
-
import { abortedBody, bootstrapPath, bootstrapResponse,
|
|
70
|
+
import { abortedBody, bodyDeadlineMs, bootstrapPath, bootstrapResponse, readBoundedBody, withBootstrap } from './anonymous'
|
|
60
71
|
import { attachmentResponse } from './attachment'
|
|
61
|
-
import { handlerSignal, readBoundedBytes } from './bounded'
|
|
72
|
+
import { boundedStream, handlerSignal, readBoundedBytes } from './bounded'
|
|
62
73
|
import type { BrowserConfig } from './browser-config'
|
|
63
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
type AppCapability,
|
|
76
|
+
CapabilityLimitError,
|
|
77
|
+
type CapabilityMint,
|
|
78
|
+
CapabilityRefusedError,
|
|
79
|
+
type Ref,
|
|
80
|
+
RequestFrame,
|
|
81
|
+
runInRequest,
|
|
82
|
+
type Within,
|
|
83
|
+
} from './capability'
|
|
64
84
|
import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
|
|
65
85
|
import { connectContext, type ImportWire } from './connect'
|
|
86
|
+
import { runAsSite } from './egress'
|
|
66
87
|
import type { FailureReports } from './failures'
|
|
67
88
|
import { ServerFault } from './faults'
|
|
68
|
-
import { ACCEPTED_BUCKET, clientBucket, type Submission, type SubmissionFields, submissionOf } from './form-admission'
|
|
69
89
|
import { type HeaderPolicy, withHeaderPolicy } from './headers'
|
|
70
90
|
import type { ContractDatabase } from './implement'
|
|
71
|
-
import { endpointData, Invocation,
|
|
91
|
+
import { boundTo, boundWebhookApp, endpointData, Invocation, kickRecorder, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
|
|
72
92
|
import {
|
|
73
93
|
applicationDirectory,
|
|
74
94
|
createScope,
|
|
@@ -80,9 +100,23 @@ import {
|
|
|
80
100
|
type ReadDirectory,
|
|
81
101
|
} from './membership'
|
|
82
102
|
import { multipartBoundary, MultipartError, multipartParts, type MultipartReader } from './multipart'
|
|
103
|
+
import { paramsObject, urlParams } from './params'
|
|
83
104
|
import { pageCallerOf, presentedBy, type Principal } from './principal'
|
|
84
105
|
import { project, ProjectionRefusedError } from './project'
|
|
85
|
-
import {
|
|
106
|
+
import { bucketsOf, DEFAULT_ADMISSION, type InFlightShare, type ProcedureAdmission, requestClient } from './public'
|
|
107
|
+
import type { SignedTokens } from './tokens'
|
|
108
|
+
import type { AppUrl } from './urls'
|
|
109
|
+
import { githubAccounts, verifyDelivery } from './verifiers'
|
|
110
|
+
import {
|
|
111
|
+
APP_BINDING_SCOPE,
|
|
112
|
+
type BindableWebhook,
|
|
113
|
+
deliveryKey,
|
|
114
|
+
WEBHOOK_CLAIM_GRACE_MS,
|
|
115
|
+
WEBHOOK_REPLAY_RETENTION_MS,
|
|
116
|
+
webhookBindings,
|
|
117
|
+
type WebhookPrincipal,
|
|
118
|
+
webhookSubject,
|
|
119
|
+
} from './webhook'
|
|
86
120
|
import type { WorkloadCredential } from './workload'
|
|
87
121
|
|
|
88
122
|
/** SHA-256 of a proxy secret: 32 bytes whatever the secret's length, as `timingSafeEqual` needs. */
|
|
@@ -122,7 +156,7 @@ export type ServedEndpoint =
|
|
|
122
156
|
readonly row: CredentialedEndpoint
|
|
123
157
|
readonly input: Schema | null
|
|
124
158
|
readonly output: Schema
|
|
125
|
-
readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }) => unknown
|
|
159
|
+
readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object; readonly query?: unknown }) => unknown
|
|
126
160
|
readonly facilities: Facilities
|
|
127
161
|
/** An export procedure: its request may only read. */
|
|
128
162
|
readonly readOnly?: true
|
|
@@ -130,8 +164,14 @@ export type ServedEndpoint =
|
|
|
130
164
|
readonly connect?: readonly ImportWire[]
|
|
131
165
|
/** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
|
|
132
166
|
readonly site?: ServedSite
|
|
133
|
-
/** The
|
|
134
|
-
readonly
|
|
167
|
+
/** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
|
|
168
|
+
readonly egress?: readonly string[]
|
|
169
|
+
/** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
|
|
170
|
+
readonly admission?: ProcedureAdmission
|
|
171
|
+
/** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
|
|
172
|
+
readonly query?: ProcedureQuery
|
|
173
|
+
/** The webhook whose bindings `ctx.webhooks` creates and removes; absent without `binds`. */
|
|
174
|
+
readonly binds?: BindableWebhook
|
|
135
175
|
}
|
|
136
176
|
/** What an export's consumer vendors, behind the export's own callers. */
|
|
137
177
|
| { readonly kind: 'description'; readonly row: CredentialedEndpoint; readonly description: ExportDescription }
|
|
@@ -141,33 +181,30 @@ export type ServedEndpoint =
|
|
|
141
181
|
/** An SPA anyone opens: anonymous, served without a token. */
|
|
142
182
|
| { readonly kind: 'public-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
|
|
143
183
|
| { readonly kind: 'health'; readonly row: EndpointRow }
|
|
144
|
-
| {
|
|
145
|
-
/** A file collection's upload or download, served by `src/files` once the access form minted its target. */
|
|
146
|
-
readonly kind: 'file'
|
|
147
|
-
readonly row: CredentialedEndpoint
|
|
148
|
-
readonly serve: (request: Request, target: unknown) => Promise<Response | FileDownload>
|
|
149
|
-
/**
|
|
150
|
-
* A link holder's download: the `file:<name>` dimension whose `:fileId` must lie beneath the link's object. Its
|
|
151
|
-
* row is `on` the owner's dimension, the link's object; the file is resolved under it.
|
|
152
|
-
*/
|
|
153
|
-
readonly beneath?: string
|
|
154
|
-
}
|
|
155
184
|
/** The shell of a link mount's share SPA: anonymous, and served with the fragment bootstrap. */
|
|
156
185
|
| { readonly kind: 'link-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string }
|
|
157
186
|
| {
|
|
158
|
-
|
|
187
|
+
/** A procedure anyone calls: its handler runs as the endpoint's own workload, holding `actions` only. */
|
|
188
|
+
readonly kind: 'public'
|
|
159
189
|
readonly row: EndpointRow
|
|
160
|
-
readonly
|
|
161
|
-
readonly
|
|
162
|
-
readonly
|
|
163
|
-
|
|
164
|
-
readonly
|
|
190
|
+
readonly input: Schema | null
|
|
191
|
+
readonly output: Schema
|
|
192
|
+
readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object; readonly query?: unknown }) => unknown
|
|
193
|
+
readonly actions: readonly string[]
|
|
194
|
+
readonly query?: ProcedureQuery
|
|
195
|
+
/** The endpoint's key: `ctx.app` mints only what IAM signs for it. */
|
|
196
|
+
readonly credential: WorkloadCredential
|
|
197
|
+
readonly admission: ProcedureAdmission
|
|
198
|
+
readonly site: ServedSite
|
|
165
199
|
}
|
|
166
200
|
| {
|
|
167
201
|
readonly kind: 'webhook'
|
|
168
202
|
readonly row: EndpointRow
|
|
169
203
|
readonly webhook: string
|
|
170
|
-
readonly
|
|
204
|
+
readonly scheme: WebhookScheme
|
|
205
|
+
readonly boundTo: ScopeBinding
|
|
206
|
+
/** How long a delivery's handler runs after the last body byte; its claim's lease outlasts it. */
|
|
207
|
+
readonly timeoutMs: number
|
|
171
208
|
readonly actions: readonly string[]
|
|
172
209
|
readonly input: Schema
|
|
173
210
|
readonly handler: (args: { readonly input: unknown; readonly ctx: object }) => unknown
|
|
@@ -177,29 +214,10 @@ export type ServedEndpoint =
|
|
|
177
214
|
readonly site?: SiteDeclaration
|
|
178
215
|
}
|
|
179
216
|
|
|
180
|
-
/** A
|
|
181
|
-
export interface
|
|
182
|
-
readonly
|
|
183
|
-
readonly
|
|
184
|
-
/** The bytes all the form's files may write per hour; each file reserves its collection's `maxBytes` first. */
|
|
185
|
-
readonly bytesPerHour: number
|
|
186
|
-
/** How long the whole body may take to arrive. */
|
|
187
|
-
readonly timeoutMs: number
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** What a form admits, decided once at boot from the approved artifact and the application's variables. */
|
|
191
|
-
export interface FormAdmission {
|
|
192
|
-
/** Requests per minute from one client, admitted before the body is read. */
|
|
193
|
-
readonly perClient: number
|
|
194
|
-
/** Submissions per minute the form accepts in all: only one that passed its honeypot and its challenge counts. */
|
|
195
|
-
readonly perForm: number
|
|
196
|
-
readonly fields: SubmissionFields
|
|
197
|
-
/** The form's file fields; `null` for a form that takes none, whose body is JSON. */
|
|
198
|
-
readonly files: FormFileAdmission | null
|
|
199
|
-
/** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
|
|
200
|
-
readonly challenge: ChallengeVerifier | null
|
|
201
|
-
/** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's; `null` otherwise. */
|
|
202
|
-
readonly repeatableToken: string | null
|
|
217
|
+
/** A POST procedure's `http.query`: the schema it is parsed with, and its described shape, which reads the parameters. */
|
|
218
|
+
export interface ProcedureQuery {
|
|
219
|
+
readonly schema: Schema
|
|
220
|
+
readonly shape: SchemaShape
|
|
203
221
|
}
|
|
204
222
|
|
|
205
223
|
export interface RuntimeParts {
|
|
@@ -221,21 +239,31 @@ export interface RuntimeParts {
|
|
|
221
239
|
readonly assets: SpaAssets | null
|
|
222
240
|
/** IAM's membership operations; `boot` refuses a contract that needs them when the adapter supplies none. */
|
|
223
241
|
readonly membership: IamMembership | null
|
|
224
|
-
/** The framework's own writes (
|
|
242
|
+
/** The framework's own writes (admission buckets, webhook tables); `boot` refuses a webhook or a bucket without them. */
|
|
225
243
|
readonly framework: FrameworkTables | null
|
|
226
244
|
/** This runtime's capability mint, which `boot` alone holds, and the authority its stores are bound to. */
|
|
227
245
|
readonly mint: CapabilityMint
|
|
228
246
|
readonly capabilities: CapabilityAuthority
|
|
229
247
|
/** The mini-ORM of the bound `scoped(tables)` layer; `boot` refuses a site without one. */
|
|
230
248
|
readonly data: DataAccess | null
|
|
231
|
-
/** The contract's file
|
|
232
|
-
readonly
|
|
249
|
+
/** The contract's file kinds: file fields, file outputs, `ctx.files.put` and `.open`; `null` when it declares none. */
|
|
250
|
+
readonly fileKinds: StoredFileKinds | null
|
|
233
251
|
/** Where a 5xx goes: the server log, and Operations when the application has a DSN. */
|
|
234
252
|
readonly failures: FailureReports
|
|
235
253
|
/** Kicks a contract job under its own key, in the background (`BoundJobs.kick`); it never rejects. */
|
|
236
254
|
readonly kick: (job: string) => Promise<void>
|
|
237
255
|
/** The contract's actions, sorted: what a caller document lists a pattern grant as. */
|
|
238
256
|
readonly actions: readonly string[]
|
|
257
|
+
/** The connections all public procedures of this instance hold at once, at most. */
|
|
258
|
+
readonly publicShare: InFlightShare
|
|
259
|
+
/** Whether the proxy's `X-Fabrika-Client-Ip` is the visitor's, so `ctx.client.ip` reads it. */
|
|
260
|
+
readonly clientVouched: boolean
|
|
261
|
+
/** The environment's signed tokens: verified in each input and query, signed by `ctx.tokens`. */
|
|
262
|
+
readonly tokens: SignedTokens
|
|
263
|
+
/** `ctx.url`: absolute links to the application's own procedures. */
|
|
264
|
+
readonly url: AppUrl
|
|
265
|
+
/** The environment's public origin (`FABRIKA_APP_ORIGIN`), which a Twilio signature covers; `null` where it has none. */
|
|
266
|
+
readonly origin: string | null
|
|
239
267
|
}
|
|
240
268
|
|
|
241
269
|
interface Caller {
|
|
@@ -270,7 +298,15 @@ function mediaType(header: string | null): string | null {
|
|
|
270
298
|
}
|
|
271
299
|
|
|
272
300
|
function describeRequirement(row: CredentialedEndpoint): string {
|
|
273
|
-
|
|
301
|
+
const { requirement } = row
|
|
302
|
+
switch (requirement.kind) {
|
|
303
|
+
case 'member':
|
|
304
|
+
return 'a permission in this application'
|
|
305
|
+
case 'action':
|
|
306
|
+
return `'${requirement.action}' in some scope`
|
|
307
|
+
case 'capabilities':
|
|
308
|
+
return `each of ${Object.values(requirement.capabilities).map((capability) => `'${capability.action}'`).join(', ')} in some scope`
|
|
309
|
+
}
|
|
274
310
|
}
|
|
275
311
|
|
|
276
312
|
export class ContractRuntime {
|
|
@@ -287,10 +323,16 @@ export class ContractRuntime {
|
|
|
287
323
|
async fetch(request: Request, exec?: RequestExecutionContext): Promise<Response> {
|
|
288
324
|
const plan = planEndpoint(this.#parts.table, request.method, rawRequestTarget(request.url).pathname)
|
|
289
325
|
const answer = await this.#answer(request, plan, exec)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
326
|
+
const answered = answer instanceof FileDownload
|
|
327
|
+
? fileResponse(answer)
|
|
328
|
+
: plan.outcome === 'endpoint' && answersFile(plan.endpoint)
|
|
329
|
+
? withFileTreatment(answer)
|
|
330
|
+
: withHeaderPolicy(answer, this.#parts.policy)
|
|
331
|
+
if (plan.outcome !== 'endpoint' || plan.endpoint.cache === undefined) {
|
|
332
|
+
return answered
|
|
333
|
+
}
|
|
334
|
+
const row = plan.endpoint
|
|
335
|
+
return withResponseHeaders(answered, (headers) => applyAnswerCache(headers, row, answered.status))
|
|
294
336
|
}
|
|
295
337
|
|
|
296
338
|
async #answer(request: Request, plan: EndpointPlan, exec: RequestExecutionContext | undefined): Promise<Response | FileDownload> {
|
|
@@ -327,12 +369,10 @@ export class ContractRuntime {
|
|
|
327
369
|
return await withBootstrap(await this.#asset(served, request), served.prefix)
|
|
328
370
|
case 'procedure':
|
|
329
371
|
return await this.#procedure(served, request, exec)
|
|
330
|
-
case '
|
|
331
|
-
return await this.#
|
|
332
|
-
case 'form':
|
|
333
|
-
return await this.#form(served, request, exec)
|
|
372
|
+
case 'public':
|
|
373
|
+
return await this.#public(served, request, exec)
|
|
334
374
|
case 'webhook':
|
|
335
|
-
return await this.#webhook(served, request, plan.params)
|
|
375
|
+
return await this.#webhook(served, request, plan.params, exec)
|
|
336
376
|
case 'description':
|
|
337
377
|
await this.#authorize(served.row, request)
|
|
338
378
|
await readInput({ row: served.row, input: null }, request)
|
|
@@ -463,79 +503,160 @@ export class ContractRuntime {
|
|
|
463
503
|
* A staged-kit site runs in one transaction: its statements commit after the handler returned, the output passed
|
|
464
504
|
* validation and an attachment was encoded, and roll back on a throw, a refused output, a file over its limit or a
|
|
465
505
|
* cancelled request. So an audited export keeps its log row only when its file goes out. Only after that commit does
|
|
466
|
-
* `
|
|
506
|
+
* `ctx.kick` send its jobs, which the answer neither waits for nor depends on.
|
|
467
507
|
*/
|
|
468
508
|
async #procedure(
|
|
469
509
|
served: Extract<ServedEndpoint, { kind: 'procedure' }>,
|
|
470
510
|
request: Request,
|
|
471
511
|
exec: RequestExecutionContext | undefined,
|
|
472
|
-
): Promise<Response> {
|
|
512
|
+
): Promise<Response | FileDownload> {
|
|
473
513
|
const caller = await this.#authorize(served.row, request)
|
|
474
|
-
const
|
|
514
|
+
const admission = served.admission ?? DEFAULT_ADMISSION
|
|
515
|
+
await this.#admit(served.row, admission, request, caller.subject)
|
|
516
|
+
const body = await procedureBody(served, request, this.#parts.fileKinds)
|
|
517
|
+
// Signed tokens are verified as the input is parsed; the texts they carry are what the request may redirect to.
|
|
518
|
+
const carried = new Set<string>()
|
|
519
|
+
const fields = this.#parts.tokens.verifyFields(served.row.input, body.multipart === null ? body.input : body.multipart.json, carried)
|
|
520
|
+
const query = this.#verifiedQuery(served.row, served.query, request, carried)
|
|
475
521
|
const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.readOnly === true, endpoint: served.row.id })
|
|
522
|
+
const work = new FileWork(frame, frame.id)
|
|
476
523
|
const site = served.site
|
|
477
524
|
const invocation = site === undefined ? null : new Invocation(frame, this.#data(), site.kind === 'query' || served.readOnly === true)
|
|
478
|
-
const
|
|
525
|
+
const kicks = kickRecorder(`'${served.row.id}'`, site?.declaration.kicks ?? [])
|
|
526
|
+
let outcome: 'open' | 'committing' = 'open'
|
|
479
527
|
try {
|
|
528
|
+
const input = body.multipart === null
|
|
529
|
+
? fields
|
|
530
|
+
: this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
|
|
531
|
+
// The handler's time starts after the last body byte, so a slow upload does not eat it.
|
|
532
|
+
const signal = handlerSignal(request, admission.timeoutMs)
|
|
480
533
|
let result: unknown
|
|
481
534
|
try {
|
|
482
535
|
result = await runInRequest(frame, async () => {
|
|
483
536
|
const membership = this.#membershipRequest(served.row, caller, request, frame)
|
|
484
537
|
const access = await this.#access(served.row, caller, input, frame, membership)
|
|
538
|
+
const binding = this.#parts.fileKinds?.binding(work)
|
|
485
539
|
const ctx = Object.freeze({
|
|
486
540
|
principal: caller.principal,
|
|
487
541
|
requestId: frame.id,
|
|
488
542
|
signal,
|
|
543
|
+
client: requestClient(request, this.#parts.clientVouched),
|
|
544
|
+
url: this.#parts.url,
|
|
489
545
|
...access.context,
|
|
490
546
|
...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
|
|
491
547
|
credential: caller.token,
|
|
492
548
|
requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
|
|
493
549
|
}),
|
|
494
550
|
...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
|
|
495
|
-
...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context)),
|
|
551
|
+
...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context, binding)),
|
|
496
552
|
// An export's consumer reaches its rows only, never the files of their owners.
|
|
497
553
|
...(site === undefined || invocation === null || served.readOnly === true
|
|
498
554
|
? {}
|
|
499
|
-
: { files: this.#files(served.row.id,
|
|
555
|
+
: { files: this.#files(served.row.id, invocation, site.kind, work) }),
|
|
556
|
+
...(served.binds === undefined || invocation === null
|
|
557
|
+
? {}
|
|
558
|
+
: { webhooks: this.#webhookBindings(served.row.id, served.binds, invocation, caller.subject) }),
|
|
559
|
+
...this.#signing(`'${served.row.id}'`, site?.declaration.signs ?? []),
|
|
560
|
+
kick: kicks.kick,
|
|
500
561
|
})
|
|
501
|
-
|
|
562
|
+
const args = { input: access.input, ctx, ...(query === undefined ? {} : { query }) }
|
|
563
|
+
const egress = served.egress
|
|
564
|
+
return egress === undefined
|
|
565
|
+
? await served.handler(args)
|
|
566
|
+
: await runAsSite(`'${served.row.id}'`, egress, async () => await served.handler(args))
|
|
502
567
|
})
|
|
503
568
|
} finally {
|
|
504
569
|
frame.end()
|
|
505
570
|
}
|
|
506
|
-
const answer = this.#answerOf(served, result)
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const kicked = this.#parts.kick(served.then)
|
|
511
|
-
exec?.waitUntil(kicked)
|
|
512
|
-
}
|
|
571
|
+
const answer = await this.#answerOf(served, result, request, work, carried)
|
|
572
|
+
outcome = 'committing'
|
|
573
|
+
await commitAnswer(invocation, signal, answer)
|
|
574
|
+
this.#kickAll(kicks.kicked(), exec)
|
|
513
575
|
return answer
|
|
514
576
|
} finally {
|
|
515
577
|
await invocation?.rollback()
|
|
578
|
+
await body.multipart?.close()
|
|
579
|
+
// Only a known rollback deletes what the request staged; after a commit that may have happened, the sweep decides.
|
|
580
|
+
if (outcome === 'open') {
|
|
581
|
+
await this.#parts.fileKinds?.release(work)
|
|
582
|
+
}
|
|
516
583
|
}
|
|
517
584
|
}
|
|
518
585
|
|
|
586
|
+
/** A multipart body's input: its JSON part with every file part staged for `work`, parsed strictly as one input. */
|
|
587
|
+
async #fileInput(
|
|
588
|
+
served: { readonly row: EndpointRow; readonly input: Schema | null },
|
|
589
|
+
multipart: MultipartInput,
|
|
590
|
+
work: FileWork,
|
|
591
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
592
|
+
const files = await multipart.files(work)
|
|
593
|
+
if (served.input === null) {
|
|
594
|
+
throw new ServerFault('contract_inconsistent', `'${served.row.id}' takes files and declares no input`)
|
|
595
|
+
}
|
|
596
|
+
return strictInput(served.row, served.input, { ...multipart.json, ...files })
|
|
597
|
+
}
|
|
598
|
+
|
|
519
599
|
/**
|
|
520
|
-
* The output as JSON, projected and validated whole;
|
|
521
|
-
*
|
|
600
|
+
* The output as JSON, projected and validated whole; an attachment's file, whose rows are projected, validated and
|
|
601
|
+
* encoded one at a time, so a result over a limit stops before it is copied; or the stored file the handler answered.
|
|
522
602
|
*/
|
|
523
|
-
#answerOf(
|
|
524
|
-
|
|
603
|
+
async #answerOf(
|
|
604
|
+
served: Extract<ServedEndpoint, { kind: 'procedure' }>,
|
|
605
|
+
result: unknown,
|
|
606
|
+
request: Request,
|
|
607
|
+
work: FileWork,
|
|
608
|
+
carried: ReadonlySet<string>,
|
|
609
|
+
): Promise<Response | FileDownload> {
|
|
610
|
+
const output = served.row.output
|
|
611
|
+
if (output !== null && isFileShape(output)) {
|
|
612
|
+
return await this.#fileKinds(served.row.id).download(work, served.row.id, output.kind, result)
|
|
613
|
+
}
|
|
614
|
+
const file = output !== null && isAttachmentShape(output) ? output : null
|
|
525
615
|
if (file !== null) {
|
|
526
616
|
return attachmentResponse(served.row.id, file, served.output, result)
|
|
527
617
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
618
|
+
return outputResponse(served.row, served.output, result, request, carried)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** A POST's `query`, parsed from the URL, each signed token in it verified; `undefined` without one. */
|
|
622
|
+
#verifiedQuery(
|
|
623
|
+
row: EndpointRow,
|
|
624
|
+
query: ProcedureQuery | undefined,
|
|
625
|
+
request: Request,
|
|
626
|
+
carried: Set<string>,
|
|
627
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
628
|
+
const read = readQuery(row, query?.schema, query?.shape, request)
|
|
629
|
+
return read === undefined ? undefined : this.#parts.tokens.verifyFields(query?.shape ?? null, read, carried)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** `ctx.tokens` of a site that declares `signs`; nothing for one that declares none, whose type has no such member. */
|
|
633
|
+
#signing(site: string, signs: readonly string[]): { readonly tokens?: object } {
|
|
634
|
+
return signs.length === 0 ? {} : { tokens: this.#parts.tokens.context(site, signs) }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#fileKinds(endpoint: string): StoredFileKinds {
|
|
638
|
+
const kinds = this.#parts.fileKinds
|
|
639
|
+
if (kinds === null) {
|
|
640
|
+
throw new ServerFault('facility_unbound', `'${endpoint}' takes or answers a file, and boot bound no file kinds`)
|
|
531
641
|
}
|
|
532
|
-
return
|
|
642
|
+
return kinds
|
|
533
643
|
}
|
|
534
644
|
|
|
535
|
-
/** `ctx.
|
|
536
|
-
#
|
|
537
|
-
|
|
538
|
-
|
|
645
|
+
/** `ctx.webhooks` of a procedure that declares `binds`: its bindings are written in the invocation's transaction. */
|
|
646
|
+
#webhookBindings(endpoint: string, webhook: BindableWebhook, invocation: Invocation, principal: string): (name: string) => object {
|
|
647
|
+
return webhookBindings({
|
|
648
|
+
site: `'${endpoint}'`,
|
|
649
|
+
webhook,
|
|
650
|
+
tables: () => this.#framework().webhooks,
|
|
651
|
+
authority: this.#parts.capabilities,
|
|
652
|
+
...invocation.frameworkSite('ctx.webhooks'),
|
|
653
|
+
principal,
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** `ctx.files` of a staged-kit site: `put` and `open` of the stored files, bound to the invocation and the request's work. */
|
|
658
|
+
#files(endpoint: string, invocation: Invocation, kind: DataKind, work: FileWork): object {
|
|
659
|
+
return storedFilesHandle(this.#parts.fileKinds, work, { where: `'${endpoint}'`, check: invocation.fileCheck(), writes: kind !== 'query' })
|
|
539
660
|
}
|
|
540
661
|
|
|
541
662
|
#data(): DataAccess {
|
|
@@ -546,73 +667,6 @@ export class ContractRuntime {
|
|
|
546
667
|
return data
|
|
547
668
|
}
|
|
548
669
|
|
|
549
|
-
/**
|
|
550
|
-
* The access form reads its value from the path parameter it names: `:owner` of an upload, `:fileId` of a download.
|
|
551
|
-
* A link holder's row names none: its owner is the link's object, checked as a link procedure's is.
|
|
552
|
-
*/
|
|
553
|
-
async #file(
|
|
554
|
-
served: Extract<ServedEndpoint, { kind: 'file' }>,
|
|
555
|
-
request: Request,
|
|
556
|
-
params: Readonly<Record<string, string>>,
|
|
557
|
-
): Promise<Response | FileDownload> {
|
|
558
|
-
const caller = await this.#authorize(served.row, request)
|
|
559
|
-
const access = served.row.requirement.kind === 'action' ? served.row.requirement.access : null
|
|
560
|
-
if (access?.form === 'on' && access.from === null && served.row.requirement.kind === 'action') {
|
|
561
|
-
return await this.#linkFile(served, request, params, caller, served.row.requirement.action, access.dimension)
|
|
562
|
-
}
|
|
563
|
-
const from = access?.form === 'on' ? access.from : null
|
|
564
|
-
const raw = from === null ? undefined : params[from]
|
|
565
|
-
if (from === null || raw === undefined) {
|
|
566
|
-
throw new ServerFault('contract_inconsistent', `'${served.row.id}' names no path parameter for its access form`)
|
|
567
|
-
}
|
|
568
|
-
const frame = new RequestFrame(crypto.randomUUID())
|
|
569
|
-
try {
|
|
570
|
-
return await runInRequest(frame, async () => {
|
|
571
|
-
const { context } = await this.#access(served.row, caller, { [from]: decodePathParameter(raw) }, frame, null)
|
|
572
|
-
return await served.serve(request, context['target'])
|
|
573
|
-
})
|
|
574
|
-
} finally {
|
|
575
|
-
frame.end()
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* A link holder's upload or download. The link's mount and its chain are checked as `#linkTarget` checks them; a
|
|
581
|
-
* download then resolves `:fileId` beneath that object, and a file of any other object is indistinguishable from a
|
|
582
|
-
* missing one.
|
|
583
|
-
*/
|
|
584
|
-
async #linkFile(
|
|
585
|
-
served: Extract<ServedEndpoint, { kind: 'file' }>,
|
|
586
|
-
request: Request,
|
|
587
|
-
params: Readonly<Record<string, string>>,
|
|
588
|
-
caller: Caller,
|
|
589
|
-
action: string,
|
|
590
|
-
dimension: string,
|
|
591
|
-
): Promise<Response | FileDownload> {
|
|
592
|
-
const frame = new RequestFrame(crypto.randomUUID())
|
|
593
|
-
try {
|
|
594
|
-
return await runInRequest(frame, async () => {
|
|
595
|
-
const owner = await this.#linkTarget(served.row, caller, action, dimension, {}, frame)
|
|
596
|
-
const beneath = served.beneath
|
|
597
|
-
if (beneath === undefined) {
|
|
598
|
-
return await served.serve(request, owner.context['target'])
|
|
599
|
-
}
|
|
600
|
-
const raw = params['fileId']
|
|
601
|
-
const fileId = raw === undefined ? '' : decodePathParameter(raw)
|
|
602
|
-
const [object, ...containers] = caller.link?.chain ?? []
|
|
603
|
-
const chain = fileId === '' ? null : await this.#chain(beneath, fileId)
|
|
604
|
-
const ownedHere = chain !== null && object !== undefined && chain.get(dimension) === object.value
|
|
605
|
-
&& containers.every((scope) => chain.get(scope.type) === scope.value)
|
|
606
|
-
if (chain === null || object === undefined || !ownedHere) {
|
|
607
|
-
throw new NotFoundError('Not found')
|
|
608
|
-
}
|
|
609
|
-
return await served.serve(request, this.#parts.mint.ref(frame, beneath, action, caller.subject, fileId, chain))
|
|
610
|
-
})
|
|
611
|
-
} finally {
|
|
612
|
-
frame.end()
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
670
|
/** The caller's authority at IAM for this request, when the endpoint's access form names a dimension and an action. */
|
|
617
671
|
#membershipRequest(row: CredentialedEndpoint, caller: Caller, request: Request, frame: RequestFrame): MembershipRequest | null {
|
|
618
672
|
const iam = this.#parts.membership
|
|
@@ -665,46 +719,23 @@ export class ContractRuntime {
|
|
|
665
719
|
if (row.requirement.kind === 'member') {
|
|
666
720
|
return { context: {}, input }
|
|
667
721
|
}
|
|
722
|
+
if (row.requirement.kind === 'capabilities') {
|
|
723
|
+
return await this.#capabilities(row.requirement, caller, input, frame)
|
|
724
|
+
}
|
|
668
725
|
const { action, access } = row.requirement
|
|
669
726
|
switch (access.form) {
|
|
670
|
-
case 'over':
|
|
671
|
-
|
|
672
|
-
if (direct === null) {
|
|
673
|
-
return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, 'all') }, input }
|
|
674
|
-
}
|
|
675
|
-
const held = this.#heldContainers(caller.perms, action, access.dimension)
|
|
676
|
-
if (direct.length === 0 && held.length === 0) {
|
|
677
|
-
throw new ForbiddenError(`The caller holds '${action}' in no ${access.dimension}`)
|
|
678
|
-
}
|
|
679
|
-
return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, direct, held) }, input }
|
|
680
|
-
}
|
|
727
|
+
case 'over':
|
|
728
|
+
return { context: { within: this.#within(caller, action, access.dimension, frame) }, input }
|
|
681
729
|
case 'in': {
|
|
682
730
|
const { value, rest } = takeField(input, access.from)
|
|
683
|
-
|
|
684
|
-
// Without a grant on the value or on a container of its dimension, nothing is read: 403 whether it exists or not.
|
|
685
|
-
if (!direct && this.#heldContainers(caller.perms, action, access.dimension).length === 0) {
|
|
686
|
-
throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
|
|
687
|
-
}
|
|
688
|
-
const chain = await this.#chain(access.dimension, value)
|
|
689
|
-
if (!holdsOnChain(caller.perms, action, access.dimension, value, chain ?? new Map())) {
|
|
690
|
-
throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
|
|
691
|
-
}
|
|
692
|
-
if (chain === null) {
|
|
693
|
-
throw new NotFoundError('Not found')
|
|
694
|
-
}
|
|
695
|
-
return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
|
|
731
|
+
return { context: { target: await this.#inRef(caller, action, access.dimension, value, frame) }, input: rest }
|
|
696
732
|
}
|
|
697
733
|
case 'on': {
|
|
698
734
|
if (access.from === null) {
|
|
699
735
|
return await this.#linkTarget(row, caller, action, access.dimension, input, frame)
|
|
700
736
|
}
|
|
701
737
|
const { value, rest } = takeField(input, access.from)
|
|
702
|
-
|
|
703
|
-
// An object the caller may not act on is indistinguishable from a missing one.
|
|
704
|
-
if (chain === null || !holdsOnChain(caller.perms, action, access.dimension, value, chain)) {
|
|
705
|
-
throw new NotFoundError('Not found')
|
|
706
|
-
}
|
|
707
|
-
return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
|
|
738
|
+
return { context: { target: await this.#onRef(caller, action, access.dimension, value, frame) }, input: rest }
|
|
708
739
|
}
|
|
709
740
|
case 'creates': {
|
|
710
741
|
if (membership === null) {
|
|
@@ -720,6 +751,71 @@ export class ContractRuntime {
|
|
|
720
751
|
}
|
|
721
752
|
}
|
|
722
753
|
|
|
754
|
+
/** The values of `dimension` in which the caller holds `action`, directly or through a container; all for a global grant. */
|
|
755
|
+
#within(caller: Caller, action: string, dimension: string, frame: RequestFrame): Within<string, string> {
|
|
756
|
+
const direct = scopedValues(caller.perms, action, dimension)
|
|
757
|
+
if (direct === null) {
|
|
758
|
+
return this.#parts.mint.within(frame, dimension, action, caller.subject, 'all')
|
|
759
|
+
}
|
|
760
|
+
const held = this.#heldContainers(caller.perms, action, dimension)
|
|
761
|
+
if (direct.length === 0 && held.length === 0) {
|
|
762
|
+
throw new ForbiddenError(`The caller holds '${action}' in no ${dimension}`)
|
|
763
|
+
}
|
|
764
|
+
return this.#parts.mint.within(frame, dimension, action, caller.subject, direct, held)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** A scope value the caller acts in: without a grant on it or on a container of it, 403 whether it exists or not. */
|
|
768
|
+
async #inRef(caller: Caller, action: string, dimension: string, value: string, frame: RequestFrame): Promise<Ref<string, string>> {
|
|
769
|
+
const direct = permits(caller.perms, action, { type: dimension, value })
|
|
770
|
+
if (!direct && this.#heldContainers(caller.perms, action, dimension).length === 0) {
|
|
771
|
+
throw new ForbiddenError(`The caller does not hold '${action}' in this ${dimension}`)
|
|
772
|
+
}
|
|
773
|
+
const chain = await this.#chain(dimension, value)
|
|
774
|
+
if (!holdsOnChain(caller.perms, action, dimension, value, chain ?? new Map())) {
|
|
775
|
+
throw new ForbiddenError(`The caller does not hold '${action}' in this ${dimension}`)
|
|
776
|
+
}
|
|
777
|
+
if (chain === null) {
|
|
778
|
+
throw new NotFoundError('Not found')
|
|
779
|
+
}
|
|
780
|
+
return this.#parts.mint.ref(frame, dimension, action, caller.subject, value, chain)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** An object the caller acts on; one it may not act on is indistinguishable from a missing one. */
|
|
784
|
+
async #onRef(caller: Caller, action: string, dimension: string, value: string, frame: RequestFrame): Promise<Ref<string, string>> {
|
|
785
|
+
const chain = await this.#chain(dimension, value)
|
|
786
|
+
if (chain === null || !holdsOnChain(caller.perms, action, dimension, value, chain)) {
|
|
787
|
+
throw new NotFoundError('Not found')
|
|
788
|
+
}
|
|
789
|
+
return this.#parts.mint.ref(frame, dimension, action, caller.subject, value, chain)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Several capabilities, each minted as its single access form mints it, in their declared order, so the first one
|
|
794
|
+
* refused answers. Each reads its value from the input as the caller sent it; the fields they read are removed after.
|
|
795
|
+
*/
|
|
796
|
+
async #capabilities(
|
|
797
|
+
requirement: CapabilitiesRequirement,
|
|
798
|
+
caller: Caller,
|
|
799
|
+
input: Readonly<Record<string, unknown>>,
|
|
800
|
+
frame: RequestFrame,
|
|
801
|
+
): Promise<Access> {
|
|
802
|
+
const minted: Record<string, unknown> = {}
|
|
803
|
+
const read = new Set<string>()
|
|
804
|
+
for (const [name, { action, access }] of Object.entries(requirement.capabilities)) {
|
|
805
|
+
if (access.form === 'over') {
|
|
806
|
+
minted[name] = this.#within(caller, action, access.dimension, frame)
|
|
807
|
+
continue
|
|
808
|
+
}
|
|
809
|
+
const { value } = takeField(input, access.from)
|
|
810
|
+
read.add(access.from)
|
|
811
|
+
minted[name] = access.form === 'in'
|
|
812
|
+
? await this.#inRef(caller, action, access.dimension, value, frame)
|
|
813
|
+
: await this.#onRef(caller, action, access.dimension, value, frame)
|
|
814
|
+
}
|
|
815
|
+
const rest = Object.freeze(Object.fromEntries(Object.entries(input).filter(([field]) => !read.has(field))))
|
|
816
|
+
return { context: { capabilities: Object.freeze(minted) }, input: rest }
|
|
817
|
+
}
|
|
818
|
+
|
|
723
819
|
/**
|
|
724
820
|
* A link procedure acts on the object its key is bound to. The key's mount must be this endpoint's, and the chain
|
|
725
821
|
* it was issued for must still be where the object lies: a link for a note moved since stops working.
|
|
@@ -749,233 +845,249 @@ export class ContractRuntime {
|
|
|
749
845
|
}
|
|
750
846
|
|
|
751
847
|
/**
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
* and writes nothing), the challenge, then the form's bucket of accepted submissions with the row, in one
|
|
755
|
-
* transaction, so nothing refused or failed can empty it. Only after that commit does `then` kick its job, which the
|
|
756
|
-
* answer neither waits for nor depends on.
|
|
848
|
+
* The token verifies for this host and this action, and was not accepted before unless it is the `repeatable` one: a
|
|
849
|
+
* failure is 400, as a 0.5 form answered, an unreachable provider 503.
|
|
757
850
|
*/
|
|
758
|
-
async #
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (!(await admissions.take(served.form, clientBucket(request), admission.perClient))) {
|
|
763
|
-
throw formRateLimited(served.row)
|
|
764
|
-
}
|
|
765
|
-
// A form with files whose every file is optional also takes the JSON body of a page that sends none.
|
|
766
|
-
if (admission.files !== null && mediaType(request.headers.get('Content-Type')) === FORM_MULTIPART_TYPE) {
|
|
767
|
-
return await this.#multipartForm(served, admission.files, request, exec)
|
|
768
|
-
}
|
|
769
|
-
const body = await readBoundedBody(request, maxBytesOf(served.row))
|
|
770
|
-
const submission = submissionOf(strictInput(served.row, served.input, parseJson(body)), admission.fields)
|
|
771
|
-
if (submission.trapped) {
|
|
772
|
-
return jsonResponse({ result: { received: true } }, 200)
|
|
773
|
-
}
|
|
774
|
-
if (admission.challenge !== null) {
|
|
775
|
-
await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
|
|
851
|
+
async #challenge(action: string, verifier: ChallengeVerifier, token: string | null, request: Request, repeatable: string | null): Promise<void> {
|
|
852
|
+
const outcome = token === null ? 'failed' : await verifier.verify({ token, hostname: new URL(request.url).hostname, action })
|
|
853
|
+
if (outcome === 'unavailable') {
|
|
854
|
+
throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${action}' could not be verified`, 503)
|
|
776
855
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
throw formRateLimited(served.row)
|
|
856
|
+
if (outcome === 'misconfigured') {
|
|
857
|
+
throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${action}' refused the application's secret`, 503)
|
|
780
858
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
859
|
+
const refused = token === null || outcome === 'failed'
|
|
860
|
+
|| (token !== repeatable
|
|
861
|
+
&& !(await this.#framework().admissions.recordChallenge(action, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)))
|
|
862
|
+
if (refused) {
|
|
863
|
+
throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
|
|
785
864
|
}
|
|
786
|
-
return jsonResponse({ result: { received: true } }, 200)
|
|
787
865
|
}
|
|
788
866
|
|
|
789
867
|
/**
|
|
790
|
-
* A
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
* not commit deletes every file it received. The whole body arrives within the form's `timeoutMs`.
|
|
868
|
+
* A public procedure, in the order of ADR-0063's public request: the in-flight share (503), then every bucket in the
|
|
869
|
+
* order of `bucketsOf` (429), each charged before the body is read and kept whatever follows; the bounded body and the
|
|
870
|
+
* strict input (413, 400); the workload token, before a challenge spends its token (503); the challenge (400); then
|
|
871
|
+
* the handler in one transaction, the output and the commit. The handler's time starts after the last body byte.
|
|
795
872
|
*/
|
|
796
|
-
async #
|
|
797
|
-
served: Extract<ServedEndpoint, { kind: '
|
|
798
|
-
files: FormFileAdmission,
|
|
873
|
+
async #public(
|
|
874
|
+
served: Extract<ServedEndpoint, { kind: 'public' }>,
|
|
799
875
|
request: Request,
|
|
800
876
|
exec: RequestExecutionContext | undefined,
|
|
801
|
-
): Promise<Response> {
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
if (
|
|
805
|
-
throw new HttpError(
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
const declaredFiles = [...files.fields.values()]
|
|
809
|
-
const limit = maxBytesOf(served.row) + declaredFiles.reduce((sum, field) => sum + files.collections.maxBytesOf(field.collection), 0)
|
|
810
|
-
+ (declaredFiles.length + 1) * MULTIPART_PART_OVERHEAD
|
|
811
|
-
const declared = Number(request.headers.get('Content-Length') ?? '')
|
|
812
|
-
if (Number.isFinite(declared) && declared > limit) {
|
|
813
|
-
throw new HttpError(413, 'too_large', `The body is larger than the ${limit} bytes the form's input and files can hold`)
|
|
814
|
-
}
|
|
815
|
-
const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(files.timeoutMs)])
|
|
816
|
-
const parts = multipartParts(request.body ?? new Blob([]).stream(), boundary, { maxHeaderBytes: FORM_FILE_LIMITS.maxPartHeaderBytes })
|
|
817
|
-
const stop = () => {
|
|
818
|
-
parts.cancel().catch(() => {})
|
|
819
|
-
}
|
|
820
|
-
deadline.addEventListener('abort', stop, { once: true })
|
|
821
|
-
const received: ReceivedFormFile[] = []
|
|
822
|
-
/**
|
|
823
|
-
* `kept` once the inbox row may have committed: an append that threw may have committed before its answer was
|
|
824
|
-
* lost, so its files are left to the sweep, which deletes a pending row's object after an hour and never a held one.
|
|
825
|
-
*/
|
|
826
|
-
let kept = false
|
|
877
|
+
): Promise<Response | FileDownload> {
|
|
878
|
+
admitContentType(served.row, request)
|
|
879
|
+
const release = this.#parts.publicShare.enter()
|
|
880
|
+
if (release === null) {
|
|
881
|
+
throw new HttpError(503, 'public_share_full', 'Too many anonymous requests at once; try again shortly')
|
|
882
|
+
}
|
|
883
|
+
let multipart: MultipartInput | null = null
|
|
827
884
|
try {
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
await
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const field = files.fields.get(part.name)
|
|
840
|
-
if (field === undefined || columns.has(part.name) || part.filename === null) {
|
|
841
|
-
await part.body.cancel()
|
|
842
|
-
throw new HttpError(
|
|
843
|
-
400,
|
|
844
|
-
'multipart',
|
|
845
|
-
`'${served.row.id}': a part is no file field of the form, a field arrives twice, or a file has no filename`,
|
|
846
|
-
)
|
|
847
|
-
}
|
|
848
|
-
const file = await this.#receiveFormFile(served, files, field.collection, part)
|
|
849
|
-
if (file !== null) {
|
|
850
|
-
received.push(file)
|
|
851
|
-
}
|
|
852
|
-
columns.set(part.name, file?.id ?? null)
|
|
853
|
-
}
|
|
854
|
-
const row: Record<string, unknown> = { ...submission.row }
|
|
855
|
-
for (const [name, field] of files.fields) {
|
|
856
|
-
const id = columns.get(name) ?? null
|
|
857
|
-
if (id === null && field.required) {
|
|
858
|
-
throw new HttpError(400, 'validation', `${name}: the form needs this file`, {
|
|
859
|
-
issues: [{ path: name, code: 'required', message: 'a file is required' }],
|
|
860
|
-
})
|
|
861
|
-
}
|
|
862
|
-
row[name] = id
|
|
885
|
+
const { admission } = served
|
|
886
|
+
await this.#admit(served.row, admission, request, null)
|
|
887
|
+
const body = await procedureBody({ row: served.row, input: served.input }, request, this.#parts.fileKinds)
|
|
888
|
+
multipart = body.multipart
|
|
889
|
+
// Signed tokens are verified as the input is parsed, before the workload token and the challenge.
|
|
890
|
+
const carried = new Set<string>()
|
|
891
|
+
const fields = this.#parts.tokens.verifyFields(served.row.input, body.multipart === null ? body.input : body.multipart.json, carried)
|
|
892
|
+
const query = this.#verifiedQuery(served.row, served.query, request, carried)
|
|
893
|
+
const authority = await served.credential.authority()
|
|
894
|
+
if (!authority.ok) {
|
|
895
|
+
throw new ServerFault('workload_unauthorized', `the key of public procedure '${served.row.id}' gives it no authority: ${authority.reason}`, 503)
|
|
863
896
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
897
|
+
if (admission.challenge !== null) {
|
|
898
|
+
const token = fields[CHALLENGE_FIELD]
|
|
899
|
+
await this.#challenge(
|
|
900
|
+
admission.challenge.action,
|
|
901
|
+
admission.challenge.verifier,
|
|
902
|
+
typeof token === 'string' ? token : null,
|
|
903
|
+
request,
|
|
904
|
+
admission.repeatableToken,
|
|
905
|
+
)
|
|
870
906
|
}
|
|
871
|
-
|
|
872
|
-
throw multipartFailure(error, parts, deadline, request)
|
|
907
|
+
return await this.#publicInvocation(served, request, body, fields, query, carried, authority.perms, exec)
|
|
873
908
|
} finally {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (!kept) {
|
|
877
|
-
await files.collections.releaseFormFiles(received)
|
|
878
|
-
}
|
|
909
|
+
await multipart?.close()
|
|
910
|
+
release()
|
|
879
911
|
}
|
|
880
|
-
if (served.then !== null) {
|
|
881
|
-
// Without an execution context the kick runs detached; it never rejects.
|
|
882
|
-
const kicked = this.#parts.kick(served.then)
|
|
883
|
-
exec?.waitUntil(kicked)
|
|
884
|
-
}
|
|
885
|
-
return jsonResponse({ result: { received: true } }, 200)
|
|
886
912
|
}
|
|
887
913
|
|
|
888
914
|
/**
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
* its reservation: its bytes may have been written.
|
|
915
|
+
* Sends each job once, after the commit: in the background, never awaited and never rejecting. Without an execution
|
|
916
|
+
* context a kick runs detached.
|
|
892
917
|
*/
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
part: { readonly filename: string | null; readonly contentType: string | null; readonly body: ReadableStream<Uint8Array> },
|
|
898
|
-
): Promise<ReceivedFormFile | null> {
|
|
899
|
-
const admissions = this.#framework().forms
|
|
900
|
-
const reserved = files.collections.maxBytesOf(collection)
|
|
901
|
-
if (!(await admissions.reserveBytes(served.form, reserved, files.bytesPerHour))) {
|
|
902
|
-
await part.body.cancel()
|
|
903
|
-
throw new HttpError(429, 'rate_limited', `'${served.row.id}' takes no more files this hour; try again later`)
|
|
904
|
-
}
|
|
905
|
-
const file = await files.collections.receiveFormFile(served.form, collection, part)
|
|
906
|
-
await admissions.releaseBytes(served.form, reserved - (file?.size ?? 0))
|
|
907
|
-
return file
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** The first part of a multipart form: its input, as JSON within the form's `maxBytes`, parsed strictly. */
|
|
911
|
-
async #multipartInput(served: Extract<ServedEndpoint, { kind: 'form' }>, parts: MultipartReader): Promise<Submission> {
|
|
912
|
-
const first = await parts.next()
|
|
913
|
-
if (first === null || first.name !== FORM_INPUT_PART || first.filename !== null) {
|
|
914
|
-
await first?.body.cancel()
|
|
915
|
-
throw new HttpError(400, 'multipart', `'${served.row.id}': the first part is the input as JSON, named '${FORM_INPUT_PART}', without a filename`)
|
|
916
|
-
}
|
|
917
|
-
const limit = maxBytesOf(served.row)
|
|
918
|
-
const bytes = await readBoundedBytes(first.body, { maxBytes: limit })
|
|
919
|
-
if (bytes === null) {
|
|
920
|
-
throw new HttpError(413, 'too_large', `The input is larger than ${limit} bytes`)
|
|
918
|
+
#kickAll(jobs: readonly string[], exec: RequestExecutionContext | undefined): void {
|
|
919
|
+
for (const job of new Set(jobs)) {
|
|
920
|
+
const kicked = this.#parts.kick(job)
|
|
921
|
+
exec?.waitUntil(kicked)
|
|
921
922
|
}
|
|
922
|
-
return submissionOf(strictInput(served.row, served.input, parseJson(bytes)), served.admission.fields)
|
|
923
923
|
}
|
|
924
924
|
|
|
925
925
|
/**
|
|
926
|
-
*
|
|
927
|
-
*
|
|
926
|
+
* Takes one from each bucket of the endpoint, in order, before the body is read; a full one is a 429, and a take
|
|
927
|
+
* stays taken whatever the request does next. `caller` is the principal a `perCaller` bucket counts. A
|
|
928
|
+
* `bytesPerHour` budget is charged the body's declared length, at most the endpoint's body limit, or that limit when
|
|
929
|
+
* the body declares none.
|
|
928
930
|
*/
|
|
929
|
-
async #
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
931
|
+
async #admit(row: EndpointRow, admission: ProcedureAdmission, request: Request, caller: string | null): Promise<void> {
|
|
932
|
+
const endpoint = row.id
|
|
933
|
+
for (const { bucket, limit } of bucketsOf(admission.rate, request, caller)) {
|
|
934
|
+
if (!(await this.#framework().admissions.take(endpoint, bucket, limit))) {
|
|
935
|
+
throw new HttpError(429, 'rate_limited', `'${endpoint}' admits no more calls this minute; try again later`)
|
|
936
|
+
}
|
|
933
937
|
}
|
|
934
|
-
|
|
935
|
-
|
|
938
|
+
const bytesPerHour = admission.rate.bytesPerHour
|
|
939
|
+
if (bytesPerHour === null) {
|
|
940
|
+
return
|
|
936
941
|
}
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
+
const declared = Number(request.headers.get('Content-Length') ?? '')
|
|
943
|
+
const charged = Math.min(Number.isSafeInteger(declared) && declared > 0 ? declared : maxBytesOf(row), maxBytesOf(row))
|
|
944
|
+
if (charged > 0 && !(await this.#framework().admissions.reserveBytes(endpoint, charged, bytesPerHour))) {
|
|
945
|
+
throw new HttpError(429, 'rate_limited', `'${endpoint}' admits no more bytes this hour; try again later`)
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async #publicInvocation(
|
|
950
|
+
served: Extract<ServedEndpoint, { kind: 'public' }>,
|
|
951
|
+
request: Request,
|
|
952
|
+
body: Awaited<ReturnType<typeof procedureBody>>,
|
|
953
|
+
fields: Readonly<Record<string, unknown>>,
|
|
954
|
+
query: Readonly<Record<string, unknown>> | undefined,
|
|
955
|
+
carried: Set<string>,
|
|
956
|
+
perms: readonly PermissionEntry[],
|
|
957
|
+
exec: RequestExecutionContext | undefined,
|
|
958
|
+
): Promise<Response | FileDownload> {
|
|
959
|
+
const kicks = kickRecorder(`'${served.row.id}'`, served.site.declaration.kicks)
|
|
960
|
+
const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.site.kind === 'query', endpoint: served.row.id })
|
|
961
|
+
const work = new FileWork(frame, frame.id)
|
|
962
|
+
const invocation = new Invocation(frame, this.#data(), served.site.kind === 'query')
|
|
963
|
+
const subject = publicSubject(served.row.id)
|
|
964
|
+
const app = (action: string): AppCapability<string> => {
|
|
965
|
+
if (frame.ended) {
|
|
966
|
+
throw new CapabilityRefusedError('ended', `request ${frame.id} has ended; it mints no capability`)
|
|
967
|
+
}
|
|
968
|
+
if (!served.actions.includes(action)) {
|
|
969
|
+
throw new CapabilityRefusedError('undeclared_action', `public procedure '${served.row.id}' does not declare this action`)
|
|
970
|
+
}
|
|
971
|
+
if (!permits([...perms], action)) {
|
|
972
|
+
throw new CapabilityRefusedError('undeclared_action', `the token IAM signed for the key of '${served.row.id}' does not carry this action`)
|
|
973
|
+
}
|
|
974
|
+
return this.#parts.mint.app(frame, action, subject)
|
|
975
|
+
}
|
|
976
|
+
let outcome: 'open' | 'committing' = 'open'
|
|
977
|
+
try {
|
|
978
|
+
// The file parts come after the challenge, so a refused visitor stores nothing.
|
|
979
|
+
const input = body.multipart === null
|
|
980
|
+
? fields
|
|
981
|
+
: this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
|
|
982
|
+
// The handler's time starts after the last body byte, so a slow upload does not eat it.
|
|
983
|
+
const signal = handlerSignal(request, served.admission.timeoutMs)
|
|
984
|
+
let result: unknown
|
|
985
|
+
try {
|
|
986
|
+
result = await runInRequest(frame, async () => {
|
|
987
|
+
const handles = invocation.handles(
|
|
988
|
+
served.site.kind,
|
|
989
|
+
served.site.declaration,
|
|
990
|
+
subject,
|
|
991
|
+
{ carried },
|
|
992
|
+
this.#parts.fileKinds?.binding(work),
|
|
993
|
+
)
|
|
994
|
+
const where = `'${served.row.id}'`
|
|
995
|
+
const ctx = Object.freeze({
|
|
996
|
+
principal: Object.freeze({ kind: 'public', endpoint: served.row.id }),
|
|
997
|
+
requestId: frame.id,
|
|
998
|
+
signal,
|
|
999
|
+
client: requestClient(request, this.#parts.clientVouched),
|
|
1000
|
+
url: this.#parts.url,
|
|
1001
|
+
app,
|
|
1002
|
+
db: Object.freeze({ app: (action: string) => handles.app(app(action)) }),
|
|
1003
|
+
...this.#signing(`'${served.row.id}'`, served.site.declaration.signs),
|
|
1004
|
+
files: storedFilesHandle(this.#parts.fileKinds, work, {
|
|
1005
|
+
where,
|
|
1006
|
+
check: boundTo(frame, () => false, 'ctx.files'),
|
|
1007
|
+
writes: served.site.kind !== 'query',
|
|
1008
|
+
}),
|
|
1009
|
+
kick: kicks.kick,
|
|
1010
|
+
})
|
|
1011
|
+
const args = { input, ctx, ...(query === undefined ? {} : { query }) }
|
|
1012
|
+
return await runAsSite(`'${served.row.id}'`, served.site.declaration.egress, async () => await served.handler(args))
|
|
1013
|
+
})
|
|
1014
|
+
} finally {
|
|
1015
|
+
frame.end()
|
|
1016
|
+
}
|
|
1017
|
+
const output = served.row.output
|
|
1018
|
+
const answer = output !== null && isFileShape(output)
|
|
1019
|
+
? await this.#fileKinds(served.row.id).download(work, served.row.id, output.kind, result)
|
|
1020
|
+
: outputResponse(served.row, served.output, result, request, carried)
|
|
1021
|
+
outcome = 'committing'
|
|
1022
|
+
await commitAnswer(invocation, signal, answer)
|
|
1023
|
+
this.#kickAll(kicks.kicked(), exec)
|
|
1024
|
+
return answer
|
|
1025
|
+
} finally {
|
|
1026
|
+
await invocation.rollback()
|
|
1027
|
+
if (outcome === 'open') {
|
|
1028
|
+
await this.#parts.fileKinds?.release(work)
|
|
1029
|
+
}
|
|
942
1030
|
}
|
|
943
1031
|
}
|
|
944
1032
|
|
|
945
1033
|
/**
|
|
946
|
-
* A
|
|
947
|
-
* secret, the account
|
|
1034
|
+
* A webhook delivery, in order: the binding the path names, the signature over the raw body with that binding's
|
|
1035
|
+
* secret, the account a GitHub payload names, the input, then the delivery key — the hash of the binding and the
|
|
948
1036
|
* signed body, so a replay is ignored whatever its unsigned headers say. The handler gets a `Ref` of the bound
|
|
949
|
-
* value for each declared action
|
|
1037
|
+
* value for each declared action, or mints application-wide ones when the webhook is bound to `'app'`, and the
|
|
1038
|
+
* key; it runs at least once: a failed run is forgotten and redelivered.
|
|
950
1039
|
*/
|
|
951
|
-
async #webhook(
|
|
1040
|
+
async #webhook(
|
|
1041
|
+
served: Extract<ServedEndpoint, { kind: 'webhook' }>,
|
|
1042
|
+
request: Request,
|
|
1043
|
+
params: Readonly<Record<string, string>>,
|
|
1044
|
+
exec: RequestExecutionContext | undefined,
|
|
1045
|
+
): Promise<Response> {
|
|
952
1046
|
admitContentType(served.row, request)
|
|
953
|
-
const
|
|
1047
|
+
const limit = maxBytesOf(served.row)
|
|
1048
|
+
const body = await readBoundedBody(request, limit, AbortSignal.timeout(bodyDeadlineMs(limit)))
|
|
954
1049
|
const tables = this.#framework().webhooks
|
|
955
1050
|
const bindingId = params[WEBHOOK_BINDING_PARAMETER] ?? ''
|
|
956
1051
|
const binding = BINDING_ID.test(bindingId) ? await tables.binding(served.webhook, bindingId) : null
|
|
957
1052
|
if (binding === null) {
|
|
958
1053
|
throw new NotFoundError('Not found')
|
|
959
1054
|
}
|
|
960
|
-
if (
|
|
1055
|
+
if (served.scheme === 'twilio' && this.#parts.origin === null) {
|
|
1056
|
+
throw new ServerFault(
|
|
1057
|
+
'webhook_unverifiable',
|
|
1058
|
+
`webhook '${served.webhook}' checks Twilio's signature over its public URL, and the environment has no FABRIKA_APP_ORIGIN`,
|
|
1059
|
+
)
|
|
1060
|
+
}
|
|
1061
|
+
const target = rawRequestTarget(request.url)
|
|
1062
|
+
const url = this.#parts.origin === null ? null : `${this.#parts.origin}${target.pathname}${target.search}`
|
|
1063
|
+
if (!(await verifyDelivery(served.scheme, { headers: request.headers, body, url }, binding.secret, Date.now()))) {
|
|
961
1064
|
throw new UnauthorizedError('The delivery signature does not verify with this binding')
|
|
962
1065
|
}
|
|
963
|
-
const payload =
|
|
964
|
-
|
|
1066
|
+
const payload = mediaType(request.headers.get('Content-Type')) === FORM_URLENCODED_TYPE
|
|
1067
|
+
? paramsObject(new URLSearchParams(utf8Body(body)), served.row.input)
|
|
1068
|
+
: parseJson(body)
|
|
1069
|
+
// Slack confirms a new events URL by asking it to echo a challenge, signed like every delivery; no handler runs.
|
|
1070
|
+
const challenge = served.scheme === 'slack' ? slackChallenge(payload) : null
|
|
1071
|
+
if (challenge !== null) {
|
|
1072
|
+
return jsonResponse({ challenge }, 200)
|
|
1073
|
+
}
|
|
1074
|
+
// Only GitHub names the account in its payload; another provider's binding is its own secret.
|
|
1075
|
+
if (served.scheme === 'github-sha256' && !githubAccounts(payload).includes(binding.account)) {
|
|
965
1076
|
throw new ForbiddenError('The delivery names another account than its binding accepts')
|
|
966
1077
|
}
|
|
967
1078
|
const input = strictInput(served.row, served.input, stripped(served, payload))
|
|
968
|
-
|
|
1079
|
+
const bound = served.boundTo.kind === 'app' ? APP_BINDING_SCOPE.type : served.boundTo.dimension
|
|
1080
|
+
if (binding.scope.type !== bound) {
|
|
969
1081
|
throw new ServerFault(
|
|
970
1082
|
'webhook_binding_mismatch',
|
|
971
|
-
`binding '${binding.id}' reaches a '${binding.scope.type}', and '${served.webhook}' is bound to '${
|
|
1083
|
+
`binding '${binding.id}' reaches a '${binding.scope.type}', and '${served.webhook}' is bound to '${bound}'`,
|
|
972
1084
|
)
|
|
973
1085
|
}
|
|
974
|
-
const chain = await this.#chain(binding.scope.type, binding.scope.value)
|
|
1086
|
+
const chain = served.boundTo.kind === 'app' ? new Map<string, string>() : await this.#chain(binding.scope.type, binding.scope.value)
|
|
975
1087
|
if (chain === null) {
|
|
976
1088
|
throw new NotFoundError('The bound value no longer exists')
|
|
977
1089
|
}
|
|
978
|
-
// Asked before the delivery is
|
|
1090
|
+
// Asked before the delivery is claimed: a delivery that cannot run is one the provider must redeliver.
|
|
979
1091
|
const authority = await served.credential.authority()
|
|
980
1092
|
if (!authority.ok) {
|
|
981
1093
|
throw new ServerFault('workload_unauthorized', `the key of webhook '${served.webhook}' gives it no authority: ${authority.reason}`, 503)
|
|
@@ -985,49 +1097,121 @@ export class ContractRuntime {
|
|
|
985
1097
|
throw new ServerFault('workload_unauthorized', `the token IAM signed for the key of webhook '${served.webhook}' does not carry '${unheld}'`, 503)
|
|
986
1098
|
}
|
|
987
1099
|
const key = await deliveryKey(binding.id, body)
|
|
1100
|
+
const budgetMs = served.timeoutMs
|
|
988
1101
|
const now = Date.now()
|
|
989
|
-
|
|
1102
|
+
const claim = await tables.claimDelivery(binding.id, key, now, budgetMs + WEBHOOK_CLAIM_GRACE_MS, now - WEBHOOK_REPLAY_RETENTION_MS)
|
|
1103
|
+
if (claim === null) {
|
|
990
1104
|
return jsonResponse({ result: { received: true } }, 200)
|
|
991
1105
|
}
|
|
992
1106
|
const frame = new RequestFrame(crypto.randomUUID())
|
|
1107
|
+
const work = new FileWork(frame, frame.id)
|
|
993
1108
|
const site = served.site
|
|
994
|
-
//
|
|
1109
|
+
// The claim stays outside the delivery's transaction, and its completion commits inside it, with the work.
|
|
995
1110
|
const invocation = site === undefined ? null : new Invocation(frame, this.#data(), false)
|
|
1111
|
+
const kicks = kickRecorder(`webhook '${served.webhook}'`, site?.kicks ?? [])
|
|
1112
|
+
const signal = handlerSignal(request, budgetMs)
|
|
1113
|
+
let committing = false
|
|
996
1114
|
try {
|
|
997
1115
|
try {
|
|
998
1116
|
await runInRequest(frame, async () => {
|
|
999
1117
|
const subject = webhookSubject(binding.id)
|
|
1000
|
-
const
|
|
1001
|
-
Object.fromEntries(
|
|
1002
|
-
served.actions.map((action) => [action, this.#parts.mint.ref(frame, served.dimension, action, subject, binding.scope.value, chain)]),
|
|
1003
|
-
),
|
|
1004
|
-
)
|
|
1118
|
+
const reach = this.#webhookReach(served, frame, subject, binding.scope.value, chain)
|
|
1005
1119
|
const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
|
|
1006
1120
|
const delivery = Object.freeze({ key })
|
|
1007
|
-
const
|
|
1121
|
+
const handles = site === undefined || invocation === null
|
|
1122
|
+
? null
|
|
1123
|
+
: invocation.handles('mutation', site, subject, 'declared', this.#parts.fileKinds?.binding(work))
|
|
1124
|
+
const data = site === undefined || invocation === null || handles === null
|
|
1008
1125
|
? {}
|
|
1009
1126
|
: {
|
|
1010
|
-
db: siteData(
|
|
1127
|
+
db: siteData(handles, (action) => handles.app(reach.app(action))),
|
|
1011
1128
|
sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
|
|
1012
1129
|
}
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1130
|
+
const client = requestClient(request, this.#parts.clientVouched)
|
|
1131
|
+
const args = {
|
|
1132
|
+
input,
|
|
1133
|
+
ctx: Object.freeze({
|
|
1134
|
+
principal,
|
|
1135
|
+
requestId: frame.id,
|
|
1136
|
+
signal,
|
|
1137
|
+
client,
|
|
1138
|
+
url: this.#parts.url,
|
|
1139
|
+
...reach.context,
|
|
1140
|
+
delivery,
|
|
1141
|
+
files: storedFilesHandle(this.#parts.fileKinds, work, {
|
|
1142
|
+
where: `webhook '${served.webhook}'`,
|
|
1143
|
+
check: boundTo(frame, () => false, 'ctx.files'),
|
|
1144
|
+
writes: true,
|
|
1145
|
+
}),
|
|
1146
|
+
...this.#signing(`webhook '${served.webhook}'`, site?.signs ?? []),
|
|
1147
|
+
kick: kicks.kick,
|
|
1148
|
+
...data,
|
|
1149
|
+
}),
|
|
1150
|
+
}
|
|
1151
|
+
if (site === undefined) {
|
|
1152
|
+
await served.handler(args)
|
|
1153
|
+
} else {
|
|
1154
|
+
await runAsSite(`webhook '${served.webhook}'`, site.egress, async () => await served.handler(args))
|
|
1155
|
+
}
|
|
1015
1156
|
})
|
|
1016
1157
|
} finally {
|
|
1017
1158
|
frame.end()
|
|
1018
1159
|
}
|
|
1019
|
-
await invocation
|
|
1160
|
+
const using = invocation === null ? undefined : await invocation.frameworkSite('the delivery claim').session()
|
|
1161
|
+
if (!(await tables.completeDelivery(claim, using))) {
|
|
1162
|
+
throw new ServerFault('webhook_claim_lost', `the claim of a delivery to '${served.webhook}' expired and another copy took it over`)
|
|
1163
|
+
}
|
|
1164
|
+
committing = true
|
|
1165
|
+
await invocation?.commit(signal)
|
|
1166
|
+
this.#kickAll(kicks.kicked(), exec)
|
|
1020
1167
|
} catch (error) {
|
|
1021
1168
|
await invocation?.rollback()
|
|
1022
|
-
//
|
|
1169
|
+
// Only a known rollback deletes what the delivery staged; after a commit that may have happened, the sweep decides.
|
|
1170
|
+
if (!committing) {
|
|
1171
|
+
await this.#parts.fileKinds?.release(work)
|
|
1172
|
+
}
|
|
1173
|
+
// The provider redelivers a failed delivery; giving the claim back lets the redelivery run at once. The unsigned
|
|
1023
1174
|
// delivery header is the caller's word, so the log names only the endpoint.
|
|
1024
1175
|
console.error(`fabrika: ${served.row.id}: delivery_failed; the provider's redelivery runs it again`)
|
|
1025
|
-
await tables.
|
|
1176
|
+
await tables.releaseDelivery(claim)
|
|
1026
1177
|
throw error
|
|
1027
1178
|
}
|
|
1028
1179
|
return jsonResponse({ result: { received: true } }, 200)
|
|
1029
1180
|
}
|
|
1030
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* What a delivery's handler acts with: a `Ref` of the bound value for each declared action, or, for a webhook bound to
|
|
1184
|
+
* `'app'`, `ctx.app(<action>)`, which mints a declared action the webhook's token carries; `ctx.db.app` binds its
|
|
1185
|
+
* handles to what `app` mints, and a webhook bound to a dimension refuses both.
|
|
1186
|
+
*/
|
|
1187
|
+
#webhookReach(
|
|
1188
|
+
served: Extract<ServedEndpoint, { kind: 'webhook' }>,
|
|
1189
|
+
frame: RequestFrame,
|
|
1190
|
+
subject: string,
|
|
1191
|
+
value: string,
|
|
1192
|
+
chain: ReadonlyMap<string, string>,
|
|
1193
|
+
): { readonly context: Readonly<Record<string, unknown>>; readonly app: (action: string) => AppCapability<string> } {
|
|
1194
|
+
const boundTo = served.boundTo
|
|
1195
|
+
if (boundTo.kind === 'dimension') {
|
|
1196
|
+
const refs = Object.freeze(
|
|
1197
|
+
Object.fromEntries(served.actions.map((action) => [action, this.#parts.mint.ref(frame, boundTo.dimension, action, subject, value, chain)])),
|
|
1198
|
+
)
|
|
1199
|
+
const app = boundWebhookApp(served.webhook)
|
|
1200
|
+
return { context: { refs, app }, app }
|
|
1201
|
+
}
|
|
1202
|
+
// The delivery checked once that the token carries every declared action, before it was recorded.
|
|
1203
|
+
const app = (action: string): AppCapability<string> => {
|
|
1204
|
+
if (frame.ended) {
|
|
1205
|
+
throw new CapabilityRefusedError('ended', `delivery ${frame.id} has ended; it mints no capability`)
|
|
1206
|
+
}
|
|
1207
|
+
if (!served.actions.includes(action)) {
|
|
1208
|
+
throw new CapabilityRefusedError('undeclared_action', `webhook '${served.webhook}' does not declare this action`)
|
|
1209
|
+
}
|
|
1210
|
+
return this.#parts.mint.app(frame, action, subject)
|
|
1211
|
+
}
|
|
1212
|
+
return { context: { app }, app }
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1031
1215
|
#framework(): FrameworkTables {
|
|
1032
1216
|
const framework = this.#parts.framework
|
|
1033
1217
|
if (framework === null) {
|
|
@@ -1061,9 +1245,10 @@ function siteContext(
|
|
|
1061
1245
|
invocation: Invocation,
|
|
1062
1246
|
principal: string,
|
|
1063
1247
|
access: Readonly<Record<string, unknown>>,
|
|
1248
|
+
files: FileBinding | undefined,
|
|
1064
1249
|
): Readonly<Record<string, unknown>> {
|
|
1065
|
-
const form = row.requirement.kind === 'member' ? 'member' : row.requirement.access.form
|
|
1066
|
-
const db = endpointData(invocation.handles(site.kind, site.declaration, principal), form, {
|
|
1250
|
+
const form = row.requirement.kind === 'member' ? 'member' : row.requirement.kind === 'capabilities' ? 'capabilities' : row.requirement.access.form
|
|
1251
|
+
const db = endpointData(invocation.handles(site.kind, site.declaration, principal, 'declared', files), form, {
|
|
1067
1252
|
target: access['target'],
|
|
1068
1253
|
within: access['within'],
|
|
1069
1254
|
created: access['created'],
|
|
@@ -1078,17 +1263,37 @@ function holdsOnChain(perms: PermissionEntry[], action: string, dimension: strin
|
|
|
1078
1263
|
return scopes.some(([type, scope]) => permits(perms, action, { type, value: scope }))
|
|
1079
1264
|
}
|
|
1080
1265
|
|
|
1081
|
-
|
|
1266
|
+
/**
|
|
1267
|
+
* The output, projected and validated whole: JSON, or a redirect to its validated target. `carried` are the texts the
|
|
1268
|
+
* request's verified tokens carry: a URL among them is a target the application signed.
|
|
1269
|
+
*/
|
|
1270
|
+
function outputResponse(row: EndpointRow, schema: Schema, result: unknown, request: Request, carried: ReadonlySet<string>): Response {
|
|
1271
|
+
const output = z.safeParse(schema, projectSchema(row.id, schema, result))
|
|
1272
|
+
if (!output.success) {
|
|
1273
|
+
throw new ServerFault('output_refused', `'${row.id}' returned a value its output schema refuses`)
|
|
1274
|
+
}
|
|
1275
|
+
if (row.output !== null && isRedirectShape(row.output)) {
|
|
1276
|
+
return redirectResponse(row, row.output, output.data, request, carried)
|
|
1277
|
+
}
|
|
1278
|
+
return jsonResponse({ result: output.data }, 200)
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function projectSchema(endpoint: string, output: Schema, result: unknown): unknown {
|
|
1082
1282
|
try {
|
|
1083
|
-
return project(
|
|
1283
|
+
return project(output, result)
|
|
1084
1284
|
} catch (error) {
|
|
1085
1285
|
if (error instanceof ProjectionRefusedError) {
|
|
1086
|
-
throw new ServerFault('output_refused', `'${
|
|
1286
|
+
throw new ServerFault('output_refused', `'${endpoint}' returned a value no branch of its output union accepts`)
|
|
1087
1287
|
}
|
|
1088
1288
|
throw error
|
|
1089
1289
|
}
|
|
1090
1290
|
}
|
|
1091
1291
|
|
|
1292
|
+
/** The principal a public procedure's capabilities are granted to: its workload, never a visitor. */
|
|
1293
|
+
function publicSubject(endpoint: string): string {
|
|
1294
|
+
return `public:${endpoint}`
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1092
1297
|
/**
|
|
1093
1298
|
* Admission. A request that carries no `Authorization` header is authenticated by the session cookie the proxy
|
|
1094
1299
|
* redeemed; when it is not a `GET`, it must come from this origin. A cross-site page cannot add the header: it is
|
|
@@ -1102,7 +1307,7 @@ function admit(row: CredentialedEndpoint, request: Request): void {
|
|
|
1102
1307
|
}
|
|
1103
1308
|
|
|
1104
1309
|
/** What one part adds to a multipart body beside its bytes: its delimiter and its headers, at most. */
|
|
1105
|
-
const MULTIPART_PART_OVERHEAD =
|
|
1310
|
+
const MULTIPART_PART_OVERHEAD = PROCEDURE_LIMITS.maxPartHeaderBytes + 128
|
|
1106
1311
|
|
|
1107
1312
|
/**
|
|
1108
1313
|
* What a failed multipart submission answers: a body that broke the format 400, one that did not arrive in time 408,
|
|
@@ -1129,10 +1334,6 @@ function multipartFailure(error: unknown, parts: MultipartReader, deadline: Abor
|
|
|
1129
1334
|
}
|
|
1130
1335
|
|
|
1131
1336
|
/** The content types an endpoint admits; an anonymous endpoint has no session, so this is its whole admission. */
|
|
1132
|
-
function formRateLimited(row: EndpointRow): HttpError {
|
|
1133
|
-
return new HttpError(429, 'rate_limited', `'${row.id}' takes no more submissions right now; try again in a minute`)
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
1337
|
function admitContentType(row: EndpointRow, request: Request): void {
|
|
1137
1338
|
if (row.admission !== null) {
|
|
1138
1339
|
const type = mediaType(request.headers.get('Content-Type'))
|
|
@@ -1153,12 +1354,29 @@ function maxBytesOf(row: EndpointRow): number {
|
|
|
1153
1354
|
|
|
1154
1355
|
function parseJson(body: Uint8Array): unknown {
|
|
1155
1356
|
try {
|
|
1156
|
-
return JSON.parse(
|
|
1357
|
+
return JSON.parse(utf8Body(body))
|
|
1157
1358
|
} catch {
|
|
1158
1359
|
throw new BadRequestError('The body is not JSON')
|
|
1159
1360
|
}
|
|
1160
1361
|
}
|
|
1161
1362
|
|
|
1363
|
+
function utf8Body(body: Uint8Array): string {
|
|
1364
|
+
try {
|
|
1365
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(body)
|
|
1366
|
+
} catch {
|
|
1367
|
+
throw new BadRequestError('The body is not UTF-8')
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** The challenge of Slack's `url_verification` request, which the URL answers as `{ challenge }`; `null` for any other. */
|
|
1372
|
+
function slackChallenge(payload: unknown): string | null {
|
|
1373
|
+
if (!isRecord(payload) || payload['type'] !== 'url_verification') {
|
|
1374
|
+
return null
|
|
1375
|
+
}
|
|
1376
|
+
const challenge = payload['challenge']
|
|
1377
|
+
return typeof challenge === 'string' ? challenge : null
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1162
1380
|
/** A webhook input keeps only the fields it declares: a provider sends many more, and none reaches the handler. */
|
|
1163
1381
|
function stripped(served: Extract<ServedEndpoint, { kind: 'webhook' }>, payload: unknown): unknown {
|
|
1164
1382
|
try {
|
|
@@ -1221,17 +1439,30 @@ function nulAt(value: unknown, path: readonly string[]): readonly string[] | nul
|
|
|
1221
1439
|
}
|
|
1222
1440
|
|
|
1223
1441
|
/** The strictly parsed input; a procedure without input takes an empty body or `{}`. */
|
|
1442
|
+
/**
|
|
1443
|
+
* A procedure's input: a GET's from the URL's flat parameters; a POST's from its body, JSON or a native form's
|
|
1444
|
+
* urlencoded fields, within the row's body limit and deadline.
|
|
1445
|
+
*/
|
|
1224
1446
|
async function readInput(
|
|
1225
|
-
served: { readonly row:
|
|
1447
|
+
served: { readonly row: EndpointRow; readonly input: Schema | null },
|
|
1226
1448
|
request: Request,
|
|
1227
1449
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
1228
|
-
const text = await request.text()
|
|
1229
1450
|
let body: unknown = {}
|
|
1230
|
-
if (
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1451
|
+
if (served.row.method === 'GET') {
|
|
1452
|
+
body = paramsObject(urlParams(request), served.row.input)
|
|
1453
|
+
} else {
|
|
1454
|
+
const limit = served.row.admission?.maxBytes ?? null
|
|
1455
|
+
const text = limit === null
|
|
1456
|
+
? await request.text()
|
|
1457
|
+
: new TextDecoder().decode(await readBoundedBody(request, limit, AbortSignal.timeout(bodyDeadlineMs(limit))))
|
|
1458
|
+
if (mediaType(request.headers.get('Content-Type')) === FORM_URLENCODED_TYPE) {
|
|
1459
|
+
body = paramsObject(new URLSearchParams(text), served.row.input)
|
|
1460
|
+
} else if (text.trim() !== '') {
|
|
1461
|
+
try {
|
|
1462
|
+
body = JSON.parse(text)
|
|
1463
|
+
} catch {
|
|
1464
|
+
throw new BadRequestError('The body is not JSON')
|
|
1465
|
+
}
|
|
1235
1466
|
}
|
|
1236
1467
|
}
|
|
1237
1468
|
if (served.input === null) {
|
|
@@ -1243,18 +1474,188 @@ async function readInput(
|
|
|
1243
1474
|
return strictInput(served.row, served.input, body)
|
|
1244
1475
|
}
|
|
1245
1476
|
|
|
1246
|
-
/**
|
|
1247
|
-
|
|
1248
|
-
|
|
1477
|
+
/**
|
|
1478
|
+
* A procedure's body: its input read whole (JSON, urlencoded, or a GET's URL parameters), or a multipart body whose JSON
|
|
1479
|
+
* part was read and whose file parts follow. Only a procedure with file fields reads multipart.
|
|
1480
|
+
*/
|
|
1481
|
+
async function procedureBody(
|
|
1482
|
+
served: { readonly row: EndpointRow; readonly input: Schema | null },
|
|
1483
|
+
request: Request,
|
|
1484
|
+
kinds: StoredFileKinds | null,
|
|
1485
|
+
): Promise<
|
|
1486
|
+
{ readonly input: Readonly<Record<string, unknown>>; readonly multipart: null } | { readonly input: null; readonly multipart: MultipartInput }
|
|
1487
|
+
> {
|
|
1488
|
+
const fields = fileFieldsOf(served.row.input)
|
|
1489
|
+
if (served.row.method === 'POST' && fields.size > 0 && mediaType(request.headers.get('Content-Type')) === FORM_MULTIPART_TYPE) {
|
|
1490
|
+
return { input: null, multipart: await openMultipart(served.row, fields, request, kinds) }
|
|
1491
|
+
}
|
|
1492
|
+
return { input: await readInput(served, request), multipart: null }
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/** A multipart body being read: its JSON input part, read first, and its file parts, read by `files`. */
|
|
1496
|
+
interface MultipartInput {
|
|
1497
|
+
/** The input part's fields: no file field among them; the challenge reads its token from here. */
|
|
1498
|
+
readonly json: Readonly<Record<string, unknown>>
|
|
1499
|
+
/** Reads every file part as the body streams, staging each for `work`: the file fields' references by name. */
|
|
1500
|
+
files(work: FileWork): Promise<Readonly<Record<string, unknown>>>
|
|
1501
|
+
/** Ends the read and the body deadline, whatever happened. */
|
|
1502
|
+
close(): Promise<void>
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Opens a procedure's `multipart/form-data` body: its first part is the input as JSON (named `input`, at most the
|
|
1507
|
+
* procedure's `maxBytes`), and each later part a file of a file field, under the field's name. The whole body arrives
|
|
1508
|
+
* within the deadline its `maxBytes` derives and holds at most `maxBytes` and each part's overhead: a declared length
|
|
1509
|
+
* past that is refused unread, and a body without one is cut off once it passes it.
|
|
1510
|
+
*/
|
|
1511
|
+
async function openMultipart(
|
|
1512
|
+
row: EndpointRow,
|
|
1513
|
+
fields: ReturnType<typeof fileFieldsOf>,
|
|
1514
|
+
request: Request,
|
|
1515
|
+
kinds: StoredFileKinds | null,
|
|
1516
|
+
): Promise<MultipartInput> {
|
|
1517
|
+
const boundary = multipartBoundary(request.headers.get('Content-Type'))
|
|
1518
|
+
if (boundary === null) {
|
|
1519
|
+
throw new HttpError(400, 'admission', `'${row.id}' takes multipart/form-data with a boundary`)
|
|
1520
|
+
}
|
|
1521
|
+
const maxBytes = maxBytesOf(row)
|
|
1522
|
+
const files = [...fields.values()].reduce((sum, field) => sum + field.max, 0)
|
|
1523
|
+
const tooLarge = () => new HttpError(413, 'too_large', `The body is larger than the ${maxBytes} bytes '${row.id}' admits`)
|
|
1524
|
+
const bodyBytes = maxBytes + (files + 1) * MULTIPART_PART_OVERHEAD
|
|
1525
|
+
const declared = Number(request.headers.get('Content-Length') ?? '')
|
|
1526
|
+
if (Number.isFinite(declared) && declared > bodyBytes) {
|
|
1527
|
+
throw tooLarge()
|
|
1528
|
+
}
|
|
1529
|
+
const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(bodyDeadlineMs(maxBytes))])
|
|
1530
|
+
const body = boundedStream(request.body ?? new Blob([]).stream(), bodyBytes)
|
|
1531
|
+
const parts = multipartParts(body.stream, boundary, { maxHeaderBytes: PROCEDURE_LIMITS.maxPartHeaderBytes })
|
|
1532
|
+
const failure = (error: unknown) => (body.exceeded() ? tooLarge() : multipartFailure(error, parts, deadline, request))
|
|
1533
|
+
const stop = () => {
|
|
1534
|
+
parts.cancel().catch(() => {})
|
|
1535
|
+
}
|
|
1536
|
+
deadline.addEventListener('abort', stop, { once: true })
|
|
1537
|
+
const close = async () => {
|
|
1538
|
+
deadline.removeEventListener('abort', stop)
|
|
1539
|
+
await parts.cancel()
|
|
1540
|
+
}
|
|
1541
|
+
let json: Readonly<Record<string, unknown>>
|
|
1249
1542
|
try {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1543
|
+
const first = await parts.next()
|
|
1544
|
+
if (first === null || first.name !== FILE_INPUT_PART || first.filename !== null) {
|
|
1545
|
+
await first?.body.cancel()
|
|
1546
|
+
throw new HttpError(400, 'multipart', `'${row.id}': the first part is the input as JSON, named '${FILE_INPUT_PART}', without a filename`)
|
|
1547
|
+
}
|
|
1548
|
+
const bytes = await readBoundedBytes(first.body, { maxBytes })
|
|
1549
|
+
if (bytes === null) {
|
|
1550
|
+
throw tooLarge()
|
|
1551
|
+
}
|
|
1552
|
+
const parsed = parseJson(bytes)
|
|
1553
|
+
if (!isRecord(parsed) || Object.keys(parsed).some((name) => fields.has(name))) {
|
|
1554
|
+
throw new HttpError(400, 'multipart', `'${row.id}': the input part is an object, and each file field arrives as a part of its own`)
|
|
1555
|
+
}
|
|
1556
|
+
json = parsed
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
const refused = failure(error)
|
|
1559
|
+
await close()
|
|
1560
|
+
throw refused
|
|
1561
|
+
}
|
|
1562
|
+
return {
|
|
1563
|
+
json,
|
|
1564
|
+
close,
|
|
1565
|
+
files: async (work) => {
|
|
1566
|
+
if (kinds === null) {
|
|
1567
|
+
throw new ServerFault('facility_unbound', `'${row.id}' takes files, and boot bound no file kinds`)
|
|
1568
|
+
}
|
|
1569
|
+
const values: Record<string, unknown> = {}
|
|
1570
|
+
const counts = new Map<string, number>()
|
|
1571
|
+
try {
|
|
1572
|
+
for (let part = await parts.next(); part !== null; part = await parts.next()) {
|
|
1573
|
+
const field = fields.get(part.name)
|
|
1574
|
+
const count = counts.get(part.name) ?? 0
|
|
1575
|
+
if (field === undefined || count >= field.max || part.filename === null) {
|
|
1576
|
+
await part.body.cancel()
|
|
1577
|
+
throw new HttpError(
|
|
1578
|
+
400,
|
|
1579
|
+
'multipart',
|
|
1580
|
+
`'${row.id}': a part is no file field of the input, a field carries more files than it admits, or a file has no filename`,
|
|
1581
|
+
)
|
|
1582
|
+
}
|
|
1583
|
+
counts.set(part.name, count + 1)
|
|
1584
|
+
const file = await kinds.receive(work, field.kind, part)
|
|
1585
|
+
if (file === null) {
|
|
1586
|
+
continue
|
|
1587
|
+
}
|
|
1588
|
+
const taken = values[part.name]
|
|
1589
|
+
values[part.name] = field.many ? [...(Array.isArray(taken) ? taken : []), file] : file
|
|
1590
|
+
}
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
throw failure(error)
|
|
1593
|
+
}
|
|
1594
|
+
return values
|
|
1595
|
+
},
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Commits the invocation of an answer that is ready: a file's object stream was opened before the commit, so a failed
|
|
1601
|
+
* commit cancels it and nothing of it is sent.
|
|
1602
|
+
*/
|
|
1603
|
+
async function commitAnswer(invocation: Invocation | null, signal: AbortSignal, answer: Response | FileDownload): Promise<void> {
|
|
1604
|
+
try {
|
|
1605
|
+
await invocation?.commit(signal)
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
if (answer instanceof FileDownload) {
|
|
1608
|
+
await answer.body.cancel()
|
|
1609
|
+
}
|
|
1610
|
+
throw error
|
|
1253
1611
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** A POST's `query`: the URL's flat parameters, parsed strictly by the declared schema; `undefined` without one. */
|
|
1615
|
+
function readQuery(
|
|
1616
|
+
row: EndpointRow,
|
|
1617
|
+
query: Schema | undefined,
|
|
1618
|
+
shape: SchemaShape | undefined,
|
|
1619
|
+
request: Request,
|
|
1620
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
1621
|
+
return query === undefined ? undefined : strictInput(row, query, paramsObject(urlParams(request), shape ?? null))
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* A redirect's answer: 303 after a POST, 302 after a GET, to a path of the application's own origin, a URL of an origin
|
|
1626
|
+
* the output lists, or an http(s) URL a verified token of this request carries as it is. Any other target fails the
|
|
1627
|
+
* invocation, so the procedure is never an open redirect.
|
|
1628
|
+
*/
|
|
1629
|
+
function redirectResponse(row: EndpointRow, shape: RedirectShape, output: unknown, request: Request, carried: ReadonlySet<string>): Response {
|
|
1630
|
+
const to = isRecord(output) && typeof output['to'] === 'string' ? output['to'] : ''
|
|
1631
|
+
const own = to.startsWith('/') && !to.startsWith('//') && !to.includes('\\')
|
|
1632
|
+
let listed = false
|
|
1633
|
+
if (!own) {
|
|
1634
|
+
try {
|
|
1635
|
+
const url = new URL(to)
|
|
1636
|
+
listed = (url.protocol === 'https:' && shape.origins.includes(url.origin))
|
|
1637
|
+
|| ((url.protocol === 'https:' || url.protocol === 'http:') && carried.has(to))
|
|
1638
|
+
} catch {
|
|
1639
|
+
listed = false
|
|
1640
|
+
}
|
|
1256
1641
|
}
|
|
1257
|
-
|
|
1642
|
+
if (!own && !listed) {
|
|
1643
|
+
throw new ServerFault(
|
|
1644
|
+
'redirect_refused',
|
|
1645
|
+
`'${row.id}' redirected to a target that is neither its own origin's path, a listed origin's URL nor a URL a verified token carries`,
|
|
1646
|
+
)
|
|
1647
|
+
}
|
|
1648
|
+
// The typed client reads the target and decides whether to navigate; a browser's form or link follows the redirect.
|
|
1649
|
+
if (asksForJson(request)) {
|
|
1650
|
+
return jsonResponse({ result: { to } }, 200)
|
|
1651
|
+
}
|
|
1652
|
+
return new Response(null, { status: request.method === 'GET' ? 302 : 303, headers: { Location: to } })
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** A request whose `Accept` names JSON and not HTML: the typed client's, never a browser navigation's. */
|
|
1656
|
+
function asksForJson(request: Request): boolean {
|
|
1657
|
+
const accept = (request.headers.get('Accept') ?? '').toLowerCase()
|
|
1658
|
+
return accept.includes('application/json') && !accept.includes('text/html')
|
|
1258
1659
|
}
|
|
1259
1660
|
|
|
1260
1661
|
/** Removes the `from` field: the only copy of that id the handler gets is the checked capability. */
|