@fabrika/app 0.8.2 → 0.9.0-cloudflare.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -24
- package/skill/SKILL.md +27 -3
- package/skill/references/app-setup.md +8 -8
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +7 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +11 -0
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +55 -4
- package/src/bun.ts +23 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +138 -0
- package/src/cloudflare/names.ts +10 -0
- package/src/cloudflare/ops.ts +246 -0
- package/src/cloudflare/r2-files.ts +172 -0
- package/src/cloudflare.ts +99 -146
- package/src/contract/app-id.ts +5 -0
- package/src/contract/artifact-schema.ts +2 -0
- package/src/contract/build.ts +35 -1
- package/src/contract/compile.ts +1 -0
- package/src/contract/context.ts +9 -0
- package/src/contract/declaration.ts +2 -0
- package/src/contract/define.ts +28 -2
- package/src/contract/kit.ts +4 -1
- package/src/contract/platform.ts +3 -2
- package/src/contract/vocabulary.ts +26 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +713 -0
- package/src/dev/output.ts +72 -0
- package/src/dev/wire.ts +136 -0
- package/src/files/archive.ts +2 -2
- package/src/files/job-names.ts +12 -0
- package/src/files/kinds.ts +19 -7
- package/src/files/scan.ts +3 -4
- package/src/files/streams.ts +3 -1
- package/src/implement/anonymous.ts +11 -1
- package/src/implement/boot.ts +61 -4
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +15 -3
- package/src/implement/dispatch.ts +249 -145
- package/src/implement/failures.ts +17 -1
- package/src/implement/faults.ts +1 -0
- package/src/implement/index.ts +1 -0
- package/src/implement/invocation.ts +8 -0
- package/src/implement/principal.ts +25 -2
- package/src/implement/terminals.ts +93 -0
- package/src/implement/workload.ts +24 -13
- package/src/jobs/report.ts +5 -1
- package/src/jobs/runner.ts +33 -17
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/testing/endpoint-access.ts +180 -137
- package/src/testing/kit.ts +20 -2
- package/src/testing/proxy.ts +5 -5
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build-recipe.d.ts +70 -0
- package/types/src/build.d.ts +29 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare/application.d.ts +42 -0
- package/types/src/cloudflare/bindings.d.ts +27 -0
- package/types/src/cloudflare/names.d.ts +6 -0
- package/types/src/cloudflare/ops.d.ts +46 -0
- package/types/src/cloudflare/r2-files.d.ts +9 -0
- package/types/src/cloudflare.d.ts +42 -0
- package/types/src/contract/app-id.d.ts +2 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +510 -0
- package/types/src/contract/declaration.d.ts +307 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +376 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +274 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +211 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +32 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +28 -0
- package/types/src/dev/wire.d.ts +126 -0
- package/types/src/disk-assets.d.ts +3 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/job-names.d.ts +9 -0
- package/types/src/files/kinds.d.ts +101 -0
- package/types/src/files/scan.d.ts +78 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +28 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +114 -0
- package/types/src/implement/bounded.d.ts +32 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +142 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +220 -0
- package/types/src/implement/egress.d.ts +14 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +39 -0
- package/types/src/implement/faults.d.ts +45 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +25 -0
- package/types/src/implement/invocation.d.ts +125 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +57 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/terminals.d.ts +33 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +151 -0
- package/types/src/jobs/schedule.d.ts +17 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +98 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +107 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +187 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- package/types/src/undumpable.d.ts +5 -0
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type EndpointRow,
|
|
17
17
|
type ExportDescription,
|
|
18
18
|
FORM_URLENCODED_TYPE,
|
|
19
|
+
type IamTerminalRpc,
|
|
19
20
|
isAttachmentShape,
|
|
20
21
|
isFileShape,
|
|
21
22
|
isRedirectShape,
|
|
@@ -67,9 +68,9 @@ import {
|
|
|
67
68
|
import type { RequestExecutionContext } from '../fetch'
|
|
68
69
|
import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
|
|
69
70
|
import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
|
|
70
|
-
import { abortedBody, bodyDeadlineMs, bootstrapPath, bootstrapResponse,
|
|
71
|
+
import { abortedBody, bodyDeadlineMs, bootstrapPath, bootstrapResponse, readBodyInTime, withBootstrap } from './anonymous'
|
|
71
72
|
import { attachmentResponse } from './attachment'
|
|
72
|
-
import { boundedStream, handlerSignal, readBoundedBytes } from './bounded'
|
|
73
|
+
import { boundedStream, type Deadline, deadline, handlerSignal, readBoundedBytes } from './bounded'
|
|
73
74
|
import type { BrowserConfig } from './browser-config'
|
|
74
75
|
import {
|
|
75
76
|
type AppCapability,
|
|
@@ -104,6 +105,7 @@ import { paramsObject, urlParams } from './params'
|
|
|
104
105
|
import { pageCallerOf, presentedBy, type Principal } from './principal'
|
|
105
106
|
import { project, ProjectionRefusedError } from './project'
|
|
106
107
|
import { bucketsOf, DEFAULT_ADMISSION, type InFlightShare, type ProcedureAdmission, requestClient } from './public'
|
|
108
|
+
import { type TerminalIdentification, terminalIdentification } from './terminals'
|
|
107
109
|
import type { SignedTokens } from './tokens'
|
|
108
110
|
import type { AppUrl } from './urls'
|
|
109
111
|
import { githubAccounts, verifyDelivery } from './verifiers'
|
|
@@ -241,6 +243,8 @@ export interface RuntimeParts {
|
|
|
241
243
|
readonly assets: SpaAssets | null
|
|
242
244
|
/** IAM's membership operations; `boot` refuses a contract that needs them when the adapter supplies none. */
|
|
243
245
|
readonly membership: IamMembership | null
|
|
246
|
+
/** IAM's identification operations, where the contract declares a terminal; `boot` refuses one without them. */
|
|
247
|
+
readonly terminals: IamTerminalRpc | null
|
|
244
248
|
/** The framework's own writes (admission buckets, webhook tables); `boot` refuses a webhook or a bucket without them. */
|
|
245
249
|
readonly framework: FrameworkTables | null
|
|
246
250
|
/** This runtime's capability mint, which `boot` alone holds, and the authority its stores are bound to. */
|
|
@@ -518,6 +522,22 @@ export class ContractRuntime {
|
|
|
518
522
|
const admission = served.admission ?? DEFAULT_ADMISSION
|
|
519
523
|
await this.#admit(served.row, admission, request, caller.subject)
|
|
520
524
|
const body = await procedureBody(served, request, this.#parts.fileKinds)
|
|
525
|
+
try {
|
|
526
|
+
return await this.#procedureInvocation(served, request, exec, caller, admission, body)
|
|
527
|
+
} finally {
|
|
528
|
+
// Whatever refuses the request once the body is open, a multipart body's stream and deadline end here.
|
|
529
|
+
await body.multipart?.close()
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async #procedureInvocation(
|
|
534
|
+
served: Extract<ServedEndpoint, { kind: 'procedure' }>,
|
|
535
|
+
request: Request,
|
|
536
|
+
exec: RequestExecutionContext | undefined,
|
|
537
|
+
caller: Caller,
|
|
538
|
+
admission: ProcedureAdmission,
|
|
539
|
+
body: Awaited<ReturnType<typeof procedureBody>>,
|
|
540
|
+
): Promise<Response | FileDownload> {
|
|
521
541
|
// Signed tokens are verified as the input is parsed; the texts they carry are what the request may redirect to.
|
|
522
542
|
const carried = new Set<string>()
|
|
523
543
|
const fields = this.#parts.tokens.verifyFields(served.row.input, body.multipart === null ? body.input : body.multipart.json, carried)
|
|
@@ -528,58 +548,64 @@ export class ContractRuntime {
|
|
|
528
548
|
const invocation = site === undefined ? null : new Invocation(frame, this.#data(), site.kind === 'query' || served.readOnly === true)
|
|
529
549
|
const kicks = kickRecorder(`'${served.row.id}'`, site?.declaration.kicks ?? [])
|
|
530
550
|
let outcome: 'open' | 'committing' = 'open'
|
|
551
|
+
let budget: Deadline | null = null
|
|
531
552
|
try {
|
|
532
553
|
const input = body.multipart === null
|
|
533
554
|
? fields
|
|
534
555
|
: this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
|
|
535
556
|
// The handler's time starts after the last body byte, so a slow upload does not eat it.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
result
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
? {}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
557
|
+
budget = handlerSignal(request, admission.timeoutMs)
|
|
558
|
+
const { signal } = budget
|
|
559
|
+
const answer = await enclosedAnswer(invocation, this.#hold(served.row.id, exec), async () => {
|
|
560
|
+
let result: unknown
|
|
561
|
+
try {
|
|
562
|
+
result = await runInRequest(frame, async () => {
|
|
563
|
+
const membership = this.#membershipRequest(served.row, caller, request, frame)
|
|
564
|
+
const access = await this.#access(served.row, caller, input, frame, membership)
|
|
565
|
+
const binding = this.#parts.fileKinds?.binding(work)
|
|
566
|
+
const ctx = Object.freeze({
|
|
567
|
+
principal: caller.principal,
|
|
568
|
+
requestId: frame.id,
|
|
569
|
+
signal,
|
|
570
|
+
client: requestClient(request, this.#parts.clientVouched),
|
|
571
|
+
url: this.#parts.url,
|
|
572
|
+
...access.context,
|
|
573
|
+
...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
|
|
574
|
+
credential: caller.token,
|
|
575
|
+
requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
|
|
576
|
+
}),
|
|
577
|
+
...this.#terminals(caller.token, request.headers.get(REQUEST_ID_HEADER) ?? frame.id),
|
|
578
|
+
...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
|
|
579
|
+
...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context, binding)),
|
|
580
|
+
// An export's consumer reaches its rows only, never the files of their owners.
|
|
581
|
+
...(site === undefined || invocation === null || served.readOnly === true
|
|
582
|
+
? {}
|
|
583
|
+
: { files: this.#files(served.row.id, invocation, site.kind, work) }),
|
|
584
|
+
...(served.binds === undefined || invocation === null
|
|
585
|
+
? {}
|
|
586
|
+
: { webhooks: this.#webhookBindings(served.row.id, served.binds, invocation, caller.subject) }),
|
|
587
|
+
...this.#signing(`'${served.row.id}'`, site?.declaration.signs ?? []),
|
|
588
|
+
kick: kicks.kick,
|
|
589
|
+
})
|
|
590
|
+
const args = { input: access.input, ctx, ...(query === undefined ? {} : { query }) }
|
|
591
|
+
const egress = served.egress
|
|
592
|
+
return egress === undefined
|
|
593
|
+
? await served.handler(args)
|
|
594
|
+
: await runAsSite(`'${served.row.id}'`, egress, async () => await served.handler(args))
|
|
565
595
|
})
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
const answer = await this.#answerOf(served, result, request, work, carried)
|
|
576
|
-
outcome = 'committing'
|
|
577
|
-
await commitAnswer(invocation, signal, answer)
|
|
596
|
+
} finally {
|
|
597
|
+
frame.end()
|
|
598
|
+
}
|
|
599
|
+
const answer = await this.#answerOf(served, result, request, work, carried)
|
|
600
|
+
outcome = 'committing'
|
|
601
|
+
await commitAnswer(invocation, signal, answer)
|
|
602
|
+
return answer
|
|
603
|
+
})
|
|
578
604
|
this.#kickAll(kicks.kicked(), exec)
|
|
579
605
|
return answer
|
|
580
606
|
} finally {
|
|
607
|
+
budget?.clear()
|
|
581
608
|
await invocation?.rollback()
|
|
582
|
-
await body.multipart?.close()
|
|
583
609
|
// Only a known rollback deletes what the request staged; after a commit that may have happened, the sweep decides.
|
|
584
610
|
if (outcome === 'open') {
|
|
585
611
|
await this.#parts.fileKinds?.release(work)
|
|
@@ -671,6 +697,22 @@ export class ContractRuntime {
|
|
|
671
697
|
return data
|
|
672
698
|
}
|
|
673
699
|
|
|
700
|
+
/** Where an invocation of `site` reports a long hold of its transaction: `FailureReports`, its delivery kept alive like a failure's. */
|
|
701
|
+
#hold(site: string, exec: RequestExecutionContext | undefined): Hold {
|
|
702
|
+
return {
|
|
703
|
+
site,
|
|
704
|
+
held: (heldMs) => {
|
|
705
|
+
// The transaction already settled: a report that cannot be made, kept or sent never changes the answer.
|
|
706
|
+
try {
|
|
707
|
+
const delivery = this.#parts.failures.held(site, heldMs).catch(() => undefined)
|
|
708
|
+
exec?.waitUntil(delivery)
|
|
709
|
+
} catch {
|
|
710
|
+
// Dropped, like a delivery that fails.
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
674
716
|
/** The caller's authority at IAM for this request, when the endpoint's access form names a dimension and an action. */
|
|
675
717
|
#membershipRequest(row: CredentialedEndpoint, caller: Caller, request: Request, frame: RequestFrame): MembershipRequest | null {
|
|
676
718
|
const iam = this.#parts.membership
|
|
@@ -687,6 +729,12 @@ export class ContractRuntime {
|
|
|
687
729
|
}
|
|
688
730
|
}
|
|
689
731
|
|
|
732
|
+
/** `ctx.terminals`, with the credential IAM takes the request's application from: the caller's, or the workload's. */
|
|
733
|
+
#terminals(credential: string, requestId: string): { readonly terminals?: TerminalIdentification } {
|
|
734
|
+
const rpc = this.#parts.terminals
|
|
735
|
+
return rpc === null ? {} : { terminals: terminalIdentification(rpc, { app: this.#parts.audience, credential, requestId }) }
|
|
736
|
+
}
|
|
737
|
+
|
|
690
738
|
/** `ctx.members` and `ctx.links`, for exactly the endpoints the artifact gives them. */
|
|
691
739
|
#facilities(
|
|
692
740
|
facilities: Facilities,
|
|
@@ -755,17 +803,21 @@ export class ContractRuntime {
|
|
|
755
803
|
}
|
|
756
804
|
}
|
|
757
805
|
|
|
758
|
-
/**
|
|
806
|
+
/**
|
|
807
|
+
* The values of `dimension` in which the caller holds `action`, directly or through a container, and its grants on the
|
|
808
|
+
* dimensions inside it, so a list returns every row a single-object read admits (S4); all for a global grant.
|
|
809
|
+
*/
|
|
759
810
|
#within(caller: Caller, action: string, dimension: string, frame: RequestFrame): Within<string, string> {
|
|
760
811
|
const direct = scopedValues(caller.perms, action, dimension)
|
|
761
812
|
if (direct === null) {
|
|
762
813
|
return this.#parts.mint.within(frame, dimension, action, caller.subject, 'all')
|
|
763
814
|
}
|
|
764
815
|
const held = this.#heldContainers(caller.perms, action, dimension)
|
|
765
|
-
|
|
816
|
+
const inside = this.#heldContained(caller.perms, action, dimension)
|
|
817
|
+
if (direct.length === 0 && held.length === 0 && inside.length === 0) {
|
|
766
818
|
throw new ForbiddenError(`The caller holds '${action}' in no ${dimension}`)
|
|
767
819
|
}
|
|
768
|
-
return this.#parts.mint.within(frame, dimension, action, caller.subject, direct, held)
|
|
820
|
+
return this.#parts.mint.within(frame, dimension, action, caller.subject, direct, held, inside)
|
|
769
821
|
}
|
|
770
822
|
|
|
771
823
|
/** A scope value the caller acts in: without a grant on it or on a container of it, 403 whether it exists or not. */
|
|
@@ -908,7 +960,7 @@ export class ContractRuntime {
|
|
|
908
960
|
admission.repeatableToken,
|
|
909
961
|
)
|
|
910
962
|
}
|
|
911
|
-
return await this.#publicInvocation(served, request, body, fields, query, carried, authority
|
|
963
|
+
return await this.#publicInvocation(served, request, body, fields, query, carried, authority, exec)
|
|
912
964
|
} finally {
|
|
913
965
|
await multipart?.close()
|
|
914
966
|
release()
|
|
@@ -974,9 +1026,10 @@ export class ContractRuntime {
|
|
|
974
1026
|
fields: Readonly<Record<string, unknown>>,
|
|
975
1027
|
query: Readonly<Record<string, unknown>> | undefined,
|
|
976
1028
|
carried: Set<string>,
|
|
977
|
-
perms: readonly PermissionEntry[],
|
|
1029
|
+
authority: { readonly perms: readonly PermissionEntry[]; readonly token: string },
|
|
978
1030
|
exec: RequestExecutionContext | undefined,
|
|
979
1031
|
): Promise<Response | FileDownload> {
|
|
1032
|
+
const { perms } = authority
|
|
980
1033
|
const kicks = kickRecorder(`'${served.row.id}'`, served.site.declaration.kicks)
|
|
981
1034
|
const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.site.kind === 'query', endpoint: served.row.id })
|
|
982
1035
|
const work = new FileWork(frame, frame.id)
|
|
@@ -995,55 +1048,62 @@ export class ContractRuntime {
|
|
|
995
1048
|
return this.#parts.mint.app(frame, action, subject)
|
|
996
1049
|
}
|
|
997
1050
|
let outcome: 'open' | 'committing' = 'open'
|
|
1051
|
+
let budget: Deadline | null = null
|
|
998
1052
|
try {
|
|
999
1053
|
// The file parts come after the challenge, so a refused visitor stores nothing.
|
|
1000
1054
|
const input = body.multipart === null
|
|
1001
1055
|
? fields
|
|
1002
1056
|
: this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
|
|
1003
1057
|
// The handler's time starts after the last body byte, so a slow upload does not eat it.
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
result
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1058
|
+
budget = handlerSignal(request, served.admission.timeoutMs)
|
|
1059
|
+
const { signal } = budget
|
|
1060
|
+
const answer = await enclosedAnswer(invocation, this.#hold(served.row.id, exec), async () => {
|
|
1061
|
+
let result: unknown
|
|
1062
|
+
try {
|
|
1063
|
+
result = await runInRequest(frame, async () => {
|
|
1064
|
+
const handles = invocation.handles(
|
|
1065
|
+
served.site.kind,
|
|
1066
|
+
served.site.declaration,
|
|
1067
|
+
subject,
|
|
1068
|
+
{ carried },
|
|
1069
|
+
this.#parts.fileKinds?.binding(work),
|
|
1070
|
+
)
|
|
1071
|
+
const where = `'${served.row.id}'`
|
|
1072
|
+
const ctx = Object.freeze({
|
|
1073
|
+
principal: Object.freeze({ kind: 'public', endpoint: served.row.id }),
|
|
1074
|
+
requestId: frame.id,
|
|
1075
|
+
signal,
|
|
1076
|
+
client: requestClient(request, this.#parts.clientVouched),
|
|
1077
|
+
url: this.#parts.url,
|
|
1078
|
+
app,
|
|
1079
|
+
db: Object.freeze({ app: (action: string) => handles.app(app(action)) }),
|
|
1080
|
+
...this.#signing(`'${served.row.id}'`, served.site.declaration.signs),
|
|
1081
|
+
files: storedFilesHandle(this.#parts.fileKinds, work, {
|
|
1082
|
+
where,
|
|
1083
|
+
check: boundTo(frame, () => false, 'ctx.files'),
|
|
1084
|
+
writes: served.site.kind !== 'query',
|
|
1085
|
+
}),
|
|
1086
|
+
...this.#terminals(authority.token, request.headers.get(REQUEST_ID_HEADER) ?? frame.id),
|
|
1087
|
+
kick: kicks.kick,
|
|
1088
|
+
})
|
|
1089
|
+
const args = { input, ctx, ...(query === undefined ? {} : { query }) }
|
|
1090
|
+
return await runAsSite(`'${served.row.id}'`, served.site.declaration.egress, async () => await served.handler(args))
|
|
1031
1091
|
})
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
await commitAnswer(invocation, signal, answer)
|
|
1092
|
+
} finally {
|
|
1093
|
+
frame.end()
|
|
1094
|
+
}
|
|
1095
|
+
const output = served.row.output
|
|
1096
|
+
const answer = output !== null && isFileShape(output)
|
|
1097
|
+
? await this.#fileKinds(served.row.id).download(work, served.row.id, output.kind, result)
|
|
1098
|
+
: outputResponse(served.row, served.output, result, request, carried)
|
|
1099
|
+
outcome = 'committing'
|
|
1100
|
+
await commitAnswer(invocation, signal, answer)
|
|
1101
|
+
return answer
|
|
1102
|
+
})
|
|
1044
1103
|
this.#kickAll(kicks.kicked(), exec)
|
|
1045
1104
|
return answer
|
|
1046
1105
|
} finally {
|
|
1106
|
+
budget?.clear()
|
|
1047
1107
|
await invocation.rollback()
|
|
1048
1108
|
if (outcome === 'open') {
|
|
1049
1109
|
await this.#parts.fileKinds?.release(work)
|
|
@@ -1066,7 +1126,7 @@ export class ContractRuntime {
|
|
|
1066
1126
|
): Promise<Response> {
|
|
1067
1127
|
admitContentType(served.row, request)
|
|
1068
1128
|
const limit = maxBytesOf(served.row)
|
|
1069
|
-
const body = await
|
|
1129
|
+
const body = await readBodyInTime(request, limit)
|
|
1070
1130
|
const tables = this.#framework().webhooks
|
|
1071
1131
|
const bindingId = params[WEBHOOK_BINDING_PARAMETER] ?? ''
|
|
1072
1132
|
const binding = BINDING_ID.test(bindingId) ? await tables.binding(served.webhook, bindingId) : null
|
|
@@ -1130,60 +1190,63 @@ export class ContractRuntime {
|
|
|
1130
1190
|
// The claim stays outside the delivery's transaction, and its completion commits inside it, with the work.
|
|
1131
1191
|
const invocation = site === undefined ? null : new Invocation(frame, this.#data(), false)
|
|
1132
1192
|
const kicks = kickRecorder(`webhook '${served.webhook}'`, site?.kicks ?? [])
|
|
1133
|
-
const
|
|
1193
|
+
const budget = handlerSignal(request, budgetMs)
|
|
1194
|
+
const { signal } = budget
|
|
1134
1195
|
let committing = false
|
|
1135
1196
|
try {
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1197
|
+
await enclosed(invocation, this.#hold(served.row.id, exec), async () => {
|
|
1198
|
+
try {
|
|
1199
|
+
await runInRequest(frame, async () => {
|
|
1200
|
+
const subject = webhookSubject(binding.id)
|
|
1201
|
+
const reach = this.#webhookReach(served, frame, subject, binding.scope.value, chain)
|
|
1202
|
+
const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
|
|
1203
|
+
const delivery = Object.freeze({ key })
|
|
1204
|
+
const handles = site === undefined || invocation === null
|
|
1205
|
+
? null
|
|
1206
|
+
: invocation.handles('mutation', site, subject, 'declared', this.#parts.fileKinds?.binding(work))
|
|
1207
|
+
const data = site === undefined || invocation === null || handles === null
|
|
1208
|
+
? {}
|
|
1209
|
+
: {
|
|
1210
|
+
db: siteData(handles, (action) => handles.app(reach.app(action))),
|
|
1211
|
+
sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
|
|
1212
|
+
}
|
|
1213
|
+
const client = requestClient(request, this.#parts.clientVouched)
|
|
1214
|
+
const args = {
|
|
1215
|
+
input,
|
|
1216
|
+
ctx: Object.freeze({
|
|
1217
|
+
principal,
|
|
1218
|
+
requestId: frame.id,
|
|
1219
|
+
signal,
|
|
1220
|
+
client,
|
|
1221
|
+
url: this.#parts.url,
|
|
1222
|
+
...reach.context,
|
|
1223
|
+
delivery,
|
|
1224
|
+
files: storedFilesHandle(this.#parts.fileKinds, work, {
|
|
1225
|
+
where: `webhook '${served.webhook}'`,
|
|
1226
|
+
check: boundTo(frame, () => false, 'ctx.files'),
|
|
1227
|
+
writes: true,
|
|
1228
|
+
}),
|
|
1229
|
+
...this.#signing(`webhook '${served.webhook}'`, site?.signs ?? []),
|
|
1230
|
+
kick: kicks.kick,
|
|
1231
|
+
...data,
|
|
1166
1232
|
}),
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1185
|
-
committing = true
|
|
1186
|
-
await invocation?.commit(signal)
|
|
1233
|
+
}
|
|
1234
|
+
if (site === undefined) {
|
|
1235
|
+
await served.handler(args)
|
|
1236
|
+
} else {
|
|
1237
|
+
await runAsSite(`webhook '${served.webhook}'`, site.egress, async () => await served.handler(args))
|
|
1238
|
+
}
|
|
1239
|
+
})
|
|
1240
|
+
} finally {
|
|
1241
|
+
frame.end()
|
|
1242
|
+
}
|
|
1243
|
+
const using = invocation === null ? undefined : await invocation.frameworkSite('the delivery claim').session()
|
|
1244
|
+
if (!(await tables.completeDelivery(claim, using))) {
|
|
1245
|
+
throw new ServerFault('webhook_claim_lost', `the claim of a delivery to '${served.webhook}' expired and another copy took it over`)
|
|
1246
|
+
}
|
|
1247
|
+
committing = true
|
|
1248
|
+
await invocation?.commit(signal)
|
|
1249
|
+
})
|
|
1187
1250
|
this.#kickAll(kicks.kicked(), exec)
|
|
1188
1251
|
} catch (error) {
|
|
1189
1252
|
await invocation?.rollback()
|
|
@@ -1196,6 +1259,8 @@ export class ContractRuntime {
|
|
|
1196
1259
|
console.error(`fabrika: ${served.row.id}: delivery_failed; the provider's redelivery runs it again`)
|
|
1197
1260
|
await tables.releaseDelivery(claim)
|
|
1198
1261
|
throw error
|
|
1262
|
+
} finally {
|
|
1263
|
+
budget.clear()
|
|
1199
1264
|
}
|
|
1200
1265
|
return jsonResponse({ result: { received: true } }, 200)
|
|
1201
1266
|
}
|
|
@@ -1257,6 +1322,14 @@ export class ContractRuntime {
|
|
|
1257
1322
|
return values.length === 0 ? [] : [{ dimension: container, values }]
|
|
1258
1323
|
})
|
|
1259
1324
|
}
|
|
1325
|
+
|
|
1326
|
+
/** The caller's grants of `action` on every dimension that lies inside `dimension`. */
|
|
1327
|
+
#heldContained(perms: PermissionEntry[], action: string, dimension: string): ContainerGrant[] {
|
|
1328
|
+
return [...this.#parts.containers].flatMap(([inner, containers]) => {
|
|
1329
|
+
const values = containers.includes(dimension) ? scopedValues(perms, action, inner) ?? [] : []
|
|
1330
|
+
return values.length === 0 ? [] : [{ dimension: inner, values }]
|
|
1331
|
+
})
|
|
1332
|
+
}
|
|
1260
1333
|
}
|
|
1261
1334
|
|
|
1262
1335
|
/** `ctx.db` (by the access form) and `ctx.sql` of a staged-kit endpoint: the declared tag, or one that refuses. */
|
|
@@ -1475,7 +1548,7 @@ async function readInput(
|
|
|
1475
1548
|
const limit = served.row.admission?.maxBytes ?? null
|
|
1476
1549
|
const text = limit === null
|
|
1477
1550
|
? await request.text()
|
|
1478
|
-
: new TextDecoder().decode(await
|
|
1551
|
+
: new TextDecoder().decode(await readBodyInTime(request, limit))
|
|
1479
1552
|
if (mediaType(request.headers.get('Content-Type')) === FORM_URLENCODED_TYPE) {
|
|
1480
1553
|
body = paramsObject(new URLSearchParams(text), served.row.input)
|
|
1481
1554
|
} else if (text.trim() !== '') {
|
|
@@ -1547,16 +1620,17 @@ async function openMultipart(
|
|
|
1547
1620
|
if (Number.isFinite(declared) && declared > bodyBytes) {
|
|
1548
1621
|
throw tooLarge()
|
|
1549
1622
|
}
|
|
1550
|
-
const
|
|
1623
|
+
const timer = deadline(bodyDeadlineMs(maxBytes), request.signal)
|
|
1551
1624
|
const body = boundedStream(request.body ?? new Blob([]).stream(), bodyBytes)
|
|
1552
1625
|
const parts = multipartParts(body.stream, boundary, { maxHeaderBytes: PROCEDURE_LIMITS.maxPartHeaderBytes })
|
|
1553
|
-
const failure = (error: unknown) => (body.exceeded() ? tooLarge() : multipartFailure(error, parts,
|
|
1626
|
+
const failure = (error: unknown) => (body.exceeded() ? tooLarge() : multipartFailure(error, parts, timer.signal, request))
|
|
1554
1627
|
const stop = () => {
|
|
1555
1628
|
parts.cancel().catch(() => {})
|
|
1556
1629
|
}
|
|
1557
|
-
|
|
1630
|
+
timer.signal.addEventListener('abort', stop, { once: true })
|
|
1558
1631
|
const close = async () => {
|
|
1559
|
-
|
|
1632
|
+
timer.signal.removeEventListener('abort', stop)
|
|
1633
|
+
timer.clear()
|
|
1560
1634
|
await parts.cancel()
|
|
1561
1635
|
}
|
|
1562
1636
|
let json: Readonly<Record<string, unknown>>
|
|
@@ -1617,6 +1691,36 @@ async function openMultipart(
|
|
|
1617
1691
|
}
|
|
1618
1692
|
}
|
|
1619
1693
|
|
|
1694
|
+
/** The site an invocation's long hold is logged under, and where it is reported. */
|
|
1695
|
+
interface Hold {
|
|
1696
|
+
readonly site: string
|
|
1697
|
+
readonly held: (heldMs: number) => void
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/** Runs `work` in the invocation's transaction (`Invocation.enclose`), or as is without one. */
|
|
1701
|
+
function enclosed<T>(invocation: Invocation | null, hold: Hold, work: () => Promise<T>): Promise<T> {
|
|
1702
|
+
return invocation === null ? work() : invocation.enclose(work, hold.site, hold.held)
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* An answer's work, from the handler to its commit, in the invocation's transaction. Where the transaction encloses
|
|
1707
|
+
* the work (SQLite) its COMMIT runs after `commitAnswer`, so a failed one cancels a file's object stream here.
|
|
1708
|
+
*/
|
|
1709
|
+
async function enclosedAnswer<A extends Response | FileDownload>(invocation: Invocation | null, hold: Hold, work: () => Promise<A>): Promise<A> {
|
|
1710
|
+
let answer: A | undefined
|
|
1711
|
+
try {
|
|
1712
|
+
return await enclosed(invocation, hold, async () => {
|
|
1713
|
+
answer = await work()
|
|
1714
|
+
return answer
|
|
1715
|
+
})
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
if (answer instanceof FileDownload) {
|
|
1718
|
+
await answer.body.cancel()
|
|
1719
|
+
}
|
|
1720
|
+
throw error
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1620
1724
|
/**
|
|
1621
1725
|
* Commits the invocation of an answer that is ready: a file's object stream was opened before the commit, so a failed
|
|
1622
1726
|
* commit cancels it and nothing of it is sent.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// fixed codes and bounded identifiers only, never an error's name or message: application code, drivers and even the
|
|
3
3
|
// framework's own refusals can put request data there. The event adds what was thrown as its cause chain, scrubbed of
|
|
4
4
|
// the environment's secrets and of credential patterns, a framework failure's message replaced by its catalog text
|
|
5
|
-
// (ADR-0059).
|
|
5
|
+
// (ADR-0059). A writing invocation that held its Durable Object's transaction too long is an event on the same budget.
|
|
6
6
|
|
|
7
7
|
import { CAPABILITY_ERRORS, CapabilityError, QUERY_REFUSALS, QueryRefusedError } from '@fabrika/db/framework'
|
|
8
8
|
import { isStructuralError } from '../errors'
|
|
@@ -127,4 +127,20 @@ export class FailureReports {
|
|
|
127
127
|
error: failure.error,
|
|
128
128
|
})
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A writing invocation that held its object's transaction past `@fabrika/db`'s report threshold, which already
|
|
133
|
+
* logged it. Never rejects; the delivery may outlive the response like a failure's.
|
|
134
|
+
*/
|
|
135
|
+
held(endpoint: string, heldMs: number): Promise<void> {
|
|
136
|
+
if (this.#reporter === null || !this.#budget.admit()) {
|
|
137
|
+
return Promise.resolve()
|
|
138
|
+
}
|
|
139
|
+
return this.#reporter.report({
|
|
140
|
+
fingerprint: ['fabrika-transaction-held', endpoint],
|
|
141
|
+
type: 'TransactionHeld',
|
|
142
|
+
message: `endpoint '${endpoint}' held its object's transaction for ${heldMs} ms`,
|
|
143
|
+
tags: { endpoint, code: 'transaction_held', held_ms: String(heldMs) },
|
|
144
|
+
})
|
|
145
|
+
}
|
|
130
146
|
}
|
package/src/implement/faults.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const SERVER_FAULTS = {
|
|
|
19
19
|
webhook_unverifiable: 'a webhook verifies a signature over its public URL (Twilio), and the environment has no FABRIKA_APP_ORIGIN',
|
|
20
20
|
webhook_claim_lost: "a delivery's claim expired and another copy of the delivery took it over before this one committed; nothing it wrote is kept",
|
|
21
21
|
workload_unauthorized: "the workload's key or token does not carry the authority its declaration needs",
|
|
22
|
+
terminal_refused: "IAM refused the application's own credential for an identification at a terminal",
|
|
22
23
|
request_cancelled: 'the request was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept',
|
|
23
24
|
file_object_missing: "a file's object is missing from storage",
|
|
24
25
|
file_object_mismatch: "a file's object has another size than its row records",
|
package/src/implement/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type {
|
|
|
53
53
|
} from './membership'
|
|
54
54
|
export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, PublicPrincipal, ServicePrincipal, UserPrincipal } from './principal'
|
|
55
55
|
export { type AppDeps, defineDeps, serve, type ServeOptions } from './serve'
|
|
56
|
+
export type { AssertedIdentity, OpenIdentification, TerminalIdentification } from './terminals'
|
|
56
57
|
export type {
|
|
57
58
|
AccessContext,
|
|
58
59
|
ConnectCall,
|
|
@@ -321,6 +321,14 @@ export class Invocation {
|
|
|
321
321
|
return { check: boundTo(this.#frame, () => transaction.ended, handle), session: () => transaction.session() }
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Runs `work`, from the handler to `commit`, in this invocation's transaction. On SQLite it encloses `work`, and a
|
|
326
|
+
* hold past `@fabrika/db`'s report threshold reaches `held`, logged under `site`.
|
|
327
|
+
*/
|
|
328
|
+
enclose<T>(work: () => Promise<T>, site: string, held: (heldMs: number) => void): Promise<T> {
|
|
329
|
+
return this.#transaction.enclose(work, { site, held })
|
|
330
|
+
}
|
|
331
|
+
|
|
324
332
|
/** Commits; a request the caller abandoned meanwhile rolls back instead. */
|
|
325
333
|
async commit(signal: AbortSignal | null): Promise<void> {
|
|
326
334
|
if (signal?.aborted === true) {
|