@fabrika/app 0.3.3 → 0.4.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/README.md +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +331 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +824 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +760 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The framework's variables, taken out of the process before any application module loads. A module that copies the
|
|
2
|
+
// environment while it initialises then copies an environment without them.
|
|
3
|
+
|
|
4
|
+
import type { ApplicationEnvironment } from './implement'
|
|
5
|
+
|
|
6
|
+
/** Every variable under this prefix is the framework's; no application code sees one. */
|
|
7
|
+
export const FRAMEWORK_PREFIX = 'FABRIKA_'
|
|
8
|
+
|
|
9
|
+
export interface CapturedEnvironment {
|
|
10
|
+
/** The `FABRIKA_*` variables, for `boot` only. */
|
|
11
|
+
readonly framework: Readonly<Record<string, string>>
|
|
12
|
+
/** Every other variable or binding, for the application's `deps`. */
|
|
13
|
+
readonly application: ApplicationEnvironment
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Deletes every framework variable from `process.env` and `Bun.env`. */
|
|
17
|
+
function scrubProcessEnvironment(): void {
|
|
18
|
+
const environments = [typeof process === 'undefined' ? undefined : process.env, typeof Bun === 'undefined' ? undefined : Bun.env]
|
|
19
|
+
for (const environment of environments) {
|
|
20
|
+
if (environment === undefined) {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
for (const name of Object.keys(environment)) {
|
|
24
|
+
if (name.startsWith(FRAMEWORK_PREFIX)) {
|
|
25
|
+
delete environment[name]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Splits `source` into the framework's variables and the application's, then scrubs the process environment.
|
|
33
|
+
* The generated entrypoint calls it through its runtime adapter before it imports `src/app.ts`.
|
|
34
|
+
*/
|
|
35
|
+
export function captureEnvironment(source: Readonly<Record<string, unknown>>): CapturedEnvironment {
|
|
36
|
+
const framework: Record<string, string> = {}
|
|
37
|
+
const application: Record<string, unknown> = {}
|
|
38
|
+
for (const [name, value] of Object.entries(source)) {
|
|
39
|
+
if (!name.startsWith(FRAMEWORK_PREFIX)) {
|
|
40
|
+
Object.defineProperty(application, name, { value, enumerable: true })
|
|
41
|
+
} else if (typeof value === 'string') {
|
|
42
|
+
Object.defineProperty(framework, name, { value, enumerable: true })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
scrubProcessEnvironment()
|
|
46
|
+
return Object.freeze({ framework: Object.freeze(framework), application: Object.freeze(application) })
|
|
47
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// A request that answered 5xx: one server log line and, with an Operations DSN, one error event. Both carry fixed codes
|
|
2
|
+
// and bounded identifiers only, never an error's name or message: application code, drivers and even the framework's
|
|
3
|
+
// own refusals can put request data there. A framework failure adds its reason and the reason's catalog text.
|
|
4
|
+
|
|
5
|
+
import { CAPABILITY_ERRORS, CapabilityError, QUERY_REFUSALS, QueryRefusedError } from '@fabrika/db/framework'
|
|
6
|
+
import { isStructuralError } from '../errors'
|
|
7
|
+
import type { OperationsReporter } from '../jobs/report'
|
|
8
|
+
import { CAPABILITY_REFUSALS, CapabilityRefusedError } from './capability'
|
|
9
|
+
import { SERVER_FAULTS, ServerFault } from './faults'
|
|
10
|
+
|
|
11
|
+
export type RequestFailureCode =
|
|
12
|
+
| 'framework_fault'
|
|
13
|
+
| 'unavailable'
|
|
14
|
+
| 'capability_refused'
|
|
15
|
+
| 'query_refused'
|
|
16
|
+
| 'handler_status'
|
|
17
|
+
| 'handler_failed'
|
|
18
|
+
|
|
19
|
+
/** At most this many request failures per process and minute become Operations events; every one is logged. */
|
|
20
|
+
export const MAX_FAILURE_REPORTS_PER_MINUTE = 30
|
|
21
|
+
|
|
22
|
+
const MINUTE_MS = 60_000
|
|
23
|
+
/** The proxy's request id is a UUID; anything else is left out of a report rather than echoed. */
|
|
24
|
+
const REQUEST_ID = /^[0-9A-Za-z-]{1,64}$/
|
|
25
|
+
|
|
26
|
+
/** Chosen by `instanceof` against the framework's own classes, never by `name`, which any thrown value can set. */
|
|
27
|
+
export function failureCode(error: unknown): RequestFailureCode {
|
|
28
|
+
if (error instanceof ServerFault) {
|
|
29
|
+
return error.status === 503 ? 'unavailable' : 'framework_fault'
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof CapabilityRefusedError || error instanceof CapabilityError) {
|
|
32
|
+
return 'capability_refused'
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof QueryRefusedError) {
|
|
35
|
+
return 'query_refused'
|
|
36
|
+
}
|
|
37
|
+
return isStructuralError(error) ? 'handler_status' : 'handler_failed'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a log may say of a framework failure: the reason its catalog keys it by, and that reason's fixed text. */
|
|
41
|
+
export interface FailureReason {
|
|
42
|
+
readonly reason: string
|
|
43
|
+
readonly text: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A framework failure's reason and the fixed text its catalog gives it; `null` for any other thrown value. */
|
|
47
|
+
export function failureReason(error: unknown): FailureReason | null {
|
|
48
|
+
if (error instanceof ServerFault) {
|
|
49
|
+
return { reason: error.reason, text: SERVER_FAULTS[error.reason] }
|
|
50
|
+
}
|
|
51
|
+
if (error instanceof CapabilityRefusedError) {
|
|
52
|
+
return { reason: error.reason, text: CAPABILITY_REFUSALS[error.reason] }
|
|
53
|
+
}
|
|
54
|
+
if (error instanceof CapabilityError) {
|
|
55
|
+
return { reason: error.reason, text: CAPABILITY_ERRORS[error.reason] }
|
|
56
|
+
}
|
|
57
|
+
if (error instanceof QueryRefusedError) {
|
|
58
|
+
return { reason: error.reason, text: QUERY_REFUSALS[error.reason] }
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RequestFailure {
|
|
64
|
+
/** The endpoint id the request selected, or `request` before one was selected. */
|
|
65
|
+
readonly endpoint: string
|
|
66
|
+
/** The proxy's `X-Request-Id`. */
|
|
67
|
+
readonly requestId: string | null
|
|
68
|
+
readonly status: number
|
|
69
|
+
readonly error: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class FailureReports {
|
|
73
|
+
readonly #reporter: OperationsReporter | null
|
|
74
|
+
readonly #now: () => number
|
|
75
|
+
#window = -1
|
|
76
|
+
#reported = 0
|
|
77
|
+
|
|
78
|
+
/** `reporter` is `null` without an Operations DSN: the log line is then the only record. */
|
|
79
|
+
constructor(reporter: OperationsReporter | null, now: () => number = Date.now) {
|
|
80
|
+
this.#reporter = reporter
|
|
81
|
+
this.#now = now
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Never rejects. The returned delivery may outlive the response; a runtime with `waitUntil` keeps it alive. */
|
|
85
|
+
record(failure: RequestFailure): Promise<void> {
|
|
86
|
+
const code = failureCode(failure.error)
|
|
87
|
+
const why = failureReason(failure.error)
|
|
88
|
+
console.error(`fabrika: ${failure.endpoint}: ${code}${why === null ? '' : `: ${why.reason}: ${why.text}`}`)
|
|
89
|
+
if (this.#reporter === null || !this.#admit()) {
|
|
90
|
+
return Promise.resolve()
|
|
91
|
+
}
|
|
92
|
+
const tags: Record<string, string> = { endpoint: failure.endpoint, code, status: String(failure.status) }
|
|
93
|
+
if (why !== null) {
|
|
94
|
+
tags['reason'] = why.reason
|
|
95
|
+
}
|
|
96
|
+
if (failure.requestId !== null && REQUEST_ID.test(failure.requestId)) {
|
|
97
|
+
tags['request'] = failure.requestId
|
|
98
|
+
}
|
|
99
|
+
return this.#reporter.report({
|
|
100
|
+
fingerprint: ['fabrika-request-failed', failure.endpoint, code],
|
|
101
|
+
type: 'RequestFailed',
|
|
102
|
+
message: `endpoint '${failure.endpoint}' answered ${failure.status}: ${code}`,
|
|
103
|
+
tags,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#admit(): boolean {
|
|
108
|
+
const window = Math.floor(this.#now() / MINUTE_MS)
|
|
109
|
+
if (window !== this.#window) {
|
|
110
|
+
this.#window = window
|
|
111
|
+
this.#reported = 0
|
|
112
|
+
}
|
|
113
|
+
this.#reported++
|
|
114
|
+
return this.#reported <= MAX_FAILURE_REPORTS_PER_MINUTE
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The framework's own failures. The envelope of every 5xx is fixed (`dispatch.ts`'s `errorResponse`), and a server
|
|
2
|
+
// log names only the fault's reason and its catalog text (`failures.ts`): the message may quote request data.
|
|
3
|
+
|
|
4
|
+
/** Why the framework failed a request, as a closed catalog with the fixed text a log may print. */
|
|
5
|
+
export const SERVER_FAULTS = {
|
|
6
|
+
iam_unavailable: 'IAM could not be reached or could not complete the operation',
|
|
7
|
+
delegation_missing: "a membership endpoint was reached without the proxy's delegation grant",
|
|
8
|
+
iam_answer_mismatch: 'IAM answered for another scope or principal than the request asked for',
|
|
9
|
+
iam_answer_unlabelled: "IAM answered a member without a label where the member directory's dimension shows labels",
|
|
10
|
+
link_mount: 'the endpoint asked for a link of a mount it does not issue',
|
|
11
|
+
directory_unlocated: "no table locates the value of the member directory's dimension that the endpoint's object lies in",
|
|
12
|
+
facility_unbound: 'a facility the endpoint needs (assets, data layer, framework tables or IAM transport) was not bound at boot',
|
|
13
|
+
endpoint_decision_missing: "the request did not carry the proxy's endpoint decision",
|
|
14
|
+
endpoint_decision_mismatch: 'the proxy applied other rules than this version serves the endpoint with',
|
|
15
|
+
key_set_unavailable: 'the IAM key set could not be fetched; nothing was checked',
|
|
16
|
+
output_refused: 'the handler returned a value its output schema refuses',
|
|
17
|
+
contract_inconsistent: 'the compiled endpoint misses what its kind needs (a path parameter, a body limit or a capability kind)',
|
|
18
|
+
webhook_binding_mismatch: "a webhook binding reaches another dimension than the webhook's",
|
|
19
|
+
workload_unauthorized: "the workload's key or token does not carry the authority its declaration needs",
|
|
20
|
+
request_cancelled: 'the request was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept',
|
|
21
|
+
file_object_missing: "a file's object is missing from storage",
|
|
22
|
+
file_object_mismatch: "a file's object has another size than its row records",
|
|
23
|
+
file_key_taken: "a new file's object key already held an object this upload did not write",
|
|
24
|
+
file_collection_unknown: 'ctx.files named a collection the contract does not declare',
|
|
25
|
+
file_sweep_failed: "the sweep could not delete a removed or orphaned file's object or row; the next run retries it",
|
|
26
|
+
challenge_unavailable: "a form's challenge provider could not be reached in time or did not answer as it documents",
|
|
27
|
+
challenge_misconfigured: "a form's challenge provider refused the application's secret",
|
|
28
|
+
attachment_rows: 'the handler returned more rows than its attachment declares (maxRows)',
|
|
29
|
+
attachment_cell: 'a cell of the attachment is longer than one cell may hold',
|
|
30
|
+
attachment_size: 'the encoded attachment is larger than it declares (maxBytes); no body was sent',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
export type ServerFaultReason = keyof typeof SERVER_FAULTS
|
|
34
|
+
|
|
35
|
+
/** A failure the framework itself describes. Its message is for the author's own run, never the response or a log. */
|
|
36
|
+
export class ServerFault extends Error {
|
|
37
|
+
override readonly name = 'ServerFault'
|
|
38
|
+
readonly reason: ServerFaultReason
|
|
39
|
+
readonly status: number
|
|
40
|
+
|
|
41
|
+
constructor(reason: ServerFaultReason, message: string, status = 500) {
|
|
42
|
+
super(message)
|
|
43
|
+
this.reason = reason
|
|
44
|
+
this.status = status
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// A form's admission: which bucket a request takes from, and what a submission carries beside its row. The buckets
|
|
2
|
+
// live in the application's database (`@fabrika/db`'s `FormAdmissions`), so every instance shares them. Admission is
|
|
3
|
+
// not authorization: a form is anonymous, and this only bounds how much one client and all clients together submit.
|
|
4
|
+
|
|
5
|
+
import { CLIENT_ADDRESS_HEADER } from '@fabrika/auth-core'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { FORM_CHALLENGE_FIELD } from '../contract/defaults'
|
|
8
|
+
|
|
9
|
+
/** The bucket of a form's accepted submissions; a client's bucket is `client:<digest>`, so the two never meet. */
|
|
10
|
+
export const ACCEPTED_BUCKET = 'accepted'
|
|
11
|
+
|
|
12
|
+
/** Longest header value read; an IPv6 address with a zone id fits with room to spare. */
|
|
13
|
+
const MAX_ADDRESS_LENGTH = 64
|
|
14
|
+
|
|
15
|
+
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/
|
|
16
|
+
const HEXTET = /^[0-9a-f]{1,4}$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The eight groups of an IPv6 address, or `null`. An embedded IPv4 tail (`::ffff:192.0.2.1`) counts as two groups,
|
|
20
|
+
* and a zone id (`%eth0`) is dropped.
|
|
21
|
+
*/
|
|
22
|
+
function ipv6Groups(text: string): number[] | null {
|
|
23
|
+
const address = text.split('%')[0] ?? ''
|
|
24
|
+
const halves = address.split('::')
|
|
25
|
+
if (halves.length > 2) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
const groupsOf = (half: string): number[] | null => {
|
|
29
|
+
if (half === '') {
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
const groups: number[] = []
|
|
33
|
+
const parts = half.split(':')
|
|
34
|
+
for (const [index, part] of parts.entries()) {
|
|
35
|
+
if (index === parts.length - 1 && IPV4.test(part)) {
|
|
36
|
+
const [a = 0, b = 0, c = 0, d = 0] = part.split('.').map(Number)
|
|
37
|
+
groups.push(a * 256 + b, c * 256 + d)
|
|
38
|
+
} else if (HEXTET.test(part)) {
|
|
39
|
+
groups.push(Number.parseInt(part, 16))
|
|
40
|
+
} else {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return groups
|
|
45
|
+
}
|
|
46
|
+
const head = groupsOf(halves[0] ?? '')
|
|
47
|
+
const tail = halves.length === 2 ? groupsOf(halves[1] ?? '') : []
|
|
48
|
+
if (head === null || tail === null) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
if (halves.length === 1) {
|
|
52
|
+
return head.length === 8 ? head : null
|
|
53
|
+
}
|
|
54
|
+
const missing = 8 - head.length - tail.length
|
|
55
|
+
return missing < 1 ? null : [...head, ...Array.from({ length: missing }, () => 0), ...tail]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The client a per-client limit counts: an IPv4 address, or an IPv6 address's /64 (one subscriber's prefix, of which
|
|
60
|
+
* it may hold every address). Anything else, the header's absence included, is one shared `unknown` client.
|
|
61
|
+
*/
|
|
62
|
+
export function clientKey(header: string | null): string {
|
|
63
|
+
const value = (header ?? '').split(',')[0]?.trim().toLowerCase() ?? ''
|
|
64
|
+
if (value === '' || value.length > MAX_ADDRESS_LENGTH) {
|
|
65
|
+
return 'unknown'
|
|
66
|
+
}
|
|
67
|
+
if (IPV4.test(value)) {
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
const groups = ipv6Groups(value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value)
|
|
71
|
+
if (groups === null) {
|
|
72
|
+
return 'unknown'
|
|
73
|
+
}
|
|
74
|
+
const mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff
|
|
75
|
+
if (mapped) {
|
|
76
|
+
const [high = 0, low = 0] = groups.slice(6)
|
|
77
|
+
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.')
|
|
78
|
+
}
|
|
79
|
+
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The bucket of the client the proxy saw. Only `X-Fabrika-Client-Ip` is read: the proxy deletes it and sets it from
|
|
84
|
+
* the connection on every request, and the proxy secret proves the request came through it. A forwarding header a
|
|
85
|
+
* client can write (`X-Forwarded-For`, `CF-Connecting-IP`) is never read. The address is kept as its digest.
|
|
86
|
+
*/
|
|
87
|
+
export function clientBucket(request: Request): string {
|
|
88
|
+
return `client:${createHash('sha256').update(clientKey(request.headers.get(CLIENT_ADDRESS_HEADER))).digest('hex')}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What the form declared beside its row: its honeypot field and whether a challenge token travels in the input. */
|
|
92
|
+
export interface SubmissionFields {
|
|
93
|
+
readonly honeypot: string | null
|
|
94
|
+
readonly challenge: boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface Submission {
|
|
98
|
+
/** The honeypot was filled: the answer is the constant one, and nothing is written. */
|
|
99
|
+
readonly trapped: boolean
|
|
100
|
+
/** The challenge token, when the form declares a challenge. */
|
|
101
|
+
readonly token: string | null
|
|
102
|
+
/** The columns the inbox row gets: the input without the honeypot and the token. */
|
|
103
|
+
readonly row: Readonly<Record<string, unknown>>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function submissionOf(input: Readonly<Record<string, unknown>>, fields: SubmissionFields): Submission {
|
|
107
|
+
const row: Record<string, unknown> = {}
|
|
108
|
+
for (const [name, value] of Object.entries(input)) {
|
|
109
|
+
if (name !== fields.honeypot && !(fields.challenge && name === FORM_CHALLENGE_FIELD)) {
|
|
110
|
+
row[name] = value
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const trap = fields.honeypot === null ? undefined : input[fields.honeypot]
|
|
114
|
+
const token = fields.challenge ? input[FORM_CHALLENGE_FIELD] : undefined
|
|
115
|
+
return {
|
|
116
|
+
trapped: typeof trap === 'string' && trap !== '',
|
|
117
|
+
token: typeof token === 'string' ? token : null,
|
|
118
|
+
row,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The one response header policy of a contract application (ADR-0022, "Response headers"). The framework sets it on
|
|
2
|
+
// every response it answers, denials included; the proxy sets the same definition, from `@fabrika/proxy-core/headers`.
|
|
3
|
+
|
|
4
|
+
import type { CspExtensions } from '@fabrika/auth-core'
|
|
5
|
+
import { applyHeaderPolicy, cspOrigins, type HeaderPolicy, headerPolicy, withResponseHeaders } from '@fabrika/proxy-core/headers'
|
|
6
|
+
|
|
7
|
+
export type { HeaderPolicy }
|
|
8
|
+
|
|
9
|
+
/** The policy of an application: its contract's named origins and the Operations ingest origin the browser reports to. */
|
|
10
|
+
export function applicationPolicy(csp: CspExtensions, operationsOrigin: string | null): HeaderPolicy {
|
|
11
|
+
return headerPolicy(cspOrigins(csp), operationsOrigin)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A copy of `response` carrying the policy; whatever the handler or an asset server set for these names is replaced. */
|
|
15
|
+
export function withHeaderPolicy(response: Response, policy: HeaderPolicy): Response {
|
|
16
|
+
return withResponseHeaders(response, (headers) => applyHeaderPolicy(headers, policy))
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `implement(contract, { database, deps, handlers })`: the only way an application serves its contract. Handlers
|
|
2
|
+
// mirror the contract's procedure tree exactly; there is no slot for routes, middleware, authentication or a server.
|
|
3
|
+
|
|
4
|
+
import type { TablePolicyArtifact } from '@fabrika/auth-core'
|
|
5
|
+
import type { DataLayer } from '@fabrika/db'
|
|
6
|
+
import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
7
|
+
import type { ConnectSnapshot } from '../contract/connect'
|
|
8
|
+
import type { AnyContract } from '../contract/define'
|
|
9
|
+
import type { ConnectSnapshots, ExactHandlers, HandlersOf } from './types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the framework reads from the application's tables before a handler runs. The table policies are what
|
|
13
|
+
* `compileContract` takes; `locate` resolves an object's containment chain for `on` and checks that an `in` scope
|
|
14
|
+
* exists. For `@fabrika/db`'s `scoped(tables)`, `boot` builds one from the locator only it receives when it binds.
|
|
15
|
+
*/
|
|
16
|
+
export interface ContractDatabase {
|
|
17
|
+
readonly tables: Readonly<Record<string, TablePolicyArtifact>>
|
|
18
|
+
/** The containers an object lies in, by dimension, or `null` when the table that owns `dimension` has no such row. */
|
|
19
|
+
locate(dimension: string, id: string): Promise<ReadonlyMap<string, string> | null>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `scoped(tables)` of `@fabrika/db`, which `boot` binds, or a `ContractDatabase` of the application's own. */
|
|
23
|
+
export type ImplementationDatabase = DataLayer | ContractDatabase
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The application's own variables: everything the platform and the operator set except the framework's. Values are
|
|
27
|
+
* unchecked, so `deps` parses what it reads.
|
|
28
|
+
*/
|
|
29
|
+
export type ApplicationEnvironment = Readonly<Record<string, unknown>>
|
|
30
|
+
|
|
31
|
+
/** What the framework hands `deps`: the authority `@fabrika/db` asks whether a capability belongs to this request. */
|
|
32
|
+
export interface DepsRuntime {
|
|
33
|
+
readonly capabilities: CapabilityAuthority
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A contract with its handlers, ready for `boot`. Only `implement` and `serve` make one. */
|
|
37
|
+
export interface ContractImplementation<C extends AnyContract = AnyContract> {
|
|
38
|
+
readonly contract: C
|
|
39
|
+
readonly database: ImplementationDatabase | null
|
|
40
|
+
/** The vendored snapshot of each import, by the import's name; `boot` checks each against the contract's digest. */
|
|
41
|
+
readonly connect?: Readonly<Record<string, ConnectSnapshot>>
|
|
42
|
+
/** Runs `deps`, then `handlers` over its result. The tree is checked by the types of `implement`, and again by `boot`. */
|
|
43
|
+
readonly handlers: (env: ApplicationEnvironment, runtime: DepsRuntime) => unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Binds one handler to every procedure of the contract. `deps` runs once, at `boot`; `handlers` receives what it
|
|
48
|
+
* returned and builds the handler tree, so a handler reaches its dependencies through that closure.
|
|
49
|
+
*/
|
|
50
|
+
export function implement<const C extends AnyContract, Deps, H extends HandlersOf<C, Snap>, Snap extends ConnectSnapshots<C> = ConnectSnapshots<C>>(
|
|
51
|
+
contract: C,
|
|
52
|
+
implementation: {
|
|
53
|
+
readonly database: ImplementationDatabase | null
|
|
54
|
+
/** The vendored snapshot of each import (`fabrika/connect/*.ts`), by the import's name. */
|
|
55
|
+
readonly connect?: Snap
|
|
56
|
+
readonly deps: (env: ApplicationEnvironment, runtime: DepsRuntime) => Deps
|
|
57
|
+
readonly handlers: (deps: Deps) => H & ExactHandlers<H, C>
|
|
58
|
+
},
|
|
59
|
+
): ContractImplementation<C> {
|
|
60
|
+
const { database, connect, deps, handlers } = implementation
|
|
61
|
+
return contractImplementation({
|
|
62
|
+
contract,
|
|
63
|
+
database,
|
|
64
|
+
connect: connect ?? {},
|
|
65
|
+
handlers: (env: ApplicationEnvironment, runtime: DepsRuntime) => handlers(deps(env, runtime)),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const implementations = new WeakSet<object>()
|
|
70
|
+
|
|
71
|
+
/** The one way a `ContractImplementation` is made, by `implement` and `serve`: frozen, and known to `isContractImplementation`. */
|
|
72
|
+
export function contractImplementation<C extends AnyContract>(made: ContractImplementation<C>): ContractImplementation<C> {
|
|
73
|
+
const frozen = Object.freeze({ ...made })
|
|
74
|
+
implementations.add(frozen)
|
|
75
|
+
return frozen
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether `value` is what `implement` or `serve` returned: how a loader checks a module it imported by path. */
|
|
79
|
+
export function isContractImplementation(value: unknown): value is ContractImplementation {
|
|
80
|
+
return typeof value === 'object' && value !== null && implementations.has(value)
|
|
81
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @fabrika/app/implement — serving a contract: `serve` binds the handlers its endpoints carry, `boot` (called only by
|
|
2
|
+
// the generated entrypoint, through a runtime adapter) serves it. Imports `node:async_hooks`, so it is not browser-safe.
|
|
3
|
+
// `implement` (a mirrored handler tree) is the framework's own test entry, `./implement`, and no application's.
|
|
4
|
+
export { FILE_SWEEP_JOB } from '../files/collections'
|
|
5
|
+
export type { FileCollectionHandle, FileCollectionReader, FileListing, ListedFile, OpenedFile } from '../files/handle'
|
|
6
|
+
export {
|
|
7
|
+
FILE_SCAN_JOB,
|
|
8
|
+
type FileScanner,
|
|
9
|
+
SCAN_LIMITS,
|
|
10
|
+
type ScanBatchResult,
|
|
11
|
+
type ScanFailure,
|
|
12
|
+
type ScanObject,
|
|
13
|
+
type ScanVerdict,
|
|
14
|
+
} from '../files/scan'
|
|
15
|
+
export type { OperationsReport, OperationsReporter } from '../jobs/report'
|
|
16
|
+
export { type ContractJobs, type JobClock, JobError, type JobFailureCode, type JobRunOptions, type JobRunResult } from '../jobs/runner'
|
|
17
|
+
export type { JobContext, JobHandler, JobHandlersOf, JobPrincipal } from '../jobs/types'
|
|
18
|
+
export { LINK_BOOTSTRAP_SCRIPT } from './anonymous'
|
|
19
|
+
export { boot, BootError, type BootOptions, type ContractApp, type IamKeySource } from './boot'
|
|
20
|
+
export { readBoundedBytes, REQUEST_BUDGET_MS } from './bounded'
|
|
21
|
+
export { AppCapability, CapabilityRefusedError, Ref, Within } from './capability'
|
|
22
|
+
export { CHALLENGE_LIMITS, type ChallengeFetch, TURNSTILE_VERIFY_URL } from './challenge'
|
|
23
|
+
export { type ConnectFailure, type ConnectFetch, type ConnectResult, type ConnectVariables, connectVariables, isJsonMediaType } from './connect'
|
|
24
|
+
export type { SpaAssets } from './dispatch'
|
|
25
|
+
export { type CapturedEnvironment, captureEnvironment } from './environment'
|
|
26
|
+
export type { ApplicationEnvironment, ContractDatabase, ContractImplementation, DepsRuntime, ImplementationDatabase } from './implement'
|
|
27
|
+
export { isContractImplementation } from './implement'
|
|
28
|
+
export type {
|
|
29
|
+
ApplicationDirectory,
|
|
30
|
+
ApplicationMember,
|
|
31
|
+
ApplicationMemberPage,
|
|
32
|
+
ApplicationMemberRole,
|
|
33
|
+
AssignedMember,
|
|
34
|
+
DirectoryMember,
|
|
35
|
+
IssuedLink,
|
|
36
|
+
LinkIssuing,
|
|
37
|
+
Member,
|
|
38
|
+
MemberDirectory,
|
|
39
|
+
MemberList,
|
|
40
|
+
MemberLookup,
|
|
41
|
+
MemberManagement,
|
|
42
|
+
MemberTarget,
|
|
43
|
+
} from './membership'
|
|
44
|
+
export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, ServicePrincipal, UserPrincipal } from './principal'
|
|
45
|
+
export { type AppDeps, defineDeps, serve, type ServeOptions } from './serve'
|
|
46
|
+
export type {
|
|
47
|
+
AccessContext,
|
|
48
|
+
ConnectCall,
|
|
49
|
+
ConnectContext,
|
|
50
|
+
ConnectSnapshots,
|
|
51
|
+
ContractContext,
|
|
52
|
+
ExactHandlers,
|
|
53
|
+
Handler,
|
|
54
|
+
HandlerInput,
|
|
55
|
+
HandlersOf,
|
|
56
|
+
RequestContext,
|
|
57
|
+
WebhookHandler,
|
|
58
|
+
} from './types'
|
|
59
|
+
export type { WebhookDelivery, WebhookPrincipal } from './webhook'
|
|
60
|
+
export { issuerWorkloadTokens, type WorkloadTokenSource } from './workload'
|