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