@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
package/src/implement/boot.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
FABRIKA_CONTRACT_DIGEST,
|
|
17
17
|
FABRIKA_IAM_APP_ID,
|
|
18
18
|
FABRIKA_PROXY_SECRET,
|
|
19
|
-
type FormMountArtifact,
|
|
20
19
|
type IamMembershipRpc,
|
|
21
20
|
isLoopbackHost,
|
|
22
21
|
type Jwks,
|
|
@@ -24,15 +23,7 @@ import {
|
|
|
24
23
|
} from '@fabrika/auth-core'
|
|
25
24
|
import type { Database } from '@fabrika/db'
|
|
26
25
|
import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
27
|
-
import {
|
|
28
|
-
bindScopedDatabase,
|
|
29
|
-
type DataAccess,
|
|
30
|
-
dataAccessOf,
|
|
31
|
-
type FrameworkTables,
|
|
32
|
-
frameworkTablesOf,
|
|
33
|
-
isScopedDatabase,
|
|
34
|
-
type WebhookTables,
|
|
35
|
-
} from '@fabrika/db/framework'
|
|
26
|
+
import { bindScopedDatabase, type DataAccess, dataAccessOf, type FrameworkTables, frameworkTablesOf, isScopedDatabase } from '@fabrika/db/framework'
|
|
36
27
|
import type { DataKind } from '@fabrika/db/tables'
|
|
37
28
|
import type { EmailProviderRegistry } from '@fabrika/email'
|
|
38
29
|
import { builtinEmailProviders } from '@fabrika/email/providers'
|
|
@@ -41,11 +32,12 @@ import type { FileObjectStore } from '@fabrika/platform'
|
|
|
41
32
|
import { compileContract } from '../contract/compile'
|
|
42
33
|
import { exportDescription } from '../contract/connect'
|
|
43
34
|
import { isProcedure, type ProcedureDeclaration, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
|
|
44
|
-
import {
|
|
45
|
-
import type { Schema } from '../contract/define'
|
|
35
|
+
import { challengeRowId, exportBase, spaPrefixRowId } from '../contract/defaults'
|
|
36
|
+
import type { AnyContract, Schema } from '../contract/define'
|
|
46
37
|
import { kitOriginOf } from '../contract/handled'
|
|
38
|
+
import { schemaFromShape } from '../contract/schema-from-shape'
|
|
47
39
|
import type { RequestExecutionContext } from '../fetch'
|
|
48
|
-
import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS,
|
|
40
|
+
import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, storedFileKinds } from '../files/kinds'
|
|
49
41
|
import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, type FileScanner, SCAN_LIMITS } from '../files/scan'
|
|
50
42
|
import { type MailStandIn, resolveMailTransports } from '../jobs/mail'
|
|
51
43
|
import { logReporter, type OperationsReporter, operationsReporter, operationsSettings, type ReportSource } from '../jobs/report'
|
|
@@ -53,28 +45,30 @@ import { bindJobs, type ContractJobs, type HandlerErrorSink } from '../jobs/runn
|
|
|
53
45
|
import { environmentScrubber } from '../jobs/scrub'
|
|
54
46
|
import { browserConfigs } from './browser-config'
|
|
55
47
|
import { createCapabilityIssuer, requestIsReadOnly } from './capability'
|
|
56
|
-
import {
|
|
48
|
+
import type { ChallengeFetch, ChallengeWork } from './challenge'
|
|
57
49
|
import { type ConnectFetch, wireImports } from './connect'
|
|
58
50
|
import {
|
|
59
51
|
ContractRuntime,
|
|
60
52
|
type Facilities,
|
|
61
|
-
type FormAdmission,
|
|
62
53
|
NO_FACILITIES,
|
|
54
|
+
type ProcedureQuery,
|
|
63
55
|
proxySecretDigest,
|
|
64
56
|
type ServedEndpoint,
|
|
65
57
|
type ServedSite,
|
|
66
58
|
type SpaAssets,
|
|
67
59
|
} from './dispatch'
|
|
60
|
+
import { installEgressGuard, platformCall, platformFacility } from './egress'
|
|
68
61
|
import type { CapturedEnvironment } from './environment'
|
|
69
62
|
import { FailureReports } from './failures'
|
|
70
|
-
import { ServerFault } from './faults'
|
|
71
|
-
import type { SubmissionFields } from './form-admission'
|
|
72
63
|
import { applicationPolicy } from './headers'
|
|
73
64
|
import type { ContractDatabase, ContractImplementation, ImplementationDatabase } from './implement'
|
|
74
65
|
import { approvedDeclaration, approvedReach, isSiteHandler, type SiteDeclaration } from './invocation'
|
|
75
66
|
import type { ReadDirectory } from './membership'
|
|
76
|
-
import {
|
|
77
|
-
import { type
|
|
67
|
+
import { type AdmissionOptions, InFlightShare, type ProcedureAdmission, procedureAdmission, publicShareOf } from './public'
|
|
68
|
+
import { readTokenKeys, type RuntimePurpose, SignedTokens } from './tokens'
|
|
69
|
+
import { appUrl, readAppOrigin } from './urls'
|
|
70
|
+
import type { BindableWebhook } from './webhook'
|
|
71
|
+
import { type WorkloadCredential, workloadCredentials, workloadSite, type WorkloadTokenSource } from './workload'
|
|
78
72
|
|
|
79
73
|
/** Where the token verifier gets IAM's public keys: the IAM binding on Cloudflare, the issuer's JWKS elsewhere. */
|
|
80
74
|
export interface IamKeySource {
|
|
@@ -95,10 +89,10 @@ export interface BootOptions {
|
|
|
95
89
|
* authenticated hop (ADR-0022 rule 1). A secret that is set is checked either way.
|
|
96
90
|
*/
|
|
97
91
|
readonly requireProxySecret?: boolean
|
|
98
|
-
/** The object storage of the
|
|
92
|
+
/** The object storage of the stored files, which the Bun adapter opens. Without it, a contract with file kinds does not boot. */
|
|
99
93
|
readonly files?: FileObjectStore
|
|
100
94
|
/**
|
|
101
|
-
* The malware scanner of the scan job of `scan: true`
|
|
95
|
+
* The malware scanner of the scan job of `scan: true` file kinds, which the Bun adapter supplies to a job run
|
|
102
96
|
* (`clamscanScanner`). Without it the scan job leaves every quarantined file quarantined and reports.
|
|
103
97
|
*/
|
|
104
98
|
readonly scanner?: FileScanner
|
|
@@ -119,15 +113,18 @@ export interface BootOptions {
|
|
|
119
113
|
readonly workloadTokens?: WorkloadTokenSource
|
|
120
114
|
/** What posts a report to Operations' ingest; `fetch` by default. */
|
|
121
115
|
readonly operationsFetch?: (request: Request) => Promise<Response>
|
|
122
|
-
/** What asks a
|
|
116
|
+
/** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
|
|
123
117
|
readonly challengeFetch?: ChallengeFetch
|
|
124
118
|
/**
|
|
125
|
-
* The developer's own machine only (`fabrika app dev`): the token a
|
|
126
|
-
* Turnstile's always-passing test widget hands out for every
|
|
119
|
+
* The developer's own machine only (`fabrika app dev`): the token a challenge admits more than once, which
|
|
120
|
+
* Turnstile's always-passing test widget hands out for every call. Every other token stays single use.
|
|
127
121
|
*/
|
|
128
122
|
readonly repeatableChallengeToken?: string
|
|
129
|
-
/**
|
|
130
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The work of every ALTCHA challenge in place of the contract's `cost`: the test kit's and `fabrika app dev`'s
|
|
125
|
+
* (`MINIMAL_CHALLENGE_WORK`), so a test solves one in milliseconds. A deployed application never sets it.
|
|
126
|
+
*/
|
|
127
|
+
readonly challengeWork?: ChallengeWork
|
|
131
128
|
/**
|
|
132
129
|
* The developer's own machine only (`fabrika app dev`): what a job's handler threw, which every log of a deployed
|
|
133
130
|
* application withholds because it may carry a credential.
|
|
@@ -145,8 +142,19 @@ export interface BootOptions {
|
|
|
145
142
|
* `FABRIKA_EMAIL_*` nor `serve`'s `mail`.
|
|
146
143
|
*/
|
|
147
144
|
readonly mailStandIn?: MailStandIn
|
|
145
|
+
/** The connections the application's pool holds: a quarter of them, at least one, is the public procedures' share. */
|
|
146
|
+
readonly connections?: number
|
|
147
|
+
/**
|
|
148
|
+
* `'vouched'` where the proxy's `X-Fabrika-Client-Ip` is the visitor's own address, so `ctx.client.ip` reads it. A
|
|
149
|
+
* runtime behind a balancer whose range the platform does not trust (Zerops, until it does) leaves it unset: the ip is
|
|
150
|
+
* then `null`.
|
|
151
|
+
*/
|
|
152
|
+
readonly clientAddress?: 'vouched'
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
/** The pool size a runtime adapter that names none is assumed to have: the Bun adapter's default. */
|
|
156
|
+
const DEFAULT_CONNECTIONS = 10
|
|
157
|
+
|
|
150
158
|
export interface ContractApp {
|
|
151
159
|
/** `exec` keeps a failure's report to Operations alive past the response (a Worker's `waitUntil`). */
|
|
152
160
|
fetch(request: Request, exec?: RequestExecutionContext): Promise<Response>
|
|
@@ -270,9 +278,6 @@ function unservable(
|
|
|
270
278
|
// Every link request re-reads where the object lies; without the table that records it, nothing could.
|
|
271
279
|
problems.push(`link mount '${name}' shares a '${mount.dimension}', but no table in src/db/tables.ts owns that dimension to locate it`)
|
|
272
280
|
}
|
|
273
|
-
if (mount.kind === 'webhook' && mount.boundTo.kind === 'app') {
|
|
274
|
-
problems.push(`webhook '${name}' is bound to the whole application; this version serves a webhook bound to a dimension (boundTo: '<dimension>')`)
|
|
275
|
-
}
|
|
276
281
|
}
|
|
277
282
|
for (const row of artifact.endpoints) {
|
|
278
283
|
if (row.requirement?.kind !== 'action') {
|
|
@@ -422,6 +427,11 @@ function boundHandler(
|
|
|
422
427
|
return null
|
|
423
428
|
}
|
|
424
429
|
|
|
430
|
+
/** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
|
|
431
|
+
function egressOf(bound: BoundHandler): { readonly egress?: readonly string[] } {
|
|
432
|
+
return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
|
|
433
|
+
}
|
|
434
|
+
|
|
425
435
|
/** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
|
|
426
436
|
function siteFacilities(facilities: Facilities, declaration: SiteDeclaration | null): Facilities {
|
|
427
437
|
if (declaration === null) {
|
|
@@ -504,24 +514,17 @@ function asksIam(served: ServedEndpoint): boolean {
|
|
|
504
514
|
return creates || served.facilities.members === 'manage' || served.facilities.links.length > 0
|
|
505
515
|
}
|
|
506
516
|
|
|
507
|
-
/** A
|
|
517
|
+
/** A webhook and a procedure's buckets write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
|
|
508
518
|
function frameworkProblems(artifact: ContractArtifact, framework: FrameworkTables | null, problems: string[]): void {
|
|
519
|
+
const counted = Object.entries(artifact.procedures ?? {}).find(([, rules]) => Object.values(rules.rate).some((bucket) => bucket !== null))
|
|
520
|
+
if (counted !== undefined && framework === null) {
|
|
521
|
+
problems.push(
|
|
522
|
+
`procedure '${counted[0]}' counts its calls in buckets, which live in @fabrika/db: pass serve(contract, { database: scoped(tables) })`,
|
|
523
|
+
)
|
|
524
|
+
}
|
|
509
525
|
for (const [name, mount] of Object.entries(artifact.mounts)) {
|
|
510
|
-
if (mount.kind
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
if (framework === null) {
|
|
514
|
-
problems.push(`mount '${name}' is a ${mount.kind}, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
515
|
-
continue
|
|
516
|
-
}
|
|
517
|
-
if (mount.kind === 'form') {
|
|
518
|
-
const input = artifact.endpoints.find((row) => row.id === name)?.input
|
|
519
|
-
const submitted = submissionFieldsOf(mount)
|
|
520
|
-
const fields = (input?.type === 'object' ? Object.keys(input.fields) : []).filter((field) =>
|
|
521
|
-
field !== submitted.honeypot && !(submitted.challenge && field === FORM_CHALLENGE_FIELD)
|
|
522
|
-
)
|
|
523
|
-
// A file field's column receives the id of the file the submission carried.
|
|
524
|
-
problems.push(...framework.inboxProblems(name, [...fields, ...Object.keys(mount.files ?? {})]))
|
|
526
|
+
if (mount.kind === 'webhook' && framework === null) {
|
|
527
|
+
problems.push(`mount '${name}' is a webhook, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
525
528
|
}
|
|
526
529
|
}
|
|
527
530
|
}
|
|
@@ -530,108 +533,137 @@ function isCredentialed(row: EndpointRow | undefined): row is CredentialedEndpoi
|
|
|
530
533
|
return row !== undefined && row.requirement !== null
|
|
531
534
|
}
|
|
532
535
|
|
|
533
|
-
|
|
534
|
-
|
|
536
|
+
/** A POST procedure's `http.query`, as the field its served endpoint carries: parsed by the approved shape. */
|
|
537
|
+
function queryOf(id: string, artifact: ContractArtifact): { readonly query?: ProcedureQuery } {
|
|
538
|
+
const shape = artifact.procedures !== undefined && Object.hasOwn(artifact.procedures, id) ? artifact.procedures[id]?.query : undefined
|
|
539
|
+
return shape === undefined ? {} : { query: { schema: schemaFromShape(shape), shape } }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** A procedure's admission, as the field its served endpoint carries; none after a problem. */
|
|
543
|
+
function admissionOf(id: string, artifact: ContractArtifact, options: BootOptions, problems: string[]): { readonly admission?: ProcedureAdmission } {
|
|
544
|
+
const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
|
|
545
|
+
return admission === null ? {} : { admission }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function admissionOptions(options: BootOptions): AdmissionOptions {
|
|
549
|
+
return {
|
|
550
|
+
application: options.environment.application,
|
|
551
|
+
...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
|
|
552
|
+
...(options.repeatableChallengeToken === undefined ? {} : { repeatableChallengeToken: options.repeatableChallengeToken }),
|
|
553
|
+
...(options.challengeWork === undefined ? {} : { challengeWork: options.challengeWork }),
|
|
554
|
+
}
|
|
535
555
|
}
|
|
536
556
|
|
|
537
557
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
558
|
+
* The framework's own endpoint that issues the ALTCHA challenge of a served public procedure, with its own buckets;
|
|
559
|
+
* `null` for a procedure without one.
|
|
540
560
|
*/
|
|
541
|
-
function
|
|
542
|
-
|
|
561
|
+
function challengeIssuerEndpoint(
|
|
562
|
+
procedure: Extract<ServedEndpoint, { kind: 'public' }>,
|
|
563
|
+
rows: ReadonlyMap<string, EndpointRow>,
|
|
543
564
|
artifact: ContractArtifact,
|
|
544
565
|
options: BootOptions,
|
|
545
|
-
files: FileCollections | null,
|
|
546
566
|
problems: string[],
|
|
547
|
-
):
|
|
548
|
-
const
|
|
549
|
-
if (
|
|
567
|
+
): ServedEndpoint | null {
|
|
568
|
+
const issuer = procedure.admission.challenge?.issuer ?? null
|
|
569
|
+
if (issuer === null) {
|
|
550
570
|
return null
|
|
551
571
|
}
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
if (fileFields.size > 0 && mount.challenge === undefined) {
|
|
559
|
-
problems.push(`form '${name}' takes files and declares no challenge; an anonymous upload needs a solved one`)
|
|
560
|
-
}
|
|
561
|
-
let challenge: ChallengeVerifier | null = null
|
|
562
|
-
if (mount.challenge !== undefined) {
|
|
563
|
-
const secret: unknown = options.environment.application[mount.challenge.secret]
|
|
564
|
-
if (typeof secret !== 'string' || secret.trim() === '') {
|
|
565
|
-
problems.push(
|
|
566
|
-
`form '${name}' verifies its ${mount.challenge.provider} challenge with the application variable ${mount.challenge.secret}, which is not set`,
|
|
567
|
-
)
|
|
568
|
-
return null
|
|
569
|
-
}
|
|
570
|
-
challenge = turnstileVerifier(secret.trim(), options.challengeFetch ?? ((request) => fetch(request)))
|
|
572
|
+
const id = challengeRowId(procedure.row.id)
|
|
573
|
+
const row = rows.get(id)
|
|
574
|
+
if (row === undefined || row.source.kind !== 'challenge') {
|
|
575
|
+
problems.push(`public procedure '${procedure.row.id}' has no endpoint that issues its challenge`)
|
|
576
|
+
return null
|
|
571
577
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
// A form with files accepts fewer submissions by default: each may write its collections' maxBytes.
|
|
575
|
-
perForm: mount.rate?.perForm ?? (fileFields.size === 0 ? FORM_LIMITS.perForm : FORM_FILE_LIMITS.perForm),
|
|
576
|
-
fields: Object.freeze(submissionFieldsOf(mount)),
|
|
577
|
-
files: fileFields.size === 0 || files === null ? null : {
|
|
578
|
-
fields: fileFields,
|
|
579
|
-
collections: files,
|
|
580
|
-
bytesPerHour: mount.rate?.bytesPerHour ?? FORM_FILE_LIMITS.bytesPerHour,
|
|
581
|
-
timeoutMs: options.formFileTimeoutMs ?? FORM_FILE_LIMITS.timeoutMs,
|
|
582
|
-
},
|
|
583
|
-
challenge,
|
|
584
|
-
repeatableToken: challenge === null ? null : (options.repeatableChallengeToken ?? null),
|
|
585
|
-
})
|
|
578
|
+
const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
|
|
579
|
+
return admission === null ? null : { kind: 'challenge', row, issuer, admission }
|
|
586
580
|
}
|
|
587
581
|
|
|
588
|
-
/**
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
582
|
+
/**
|
|
583
|
+
* A public procedure: its staged-kit handler, its key, and its admission. Without the key's credential (no token
|
|
584
|
+
* exchange, or the variable unset) `workloadCredentials` already reported why.
|
|
585
|
+
*/
|
|
586
|
+
function publicEndpoint(
|
|
587
|
+
row: EndpointRow,
|
|
588
|
+
procedure: ProcedureDeclaration<Schema, unknown>,
|
|
589
|
+
bound: BoundHandler | null,
|
|
590
|
+
artifact: ContractArtifact,
|
|
591
|
+
workloads: ReadonlyMap<string, WorkloadCredential>,
|
|
592
|
+
options: BootOptions,
|
|
593
|
+
problems: string[],
|
|
594
|
+
): Extract<ServedEndpoint, { kind: 'public' }> | null {
|
|
595
|
+
const where = `public procedure '${row.id}'`
|
|
596
|
+
const site = bound === null ? undefined : servedSite(procedure.type, bound)
|
|
597
|
+
if (bound === null || site === undefined) {
|
|
598
|
+
problems.push(`${where} has no handler of the staged kit; declare it with .handle(…) beside its declaration`)
|
|
599
|
+
return null
|
|
600
|
+
}
|
|
601
|
+
const actions = artifact.declared?.[row.id]?.actions ?? []
|
|
602
|
+
const credential = workloads.get(workloadSite('public', row.id))
|
|
603
|
+
const admission = procedureAdmission(row.id, artifact, admissionOptions(options), problems)
|
|
604
|
+
if (credential === undefined || admission === null) {
|
|
605
|
+
return null
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
kind: 'public',
|
|
609
|
+
row,
|
|
610
|
+
input: procedure.input,
|
|
611
|
+
output: procedure.output,
|
|
612
|
+
handler: bound.handler,
|
|
613
|
+
actions,
|
|
614
|
+
credential,
|
|
615
|
+
admission,
|
|
616
|
+
site,
|
|
617
|
+
...queryOf(row.id, artifact),
|
|
592
618
|
}
|
|
593
|
-
return framework
|
|
594
619
|
}
|
|
595
620
|
|
|
596
|
-
/** The
|
|
597
|
-
function
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
facilities: NO_FACILITIES,
|
|
625
|
-
})
|
|
621
|
+
/** The webhook a procedure binds (`binds`), as the approved artifact declares it. */
|
|
622
|
+
function bindsOf(
|
|
623
|
+
id: string,
|
|
624
|
+
declaration: SiteDeclaration | null,
|
|
625
|
+
artifact: ContractArtifact,
|
|
626
|
+
problems: string[],
|
|
627
|
+
): { readonly binds?: BindableWebhook } {
|
|
628
|
+
const name = declaration?.binds ?? null
|
|
629
|
+
if (name === null) {
|
|
630
|
+
return {}
|
|
631
|
+
}
|
|
632
|
+
const mount = Object.hasOwn(artifact.mounts, name) ? artifact.mounts[name] : undefined
|
|
633
|
+
if (mount?.kind !== 'webhook') {
|
|
634
|
+
problems.push(`procedure '${id}' binds '${name}', which is not a webhook of the contract`)
|
|
635
|
+
return {}
|
|
636
|
+
}
|
|
637
|
+
return { binds: { name, path: mount.path, scheme: mount.scheme, boundTo: mount.boundTo } }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Each approved token purpose with the claims schema the application declared for it. */
|
|
641
|
+
function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): ReadonlyMap<string, RuntimePurpose> {
|
|
642
|
+
const declared = contract.surface.tokens ?? {}
|
|
643
|
+
const purposes = new Map<string, RuntimePurpose>()
|
|
644
|
+
for (const [purpose, approved] of Object.entries(artifact.tokens ?? {})) {
|
|
645
|
+
const own = Object.hasOwn(declared, purpose) ? declared[purpose] : undefined
|
|
646
|
+
if (own !== undefined) {
|
|
647
|
+
purposes.set(purpose, { claims: own.claims, maxDays: approved.maxDays })
|
|
648
|
+
}
|
|
626
649
|
}
|
|
650
|
+
return purposes
|
|
627
651
|
}
|
|
628
652
|
|
|
629
653
|
export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
|
|
630
654
|
const problems: string[] = []
|
|
655
|
+
installEgressGuard()
|
|
656
|
+
// What a handler reaches through these is the platform's own request, which no site's egress covers.
|
|
657
|
+
const objects = options.files === undefined ? undefined : platformFacility(options.files)
|
|
658
|
+
const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
|
|
659
|
+
const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
|
|
631
660
|
const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
|
|
632
661
|
const artifact = compileContract(app.contract, app.database?.tables ?? {})
|
|
633
662
|
checkContractDigest(options.environment.framework, artifact, problems)
|
|
634
663
|
const proxySecret = readProxySecret(options.environment.framework, options.requireProxySecret === true, problems)
|
|
664
|
+
const tokens = new SignedTokens(runtimePurposes(app.contract, artifact), readTokenKeys(options.environment.framework, artifact, problems))
|
|
665
|
+
const origin = readAppOrigin(options.environment.framework, problems)
|
|
666
|
+
const url = appUrl(artifact, origin)
|
|
635
667
|
const operations = operationsSettings(options.environment.framework, problems)
|
|
636
668
|
const scrubber = environmentScrubber(options.environment)
|
|
637
669
|
const reporterFor = (source: ReportSource): OperationsReporter | null =>
|
|
@@ -639,33 +671,33 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
639
671
|
? null
|
|
640
672
|
: operationsReporter({ ...operations, source, scrubber, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
|
|
641
673
|
const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
|
|
642
|
-
// A file collection's dimension is located through its own table and its owner's.
|
|
643
|
-
for (const name of Object.keys(artifact.files)) {
|
|
644
|
-
owned.add(`file:${name}`)
|
|
645
|
-
}
|
|
646
674
|
const containers = containersOf(artifact)
|
|
647
675
|
unservable(artifact, options.assets, owned, containers, problems)
|
|
648
676
|
// This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
|
|
649
677
|
const issuer = createCapabilityIssuer()
|
|
650
|
-
const
|
|
678
|
+
const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
|
|
651
679
|
const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
|
|
652
680
|
const framework = bound?.framework ?? null
|
|
653
681
|
frameworkProblems(artifact, framework, problems)
|
|
654
|
-
const database =
|
|
655
|
-
const verifier = new TokenVerifier({ getJwks: () => options.iam.getJwks(), issuer: variables.issuer })
|
|
682
|
+
const database = bound?.database ?? null
|
|
683
|
+
const verifier = new TokenVerifier({ getJwks: () => platformCall(() => options.iam.getJwks()), issuer: variables.issuer })
|
|
656
684
|
const workloads = workloadCredentials({
|
|
657
685
|
artifact,
|
|
658
686
|
framework: options.environment.framework,
|
|
659
687
|
audience: variables.iamAppId,
|
|
660
|
-
source:
|
|
688
|
+
source: workloadTokens,
|
|
661
689
|
verifier,
|
|
662
690
|
}, problems)
|
|
663
691
|
const connect = wireImports({
|
|
664
692
|
artifact,
|
|
665
693
|
snapshots: app.connect ?? {},
|
|
666
694
|
framework: options.environment.framework,
|
|
667
|
-
bindings:
|
|
668
|
-
|
|
695
|
+
bindings: Object.fromEntries(
|
|
696
|
+
Object.entries(options.connectBindings ?? {}).map((
|
|
697
|
+
[name, binding],
|
|
698
|
+
): [string, ConnectFetch] => [name, (request) => platformCall(() => binding(request))]),
|
|
699
|
+
),
|
|
700
|
+
fallback: (request) => platformCall(() => fetch(request)),
|
|
669
701
|
}, problems)
|
|
670
702
|
const configs = browserConfigs(artifact, options.environment, {
|
|
671
703
|
dsn: operations === null ? null : (options.environment.framework['FABRIKA_OPERATIONS_DSN'] ?? '').trim(),
|
|
@@ -724,6 +756,17 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
724
756
|
declared.add(id)
|
|
725
757
|
const row = rows.get(id)
|
|
726
758
|
const bound = boundHandler(path.reduce<unknown>(childAt, handlers), id, `procedure '${id}'`, procedure.type, artifact, problems)
|
|
759
|
+
if (row?.kind === 'public') {
|
|
760
|
+
const served = publicEndpoint(row, procedure, bound, artifact, workloads, options, problems)
|
|
761
|
+
if (served !== null) {
|
|
762
|
+
endpoints.set(id, served)
|
|
763
|
+
const issuer = challengeIssuerEndpoint(served, rows, artifact, options, problems)
|
|
764
|
+
if (issuer !== null) {
|
|
765
|
+
endpoints.set(issuer.row.id, issuer)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
continue
|
|
769
|
+
}
|
|
727
770
|
if (!isCredentialed(row)) {
|
|
728
771
|
problems.push(`procedure '${id}' has no endpoint row`)
|
|
729
772
|
} else if (bound === null) {
|
|
@@ -738,45 +781,39 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
738
781
|
facilities: siteFacilities(facilitiesOf(artifact, row), bound.declaration),
|
|
739
782
|
connect: siteConnect(connect.get(id), bound.declaration),
|
|
740
783
|
site: servedSite(procedure.type, bound),
|
|
741
|
-
...(bound
|
|
784
|
+
...egressOf(bound),
|
|
785
|
+
...bindsOf(id, bound.declaration, artifact, problems),
|
|
786
|
+
...admissionOf(id, artifact, options, problems),
|
|
787
|
+
...queryOf(id, artifact),
|
|
742
788
|
})
|
|
743
789
|
}
|
|
744
790
|
}
|
|
745
|
-
} else if (mount.kind === 'form') {
|
|
746
|
-
const row = rows.get(name)
|
|
747
|
-
const admission = formAdmission(name, artifact, options, files, problems)
|
|
748
|
-
const approved = artifact.mounts[name]
|
|
749
|
-
const then = approved?.kind === 'form' ? (approved.then ?? null) : null
|
|
750
|
-
if (row !== undefined && admission !== null) {
|
|
751
|
-
endpoints.set(name, { kind: 'form', row, form: name, input: mount.input, admission, then })
|
|
752
|
-
}
|
|
753
791
|
} else if (mount.kind === 'webhook') {
|
|
754
792
|
declared.add(name)
|
|
755
793
|
const row = rows.get(name)
|
|
756
794
|
const bound = boundHandler(childAt(handlers, name), name, `webhook '${name}'`, 'mutation', artifact, problems)
|
|
757
|
-
const credential: WorkloadCredential | undefined = workloads.get(
|
|
795
|
+
const credential: WorkloadCredential | undefined = workloads.get(workloadSite('webhook', name))
|
|
796
|
+
const approved = artifact.mounts[name]
|
|
758
797
|
if (row === undefined || bound === null) {
|
|
759
798
|
problems.push(`webhook '${name}' has no handler; add handlers.${name}: ({ input, ctx }) => …`)
|
|
760
|
-
} else if (credential !== undefined) {
|
|
799
|
+
} else if (credential !== undefined && approved?.kind === 'webhook') {
|
|
761
800
|
endpoints.set(name, {
|
|
762
801
|
kind: 'webhook',
|
|
763
802
|
row,
|
|
764
803
|
webhook: name,
|
|
765
|
-
|
|
804
|
+
scheme: approved.scheme,
|
|
805
|
+
boundTo: approved.boundTo,
|
|
806
|
+
timeoutMs: approved.timeoutMs,
|
|
766
807
|
actions: mount.actions,
|
|
767
808
|
input: mount.input,
|
|
768
809
|
handler: bound.handler,
|
|
769
810
|
credential,
|
|
770
811
|
site: bound.site ? (bound.declaration ?? undefined) : undefined,
|
|
771
812
|
})
|
|
772
|
-
bindWebhookBindings(name, mount.path, rows, endpoints, () => frameworkOf(framework).webhooks)
|
|
773
813
|
}
|
|
774
814
|
}
|
|
775
815
|
}
|
|
776
|
-
|
|
777
|
-
endpoints.set(id, served)
|
|
778
|
-
}
|
|
779
|
-
const membership = options.membership === undefined ? null : new IamMembership(options.membership, variables.iamAppId)
|
|
816
|
+
const membership = membershipRpc === undefined ? null : new IamMembership(membershipRpc, variables.iamAppId)
|
|
780
817
|
const jobs = bindJobs({
|
|
781
818
|
artifact,
|
|
782
819
|
handlers,
|
|
@@ -787,23 +824,30 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
787
824
|
credentials: workloads,
|
|
788
825
|
mint: issuer.mint,
|
|
789
826
|
data: bound?.data ?? null,
|
|
790
|
-
|
|
791
|
-
frameworkJobs:
|
|
792
|
-
...(
|
|
827
|
+
fileKinds,
|
|
828
|
+
frameworkJobs: fileKinds === null ? [] : [
|
|
829
|
+
...(fileKinds.scans
|
|
793
830
|
? [{
|
|
794
831
|
name: FILE_SCAN_JOB,
|
|
795
832
|
schedule: FILE_SCAN_SCHEDULE,
|
|
796
833
|
timeoutMs: SCAN_LIMITS.jobTimeoutMs,
|
|
797
|
-
run: (reporter: OperationsReporter, signal: AbortSignal) =>
|
|
834
|
+
run: (reporter: OperationsReporter, signal: AbortSignal) => fileKinds.scanPass(options.scanner ?? null, reporter, signal),
|
|
798
835
|
}]
|
|
799
836
|
: []),
|
|
800
|
-
{
|
|
837
|
+
{
|
|
838
|
+
name: FILE_SWEEP_JOB,
|
|
839
|
+
schedule: FILE_SWEEP_SCHEDULE,
|
|
840
|
+
timeoutMs: FILE_SWEEP_TIMEOUT_MS,
|
|
841
|
+
run: (_reporter, signal) => fileKinds.sweep(signal),
|
|
842
|
+
},
|
|
801
843
|
],
|
|
802
844
|
membership,
|
|
803
845
|
capabilities: issuer.authority,
|
|
804
846
|
containers,
|
|
805
847
|
...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
|
|
806
848
|
mail,
|
|
849
|
+
tokens,
|
|
850
|
+
url,
|
|
807
851
|
}, problems)
|
|
808
852
|
for (const id of jobs.declared) {
|
|
809
853
|
declared.add(id)
|
|
@@ -830,6 +874,8 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
830
874
|
facilities: NO_FACILITIES,
|
|
831
875
|
readOnly: true,
|
|
832
876
|
site: servedSite('query', bound),
|
|
877
|
+
...egressOf(bound),
|
|
878
|
+
...admissionOf(id, artifact, options, problems),
|
|
833
879
|
})
|
|
834
880
|
}
|
|
835
881
|
}
|
|
@@ -844,7 +890,9 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
844
890
|
if (data === null && kitOriginOf(app.contract) === 'tableless') {
|
|
845
891
|
servedWithoutData(endpoints, problems)
|
|
846
892
|
}
|
|
847
|
-
const site = [...endpoints.values()].find((served) =>
|
|
893
|
+
const site = [...endpoints.values()].find((served) =>
|
|
894
|
+
served.kind === 'public' || ((served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
|
|
895
|
+
)
|
|
848
896
|
if (site !== undefined && data === null) {
|
|
849
897
|
problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
850
898
|
}
|
|
@@ -875,10 +923,15 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
875
923
|
mint: issuer.mint,
|
|
876
924
|
capabilities: issuer.authority,
|
|
877
925
|
data,
|
|
878
|
-
|
|
926
|
+
fileKinds,
|
|
879
927
|
failures: new FailureReports(reporterFor('fabrika.requests')),
|
|
880
928
|
kick: jobs.kick,
|
|
881
929
|
actions: Object.freeze(Object.keys(artifact.actions).sort()),
|
|
930
|
+
publicShare: new InFlightShare(publicShareOf(options.connections ?? DEFAULT_CONNECTIONS)),
|
|
931
|
+
clientVouched: options.clientAddress === 'vouched',
|
|
932
|
+
tokens,
|
|
933
|
+
url,
|
|
934
|
+
origin,
|
|
882
935
|
})
|
|
883
936
|
return Object.freeze({
|
|
884
937
|
fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec),
|
package/src/implement/bounded.ts
CHANGED
|
@@ -70,3 +70,26 @@ export async function readBoundedBytes(stream: ReadableStream<Uint8Array> | null
|
|
|
70
70
|
signal?.removeEventListener('abort', cancel)
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/** `body` passed on as it streams, failed once more than `maxBytes` went through; `exceeded` tells that failure apart. */
|
|
75
|
+
export function boundedStream(body: ReadableStream<Uint8Array>, maxBytes: number): {
|
|
76
|
+
readonly stream: ReadableStream<Uint8Array>
|
|
77
|
+
exceeded(): boolean
|
|
78
|
+
} {
|
|
79
|
+
let size = 0
|
|
80
|
+
let over = false
|
|
81
|
+
const stream = body.pipeThrough(
|
|
82
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
83
|
+
transform(chunk, controller) {
|
|
84
|
+
size += chunk.byteLength
|
|
85
|
+
if (size > maxBytes) {
|
|
86
|
+
over = true
|
|
87
|
+
controller.error(new Error(`the body is longer than ${maxBytes} bytes`))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
controller.enqueue(chunk)
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
return { stream, exceeded: () => over }
|
|
95
|
+
}
|
|
@@ -11,7 +11,7 @@ export interface BrowserConfig {
|
|
|
11
11
|
/** `FABRIKA_ENVIRONMENT`, which the page's error reports carry, or `null` when unset. */
|
|
12
12
|
readonly environment: string | null
|
|
13
13
|
/**
|
|
14
|
-
* Every
|
|
14
|
+
* Every public procedure challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
|
|
15
15
|
* there is none, so the file of an application without them holds the three Operations values only.
|
|
16
16
|
*/
|
|
17
17
|
readonly public?: Readonly<Record<string, string>>
|
|
@@ -33,11 +33,12 @@ export function browserConfigs(
|
|
|
33
33
|
return typeof value === 'string' && value.trim() !== '' ? value : null
|
|
34
34
|
}
|
|
35
35
|
const siteKeys: Record<string, string> = {}
|
|
36
|
-
for (const [
|
|
37
|
-
const challenge =
|
|
38
|
-
|
|
36
|
+
for (const [id, procedure] of Object.entries(artifact.procedures ?? {})) {
|
|
37
|
+
const challenge = procedure.challenge
|
|
38
|
+
// Only Turnstile's widget needs a key in the page; ALTCHA's fetches its challenge from the application.
|
|
39
|
+
if (challenge?.provider !== 'turnstile' || challenge.siteKey === undefined) continue
|
|
39
40
|
const value = valueOf(challenge.siteKey)
|
|
40
|
-
if (value === null) problems.push(`
|
|
41
|
+
if (value === null) problems.push(`procedure '${id}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
|
|
41
42
|
else siteKeys[challenge.siteKey] = value
|
|
42
43
|
}
|
|
43
44
|
const configs = new Map<string, BrowserConfig>()
|