@fabrika/app 0.5.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
// What the anonymous kinds need beyond the request pipeline: a body read under its limit (the proxy never sees a
|
|
2
|
-
// body, so the framework enforces it)
|
|
3
|
-
//
|
|
2
|
+
// body, so the framework enforces it) and the share SPA's fragment bootstrap. A form's admission is
|
|
3
|
+
// `form-admission.ts`, a webhook's signature `verifiers.ts`.
|
|
4
4
|
|
|
5
5
|
import { LINK_BOOTSTRAP_FILE, SHARE_KEY_GLOBAL } from '../contract/defaults'
|
|
6
6
|
import { HttpError } from '../errors'
|
|
7
7
|
import { readBoundedBytes } from './bounded'
|
|
8
8
|
|
|
9
|
+
/** The slowest body a procedure waits for, in bytes per second: its deadline is `maxBytes` at this rate, 30 s at least. */
|
|
10
|
+
export const BODY_RATE_FLOOR = 16 * 1024
|
|
11
|
+
const MIN_BODY_DEADLINE_MS = 30_000
|
|
12
|
+
|
|
13
|
+
/** How long a body of at most `maxBytes` may take to arrive. */
|
|
14
|
+
export function bodyDeadlineMs(maxBytes: number): number {
|
|
15
|
+
return Math.max(MIN_BODY_DEADLINE_MS, Math.ceil((maxBytes / BODY_RATE_FLOOR) * 1000))
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* The body as bytes, read until `maxBytes`; a longer one stops the read and is a 413. A body whose stream fails before
|
|
11
20
|
* its end (the client closed the connection) is the sender's 400 `aborted`, never a failure of the application.
|
|
12
21
|
*/
|
|
13
|
-
export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
|
|
22
|
+
export async function readBoundedBody(request: Request, maxBytes: number, deadline?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
|
|
14
23
|
let body: Uint8Array<ArrayBuffer> | null
|
|
15
24
|
try {
|
|
16
|
-
body = await readBoundedBytes(request.body, {
|
|
25
|
+
body = await readBoundedBytes(request.body, {
|
|
26
|
+
maxBytes,
|
|
27
|
+
declaredLength: request.headers.get('Content-Length'),
|
|
28
|
+
...(deadline === undefined ? {} : { signal: deadline }),
|
|
29
|
+
})
|
|
17
30
|
} catch {
|
|
18
|
-
throw abortedBody()
|
|
31
|
+
throw deadline?.aborted === true ? new HttpError(408, 'body_timeout', 'The body did not arrive within its deadline') : abortedBody()
|
|
19
32
|
}
|
|
20
33
|
if (body === null) {
|
|
21
34
|
throw new HttpError(413, 'too_large', `The body is larger than ${maxBytes} bytes`)
|
|
@@ -76,40 +89,3 @@ export async function withBootstrap(response: Response, prefix: string): Promise
|
|
|
76
89
|
headers.delete('Content-Length')
|
|
77
90
|
return new Response(injected, { status: response.status, statusText: response.statusText, headers })
|
|
78
91
|
}
|
|
79
|
-
|
|
80
|
-
const SIGNATURE_PREFIX = 'sha256='
|
|
81
|
-
const HEX = /^[0-9a-f]{64}$/
|
|
82
|
-
|
|
83
|
-
/** Whether `X-Hub-Signature-256` is GitHub's HMAC-SHA256 of the raw body under `secret`, compared in constant time. */
|
|
84
|
-
export async function verifyGithubSignature(header: string | null, secret: string, body: Uint8Array<ArrayBuffer>): Promise<boolean> {
|
|
85
|
-
if (header === null || !header.startsWith(SIGNATURE_PREFIX)) {
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
88
|
-
const hex = header.slice(SIGNATURE_PREFIX.length).toLowerCase()
|
|
89
|
-
if (!HEX.test(hex)) {
|
|
90
|
-
return false
|
|
91
|
-
}
|
|
92
|
-
const signature = new Uint8Array(32)
|
|
93
|
-
for (let index = 0; index < 32; index++) {
|
|
94
|
-
signature[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
|
|
95
|
-
}
|
|
96
|
-
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
|
|
97
|
-
return crypto.subtle.verify('HMAC', key, signature, body)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** The GitHub account a payload names, as a binding records it: `installation:<id>` or `repository:<id>`. */
|
|
101
|
-
export function githubAccounts(payload: unknown): readonly string[] {
|
|
102
|
-
const accounts: string[] = []
|
|
103
|
-
for (const kind of ['installation', 'repository'] as const) {
|
|
104
|
-
const holder = ownField(payload, kind)
|
|
105
|
-
const id = ownField(holder, 'id')
|
|
106
|
-
if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) {
|
|
107
|
-
accounts.push(`${kind}:${id}`)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return accounts
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function ownField(value: unknown, name: string): unknown {
|
|
114
|
-
return typeof value === 'object' && value !== null && Object.hasOwn(value, name) ? Reflect.get(value, name) : undefined
|
|
115
|
-
}
|
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 { exportBase,
|
|
45
|
-
import type { Schema } from '../contract/define'
|
|
35
|
+
import { 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 } 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,13 @@ 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
|
-
/** How long a form's multipart body may take to arrive; `FORM_FILE_LIMITS.timeoutMs` unless a framework test shortens it. */
|
|
130
|
-
readonly formFileTimeoutMs?: number
|
|
131
123
|
/**
|
|
132
124
|
* The developer's own machine only (`fabrika app dev`): what a job's handler threw, which every log of a deployed
|
|
133
125
|
* application withholds because it may carry a credential.
|
|
@@ -145,8 +137,19 @@ export interface BootOptions {
|
|
|
145
137
|
* `FABRIKA_EMAIL_*` nor `serve`'s `mail`.
|
|
146
138
|
*/
|
|
147
139
|
readonly mailStandIn?: MailStandIn
|
|
140
|
+
/** The connections the application's pool holds: a quarter of them, at least one, is the public procedures' share. */
|
|
141
|
+
readonly connections?: number
|
|
142
|
+
/**
|
|
143
|
+
* `'vouched'` where the proxy's `X-Fabrika-Client-Ip` is the visitor's own address, so `ctx.client.ip` reads it. A
|
|
144
|
+
* runtime behind a balancer whose range the platform does not trust (Zerops, until it does) leaves it unset: the ip is
|
|
145
|
+
* then `null`.
|
|
146
|
+
*/
|
|
147
|
+
readonly clientAddress?: 'vouched'
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** The pool size a runtime adapter that names none is assumed to have: the Bun adapter's default. */
|
|
151
|
+
const DEFAULT_CONNECTIONS = 10
|
|
152
|
+
|
|
150
153
|
export interface ContractApp {
|
|
151
154
|
/** `exec` keeps a failure's report to Operations alive past the response (a Worker's `waitUntil`). */
|
|
152
155
|
fetch(request: Request, exec?: RequestExecutionContext): Promise<Response>
|
|
@@ -270,9 +273,6 @@ function unservable(
|
|
|
270
273
|
// Every link request re-reads where the object lies; without the table that records it, nothing could.
|
|
271
274
|
problems.push(`link mount '${name}' shares a '${mount.dimension}', but no table in src/db/tables.ts owns that dimension to locate it`)
|
|
272
275
|
}
|
|
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
276
|
}
|
|
277
277
|
for (const row of artifact.endpoints) {
|
|
278
278
|
if (row.requirement?.kind !== 'action') {
|
|
@@ -422,6 +422,11 @@ function boundHandler(
|
|
|
422
422
|
return null
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
/** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
|
|
426
|
+
function egressOf(bound: BoundHandler): { readonly egress?: readonly string[] } {
|
|
427
|
+
return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
|
|
428
|
+
}
|
|
429
|
+
|
|
425
430
|
/** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
|
|
426
431
|
function siteFacilities(facilities: Facilities, declaration: SiteDeclaration | null): Facilities {
|
|
427
432
|
if (declaration === null) {
|
|
@@ -504,24 +509,17 @@ function asksIam(served: ServedEndpoint): boolean {
|
|
|
504
509
|
return creates || served.facilities.members === 'manage' || served.facilities.links.length > 0
|
|
505
510
|
}
|
|
506
511
|
|
|
507
|
-
/** A
|
|
512
|
+
/** A webhook and a procedure's buckets write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
|
|
508
513
|
function frameworkProblems(artifact: ContractArtifact, framework: FrameworkTables | null, problems: string[]): void {
|
|
514
|
+
const counted = Object.entries(artifact.procedures ?? {}).find(([, rules]) => Object.values(rules.rate).some((bucket) => bucket !== null))
|
|
515
|
+
if (counted !== undefined && framework === null) {
|
|
516
|
+
problems.push(
|
|
517
|
+
`procedure '${counted[0]}' counts its calls in buckets, which live in @fabrika/db: pass serve(contract, { database: scoped(tables) })`,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
509
520
|
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 ?? {})]))
|
|
521
|
+
if (mount.kind === 'webhook' && framework === null) {
|
|
522
|
+
problems.push(`mount '${name}' is a webhook, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
525
523
|
}
|
|
526
524
|
}
|
|
527
525
|
}
|
|
@@ -530,108 +528,111 @@ function isCredentialed(row: EndpointRow | undefined): row is CredentialedEndpoi
|
|
|
530
528
|
return row !== undefined && row.requirement !== null
|
|
531
529
|
}
|
|
532
530
|
|
|
533
|
-
|
|
534
|
-
|
|
531
|
+
/** A POST procedure's `http.query`, as the field its served endpoint carries: parsed by the approved shape. */
|
|
532
|
+
function queryOf(id: string, artifact: ContractArtifact): { readonly query?: ProcedureQuery } {
|
|
533
|
+
const shape = artifact.procedures !== undefined && Object.hasOwn(artifact.procedures, id) ? artifact.procedures[id]?.query : undefined
|
|
534
|
+
return shape === undefined ? {} : { query: { schema: schemaFromShape(shape), shape } }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** A procedure's admission, as the field its served endpoint carries; none after a problem. */
|
|
538
|
+
function admissionOf(id: string, artifact: ContractArtifact, options: BootOptions, problems: string[]): { readonly admission?: ProcedureAdmission } {
|
|
539
|
+
const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
|
|
540
|
+
return admission === null ? {} : { admission }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function admissionOptions(options: BootOptions): AdmissionOptions {
|
|
544
|
+
return {
|
|
545
|
+
application: options.environment.application,
|
|
546
|
+
...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
|
|
547
|
+
...(options.repeatableChallengeToken === undefined ? {} : { repeatableChallengeToken: options.repeatableChallengeToken }),
|
|
548
|
+
}
|
|
535
549
|
}
|
|
536
550
|
|
|
537
551
|
/**
|
|
538
|
-
* A
|
|
539
|
-
*
|
|
552
|
+
* A public procedure: its staged-kit handler, its key, and its admission. Without the key's credential (no token
|
|
553
|
+
* exchange, or the variable unset) `workloadCredentials` already reported why.
|
|
540
554
|
*/
|
|
541
|
-
function
|
|
542
|
-
|
|
555
|
+
function publicEndpoint(
|
|
556
|
+
row: EndpointRow,
|
|
557
|
+
procedure: ProcedureDeclaration<Schema, unknown>,
|
|
558
|
+
bound: BoundHandler | null,
|
|
543
559
|
artifact: ContractArtifact,
|
|
560
|
+
workloads: ReadonlyMap<string, WorkloadCredential>,
|
|
544
561
|
options: BootOptions,
|
|
545
|
-
files: FileCollections | null,
|
|
546
562
|
problems: string[],
|
|
547
|
-
):
|
|
548
|
-
const
|
|
549
|
-
|
|
563
|
+
): ServedEndpoint | null {
|
|
564
|
+
const where = `public procedure '${row.id}'`
|
|
565
|
+
const site = bound === null ? undefined : servedSite(procedure.type, bound)
|
|
566
|
+
if (bound === null || site === undefined) {
|
|
567
|
+
problems.push(`${where} has no handler of the staged kit; declare it with .handle(…) beside its declaration`)
|
|
550
568
|
return null
|
|
551
569
|
}
|
|
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`)
|
|
570
|
+
const actions = artifact.declared?.[row.id]?.actions ?? []
|
|
571
|
+
const credential = workloads.get(workloadSite('public', row.id))
|
|
572
|
+
const admission = procedureAdmission(row.id, artifact, admissionOptions(options), problems)
|
|
573
|
+
if (credential === undefined || admission === null) {
|
|
574
|
+
return null
|
|
560
575
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
576
|
+
return {
|
|
577
|
+
kind: 'public',
|
|
578
|
+
row,
|
|
579
|
+
input: procedure.input,
|
|
580
|
+
output: procedure.output,
|
|
581
|
+
handler: bound.handler,
|
|
582
|
+
actions,
|
|
583
|
+
credential,
|
|
584
|
+
admission,
|
|
585
|
+
site,
|
|
586
|
+
...queryOf(row.id, artifact),
|
|
571
587
|
}
|
|
572
|
-
return Object.freeze({
|
|
573
|
-
perClient: mount.rate?.perClient ?? FORM_LIMITS.perClient,
|
|
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
|
-
})
|
|
586
588
|
}
|
|
587
589
|
|
|
588
|
-
/**
|
|
589
|
-
function
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
if (isCredentialed(unbind)) {
|
|
618
|
-
endpoints.set(unbind.id, {
|
|
619
|
-
kind: 'procedure',
|
|
620
|
-
row: unbind,
|
|
621
|
-
input: webhookUnbindInput,
|
|
622
|
-
output: webhookUnbindOutput,
|
|
623
|
-
handler: handlers.unbind,
|
|
624
|
-
facilities: NO_FACILITIES,
|
|
625
|
-
})
|
|
590
|
+
/** The webhook a procedure binds (`binds`), as the approved artifact declares it. */
|
|
591
|
+
function bindsOf(
|
|
592
|
+
id: string,
|
|
593
|
+
declaration: SiteDeclaration | null,
|
|
594
|
+
artifact: ContractArtifact,
|
|
595
|
+
problems: string[],
|
|
596
|
+
): { readonly binds?: BindableWebhook } {
|
|
597
|
+
const name = declaration?.binds ?? null
|
|
598
|
+
if (name === null) {
|
|
599
|
+
return {}
|
|
600
|
+
}
|
|
601
|
+
const mount = Object.hasOwn(artifact.mounts, name) ? artifact.mounts[name] : undefined
|
|
602
|
+
if (mount?.kind !== 'webhook') {
|
|
603
|
+
problems.push(`procedure '${id}' binds '${name}', which is not a webhook of the contract`)
|
|
604
|
+
return {}
|
|
605
|
+
}
|
|
606
|
+
return { binds: { name, path: mount.path, scheme: mount.scheme, boundTo: mount.boundTo } }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Each approved token purpose with the claims schema the application declared for it. */
|
|
610
|
+
function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): ReadonlyMap<string, RuntimePurpose> {
|
|
611
|
+
const declared = contract.surface.tokens ?? {}
|
|
612
|
+
const purposes = new Map<string, RuntimePurpose>()
|
|
613
|
+
for (const [purpose, approved] of Object.entries(artifact.tokens ?? {})) {
|
|
614
|
+
const own = Object.hasOwn(declared, purpose) ? declared[purpose] : undefined
|
|
615
|
+
if (own !== undefined) {
|
|
616
|
+
purposes.set(purpose, { claims: own.claims, maxDays: approved.maxDays })
|
|
617
|
+
}
|
|
626
618
|
}
|
|
619
|
+
return purposes
|
|
627
620
|
}
|
|
628
621
|
|
|
629
622
|
export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
|
|
630
623
|
const problems: string[] = []
|
|
624
|
+
installEgressGuard()
|
|
625
|
+
// What a handler reaches through these is the platform's own request, which no site's egress covers.
|
|
626
|
+
const objects = options.files === undefined ? undefined : platformFacility(options.files)
|
|
627
|
+
const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
|
|
628
|
+
const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
|
|
631
629
|
const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
|
|
632
630
|
const artifact = compileContract(app.contract, app.database?.tables ?? {})
|
|
633
631
|
checkContractDigest(options.environment.framework, artifact, problems)
|
|
634
632
|
const proxySecret = readProxySecret(options.environment.framework, options.requireProxySecret === true, problems)
|
|
633
|
+
const tokens = new SignedTokens(runtimePurposes(app.contract, artifact), readTokenKeys(options.environment.framework, artifact, problems))
|
|
634
|
+
const origin = readAppOrigin(options.environment.framework, problems)
|
|
635
|
+
const url = appUrl(artifact, origin)
|
|
635
636
|
const operations = operationsSettings(options.environment.framework, problems)
|
|
636
637
|
const scrubber = environmentScrubber(options.environment)
|
|
637
638
|
const reporterFor = (source: ReportSource): OperationsReporter | null =>
|
|
@@ -639,33 +640,33 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
639
640
|
? null
|
|
640
641
|
: operationsReporter({ ...operations, source, scrubber, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
|
|
641
642
|
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
643
|
const containers = containersOf(artifact)
|
|
647
644
|
unservable(artifact, options.assets, owned, containers, problems)
|
|
648
645
|
// This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
|
|
649
646
|
const issuer = createCapabilityIssuer()
|
|
650
|
-
const
|
|
647
|
+
const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
|
|
651
648
|
const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
|
|
652
649
|
const framework = bound?.framework ?? null
|
|
653
650
|
frameworkProblems(artifact, framework, problems)
|
|
654
|
-
const database =
|
|
655
|
-
const verifier = new TokenVerifier({ getJwks: () => options.iam.getJwks(), issuer: variables.issuer })
|
|
651
|
+
const database = bound?.database ?? null
|
|
652
|
+
const verifier = new TokenVerifier({ getJwks: () => platformCall(() => options.iam.getJwks()), issuer: variables.issuer })
|
|
656
653
|
const workloads = workloadCredentials({
|
|
657
654
|
artifact,
|
|
658
655
|
framework: options.environment.framework,
|
|
659
656
|
audience: variables.iamAppId,
|
|
660
|
-
source:
|
|
657
|
+
source: workloadTokens,
|
|
661
658
|
verifier,
|
|
662
659
|
}, problems)
|
|
663
660
|
const connect = wireImports({
|
|
664
661
|
artifact,
|
|
665
662
|
snapshots: app.connect ?? {},
|
|
666
663
|
framework: options.environment.framework,
|
|
667
|
-
bindings:
|
|
668
|
-
|
|
664
|
+
bindings: Object.fromEntries(
|
|
665
|
+
Object.entries(options.connectBindings ?? {}).map((
|
|
666
|
+
[name, binding],
|
|
667
|
+
): [string, ConnectFetch] => [name, (request) => platformCall(() => binding(request))]),
|
|
668
|
+
),
|
|
669
|
+
fallback: (request) => platformCall(() => fetch(request)),
|
|
669
670
|
}, problems)
|
|
670
671
|
const configs = browserConfigs(artifact, options.environment, {
|
|
671
672
|
dsn: operations === null ? null : (options.environment.framework['FABRIKA_OPERATIONS_DSN'] ?? '').trim(),
|
|
@@ -724,6 +725,13 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
724
725
|
declared.add(id)
|
|
725
726
|
const row = rows.get(id)
|
|
726
727
|
const bound = boundHandler(path.reduce<unknown>(childAt, handlers), id, `procedure '${id}'`, procedure.type, artifact, problems)
|
|
728
|
+
if (row?.kind === 'public') {
|
|
729
|
+
const served = publicEndpoint(row, procedure, bound, artifact, workloads, options, problems)
|
|
730
|
+
if (served !== null) {
|
|
731
|
+
endpoints.set(id, served)
|
|
732
|
+
}
|
|
733
|
+
continue
|
|
734
|
+
}
|
|
727
735
|
if (!isCredentialed(row)) {
|
|
728
736
|
problems.push(`procedure '${id}' has no endpoint row`)
|
|
729
737
|
} else if (bound === null) {
|
|
@@ -738,45 +746,39 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
738
746
|
facilities: siteFacilities(facilitiesOf(artifact, row), bound.declaration),
|
|
739
747
|
connect: siteConnect(connect.get(id), bound.declaration),
|
|
740
748
|
site: servedSite(procedure.type, bound),
|
|
741
|
-
...(bound
|
|
749
|
+
...egressOf(bound),
|
|
750
|
+
...bindsOf(id, bound.declaration, artifact, problems),
|
|
751
|
+
...admissionOf(id, artifact, options, problems),
|
|
752
|
+
...queryOf(id, artifact),
|
|
742
753
|
})
|
|
743
754
|
}
|
|
744
755
|
}
|
|
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
756
|
} else if (mount.kind === 'webhook') {
|
|
754
757
|
declared.add(name)
|
|
755
758
|
const row = rows.get(name)
|
|
756
759
|
const bound = boundHandler(childAt(handlers, name), name, `webhook '${name}'`, 'mutation', artifact, problems)
|
|
757
|
-
const credential: WorkloadCredential | undefined = workloads.get(
|
|
760
|
+
const credential: WorkloadCredential | undefined = workloads.get(workloadSite('webhook', name))
|
|
761
|
+
const approved = artifact.mounts[name]
|
|
758
762
|
if (row === undefined || bound === null) {
|
|
759
763
|
problems.push(`webhook '${name}' has no handler; add handlers.${name}: ({ input, ctx }) => …`)
|
|
760
|
-
} else if (credential !== undefined) {
|
|
764
|
+
} else if (credential !== undefined && approved?.kind === 'webhook') {
|
|
761
765
|
endpoints.set(name, {
|
|
762
766
|
kind: 'webhook',
|
|
763
767
|
row,
|
|
764
768
|
webhook: name,
|
|
765
|
-
|
|
769
|
+
scheme: approved.scheme,
|
|
770
|
+
boundTo: approved.boundTo,
|
|
771
|
+
timeoutMs: approved.timeoutMs,
|
|
766
772
|
actions: mount.actions,
|
|
767
773
|
input: mount.input,
|
|
768
774
|
handler: bound.handler,
|
|
769
775
|
credential,
|
|
770
776
|
site: bound.site ? (bound.declaration ?? undefined) : undefined,
|
|
771
777
|
})
|
|
772
|
-
bindWebhookBindings(name, mount.path, rows, endpoints, () => frameworkOf(framework).webhooks)
|
|
773
778
|
}
|
|
774
779
|
}
|
|
775
780
|
}
|
|
776
|
-
|
|
777
|
-
endpoints.set(id, served)
|
|
778
|
-
}
|
|
779
|
-
const membership = options.membership === undefined ? null : new IamMembership(options.membership, variables.iamAppId)
|
|
781
|
+
const membership = membershipRpc === undefined ? null : new IamMembership(membershipRpc, variables.iamAppId)
|
|
780
782
|
const jobs = bindJobs({
|
|
781
783
|
artifact,
|
|
782
784
|
handlers,
|
|
@@ -787,23 +789,30 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
787
789
|
credentials: workloads,
|
|
788
790
|
mint: issuer.mint,
|
|
789
791
|
data: bound?.data ?? null,
|
|
790
|
-
|
|
791
|
-
frameworkJobs:
|
|
792
|
-
...(
|
|
792
|
+
fileKinds,
|
|
793
|
+
frameworkJobs: fileKinds === null ? [] : [
|
|
794
|
+
...(fileKinds.scans
|
|
793
795
|
? [{
|
|
794
796
|
name: FILE_SCAN_JOB,
|
|
795
797
|
schedule: FILE_SCAN_SCHEDULE,
|
|
796
798
|
timeoutMs: SCAN_LIMITS.jobTimeoutMs,
|
|
797
|
-
run: (reporter: OperationsReporter, signal: AbortSignal) =>
|
|
799
|
+
run: (reporter: OperationsReporter, signal: AbortSignal) => fileKinds.scanPass(options.scanner ?? null, reporter, signal),
|
|
798
800
|
}]
|
|
799
801
|
: []),
|
|
800
|
-
{
|
|
802
|
+
{
|
|
803
|
+
name: FILE_SWEEP_JOB,
|
|
804
|
+
schedule: FILE_SWEEP_SCHEDULE,
|
|
805
|
+
timeoutMs: FILE_SWEEP_TIMEOUT_MS,
|
|
806
|
+
run: (_reporter, signal) => fileKinds.sweep(signal),
|
|
807
|
+
},
|
|
801
808
|
],
|
|
802
809
|
membership,
|
|
803
810
|
capabilities: issuer.authority,
|
|
804
811
|
containers,
|
|
805
812
|
...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
|
|
806
813
|
mail,
|
|
814
|
+
tokens,
|
|
815
|
+
url,
|
|
807
816
|
}, problems)
|
|
808
817
|
for (const id of jobs.declared) {
|
|
809
818
|
declared.add(id)
|
|
@@ -830,6 +839,8 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
830
839
|
facilities: NO_FACILITIES,
|
|
831
840
|
readOnly: true,
|
|
832
841
|
site: servedSite('query', bound),
|
|
842
|
+
...egressOf(bound),
|
|
843
|
+
...admissionOf(id, artifact, options, problems),
|
|
833
844
|
})
|
|
834
845
|
}
|
|
835
846
|
}
|
|
@@ -844,7 +855,9 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
844
855
|
if (data === null && kitOriginOf(app.contract) === 'tableless') {
|
|
845
856
|
servedWithoutData(endpoints, problems)
|
|
846
857
|
}
|
|
847
|
-
const site = [...endpoints.values()].find((served) =>
|
|
858
|
+
const site = [...endpoints.values()].find((served) =>
|
|
859
|
+
served.kind === 'public' || ((served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
|
|
860
|
+
)
|
|
848
861
|
if (site !== undefined && data === null) {
|
|
849
862
|
problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
850
863
|
}
|
|
@@ -875,10 +888,15 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
875
888
|
mint: issuer.mint,
|
|
876
889
|
capabilities: issuer.authority,
|
|
877
890
|
data,
|
|
878
|
-
|
|
891
|
+
fileKinds,
|
|
879
892
|
failures: new FailureReports(reporterFor('fabrika.requests')),
|
|
880
893
|
kick: jobs.kick,
|
|
881
894
|
actions: Object.freeze(Object.keys(artifact.actions).sort()),
|
|
895
|
+
publicShare: new InFlightShare(publicShareOf(options.connections ?? DEFAULT_CONNECTIONS)),
|
|
896
|
+
clientVouched: options.clientAddress === 'vouched',
|
|
897
|
+
tokens,
|
|
898
|
+
url,
|
|
899
|
+
origin,
|
|
882
900
|
})
|
|
883
901
|
return Object.freeze({
|
|
884
902
|
fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec),
|