@fabrika/app 0.3.2 → 0.4.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/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -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 +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -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 +309 -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 +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -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 +808 -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 +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -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 +749 -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 +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -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/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -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,72 @@
|
|
|
1
|
+
// Reading a body with a byte budget, and a handler's time budget. A body over the budget, or one whose reader the
|
|
2
|
+
// deadline cancels, is never buffered whole: the stream is cancelled and nothing of it is kept.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long a request handler runs before its `ctx.signal` aborts and its transaction cannot commit. It equals
|
|
6
|
+
* `Bun.serve`'s default `idleTimeout`, which already closes a connection the server sends nothing on for that long, so
|
|
7
|
+
* both runtimes stop a handler at the same point.
|
|
8
|
+
*/
|
|
9
|
+
export const REQUEST_BUDGET_MS = 10_000
|
|
10
|
+
|
|
11
|
+
/** A handler's `ctx.signal`: aborted when the client leaves (`request.signal`) or once `budgetMs` passed. */
|
|
12
|
+
export function handlerSignal(request: Request, budgetMs: number = REQUEST_BUDGET_MS): AbortSignal {
|
|
13
|
+
return AbortSignal.any([request.signal, AbortSignal.timeout(budgetMs)])
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BoundedRead {
|
|
17
|
+
readonly maxBytes: number
|
|
18
|
+
/** `Content-Length`, when the sender declared one: a larger value is refused before anything is read. */
|
|
19
|
+
readonly declaredLength?: string | null
|
|
20
|
+
/** Aborting it cancels the stream; the read then rejects. */
|
|
21
|
+
readonly signal?: AbortSignal
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The body's bytes, or `null` when it is larger than `maxBytes`. Rejects when `signal` aborts the read. */
|
|
25
|
+
export async function readBoundedBytes(stream: ReadableStream<Uint8Array> | null, options: BoundedRead): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
26
|
+
const declared = Number(options.declaredLength ?? '')
|
|
27
|
+
if (options.declaredLength !== undefined && options.declaredLength !== null && Number.isFinite(declared) && declared > options.maxBytes) {
|
|
28
|
+
await stream?.cancel()
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
const reader = stream?.getReader()
|
|
32
|
+
if (reader === undefined) {
|
|
33
|
+
return new Uint8Array()
|
|
34
|
+
}
|
|
35
|
+
const { signal } = options
|
|
36
|
+
const aborted = (): boolean => signal?.aborted === true
|
|
37
|
+
const cancel = () => {
|
|
38
|
+
reader.cancel().catch(() => {})
|
|
39
|
+
}
|
|
40
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
41
|
+
try {
|
|
42
|
+
const chunks: Uint8Array[] = []
|
|
43
|
+
let size = 0
|
|
44
|
+
for (;;) {
|
|
45
|
+
if (aborted()) {
|
|
46
|
+
throw new Error('the read was aborted')
|
|
47
|
+
}
|
|
48
|
+
const { done, value } = await reader.read()
|
|
49
|
+
if (aborted()) {
|
|
50
|
+
throw new Error('the read was aborted')
|
|
51
|
+
}
|
|
52
|
+
if (done) {
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
size += value.byteLength
|
|
56
|
+
if (size > options.maxBytes) {
|
|
57
|
+
await reader.cancel()
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
chunks.push(value)
|
|
61
|
+
}
|
|
62
|
+
const body = new Uint8Array(size)
|
|
63
|
+
let offset = 0
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
body.set(chunk, offset)
|
|
66
|
+
offset += chunk.byteLength
|
|
67
|
+
}
|
|
68
|
+
return body
|
|
69
|
+
} finally {
|
|
70
|
+
signal?.removeEventListener('abort', cancel)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What an SPA mount answers at `<prefix>/__fabrika-config.json`: the browser's Operations settings and the public
|
|
2
|
+
// values the contract names, and nothing else of the environment.
|
|
3
|
+
|
|
4
|
+
import type { ContractArtifact } from '@fabrika/auth-core'
|
|
5
|
+
import type { CapturedEnvironment } from './environment'
|
|
6
|
+
|
|
7
|
+
export interface BrowserConfig {
|
|
8
|
+
/** The Operations ingest DSN (a public key), or `null` without one. */
|
|
9
|
+
readonly dsn: string | null
|
|
10
|
+
readonly release: string | null
|
|
11
|
+
/**
|
|
12
|
+
* Every form challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
|
|
13
|
+
* there is none, so the file of an application without them holds the two Operations values only.
|
|
14
|
+
*/
|
|
15
|
+
readonly public?: Readonly<Record<string, string>>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The browser config of each SPA mount, by mount name: the Operations settings, and the public variables the contract
|
|
20
|
+
* names. A named variable that is not set is a boot problem.
|
|
21
|
+
*/
|
|
22
|
+
export function browserConfigs(
|
|
23
|
+
artifact: ContractArtifact,
|
|
24
|
+
environment: CapturedEnvironment,
|
|
25
|
+
operations: Pick<BrowserConfig, 'dsn' | 'release'>,
|
|
26
|
+
problems: string[],
|
|
27
|
+
): ReadonlyMap<string, BrowserConfig> {
|
|
28
|
+
// A value is served as the environment holds it; only a blank one counts as unset.
|
|
29
|
+
const valueOf = (name: string): string | null => {
|
|
30
|
+
const value: unknown = environment.application[name]
|
|
31
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null
|
|
32
|
+
}
|
|
33
|
+
const siteKeys: Record<string, string> = {}
|
|
34
|
+
for (const [form, mount] of Object.entries(artifact.mounts)) {
|
|
35
|
+
const challenge = mount.kind === 'form' ? mount.challenge : undefined
|
|
36
|
+
if (challenge?.siteKey === undefined) continue
|
|
37
|
+
const value = valueOf(challenge.siteKey)
|
|
38
|
+
if (value === null) problems.push(`form '${form}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
|
|
39
|
+
else siteKeys[challenge.siteKey] = value
|
|
40
|
+
}
|
|
41
|
+
const configs = new Map<string, BrowserConfig>()
|
|
42
|
+
for (const [name, mount] of Object.entries(artifact.mounts)) {
|
|
43
|
+
if (mount.kind !== 'spa') continue
|
|
44
|
+
const own: Record<string, string> = {}
|
|
45
|
+
for (const variable of mount.public ?? []) {
|
|
46
|
+
const value = valueOf(variable)
|
|
47
|
+
if (value === null) problems.push(`mount '${name}' serves the public variable ${variable}, which is not set`)
|
|
48
|
+
else own[variable] = value
|
|
49
|
+
}
|
|
50
|
+
const served = { ...siteKeys, ...own }
|
|
51
|
+
configs.set(name, Object.freeze(Object.keys(served).length === 0 ? { ...operations } : { ...operations, public: Object.freeze(served) }))
|
|
52
|
+
}
|
|
53
|
+
return configs
|
|
54
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// `Ref` and `Within`: the capabilities the framework mints after an access check. A handler receives them in `ctx`;
|
|
2
|
+
// it can neither construct one nor use one outside the request that obtained it. `@fabrika/db` checks the kind, the
|
|
3
|
+
// dimension and the action of every capability it is handed, and asks the runtime's authority whether it is authentic.
|
|
4
|
+
// Each `boot` makes its own issuer: its stores accept only what its own dispatcher and job runner minted, so a mint
|
|
5
|
+
// that application code makes, or a second runtime it boots, reaches none of them.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type AppCapability as AppCapabilityType,
|
|
9
|
+
type AppGrant,
|
|
10
|
+
type CapabilityAuthority,
|
|
11
|
+
type CapabilityGrant,
|
|
12
|
+
type ContainerGrant,
|
|
13
|
+
describeGrant,
|
|
14
|
+
type LeaseFence,
|
|
15
|
+
MAX_WITHIN_VALUES,
|
|
16
|
+
type Ref as RefCapability,
|
|
17
|
+
type RefGrant,
|
|
18
|
+
type Within as WithinCapability,
|
|
19
|
+
type WithinGrant,
|
|
20
|
+
} from '@fabrika/db/capability'
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
22
|
+
|
|
23
|
+
/** Why a capability or a handle was refused, as a closed catalog with the fixed text a log may print. */
|
|
24
|
+
export const CAPABILITY_REFUSALS = {
|
|
25
|
+
not_minted: 'a value the framework did not mint was used as a capability; pass the ctx.target or ctx.within of this request',
|
|
26
|
+
ended: 'a capability or handle was used after its request, invocation or attempt ended',
|
|
27
|
+
foreign_work: 'a capability or handle was used from other work than the request it belongs to',
|
|
28
|
+
wrong_target: "an IAM request was not passed this request's ctx.target",
|
|
29
|
+
undeclared_action: "the job's declaration or its token does not carry the action",
|
|
30
|
+
undeclared_dimension: 'the job named a dimension the contract does not declare',
|
|
31
|
+
missing_id: 'the job named an object without an id',
|
|
32
|
+
wrong_capability: 'a file collection was handed a capability of another dimension or action than the operation needs',
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
export type CapabilityRefusal = keyof typeof CAPABILITY_REFUSALS
|
|
36
|
+
|
|
37
|
+
/** A capability that is forged, from a request that has ended, or from another request than the current one. */
|
|
38
|
+
export class CapabilityRefusedError extends Error {
|
|
39
|
+
override readonly name = 'CapabilityRefusedError'
|
|
40
|
+
/** What a log may say; the message is for the author's own run. */
|
|
41
|
+
readonly reason: CapabilityRefusal
|
|
42
|
+
|
|
43
|
+
constructor(reason: CapabilityRefusal, message: string) {
|
|
44
|
+
super(message)
|
|
45
|
+
this.reason = reason
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A `Within` that would carry more scope values than one statement may bind; the caller is refused, not truncated. */
|
|
50
|
+
export class CapabilityLimitError extends Error {
|
|
51
|
+
override readonly name = 'CapabilityLimitError'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One request's lifetime, or one job attempt's. A capability minted in it is usable only while it runs, and only
|
|
56
|
+
* inside it; a job attempt's writes also need its lease (`fence`).
|
|
57
|
+
*/
|
|
58
|
+
export class RequestFrame {
|
|
59
|
+
readonly id: string
|
|
60
|
+
readonly fence: LeaseFence | null
|
|
61
|
+
/** An export's request: the data layer runs its every statement `READ ONLY` and refuses every write. */
|
|
62
|
+
readonly readOnly: boolean
|
|
63
|
+
/** The endpoint the request is served by; `ctx.connect` checks it against each import's `usedBy`. */
|
|
64
|
+
readonly endpoint: string | null
|
|
65
|
+
#ended = false
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
id: string,
|
|
69
|
+
options: { readonly fence?: LeaseFence | null; readonly readOnly?: boolean; readonly endpoint?: string | null } = {},
|
|
70
|
+
) {
|
|
71
|
+
this.id = id
|
|
72
|
+
this.fence = options.fence ?? null
|
|
73
|
+
this.readOnly = options.readOnly ?? false
|
|
74
|
+
this.endpoint = options.endpoint ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get ended(): boolean {
|
|
78
|
+
return this.#ended
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
end(): void {
|
|
82
|
+
this.#ended = true
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const currentRequest = new AsyncLocalStorage<RequestFrame>()
|
|
87
|
+
|
|
88
|
+
/** Run `work` as the request `frame`: every capability check inside it compares against this frame. */
|
|
89
|
+
export function runInRequest<T>(frame: RequestFrame, work: () => Promise<T>): Promise<T> {
|
|
90
|
+
return currentRequest.run(frame, work)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether `frame` is the request running now. */
|
|
94
|
+
export function isActiveRequest(frame: RequestFrame): boolean {
|
|
95
|
+
return currentRequest.getStore() === frame
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the request running now may only read; `@fabrika/db` asks it before every statement. */
|
|
99
|
+
export function requestIsReadOnly(): boolean {
|
|
100
|
+
return currentRequest.getStore()?.readOnly === true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The module-private token that makes a `Ref` or a `Within` constructible by the framework only. */
|
|
104
|
+
const MINT: unique symbol = Symbol('fabrika.capability.mint')
|
|
105
|
+
|
|
106
|
+
// Captured when the module loads, before application code: a later patch to Map, Object or Reflect does not change
|
|
107
|
+
// how a chain is copied.
|
|
108
|
+
const CapturedMap = Map
|
|
109
|
+
const mapSet = Map.prototype.set
|
|
110
|
+
const mapForEach: (this: ReadonlyMap<string, string>, callback: (value: string, key: string) => void) => void = Map.prototype.forEach
|
|
111
|
+
const apply = Reflect.apply
|
|
112
|
+
const freeze = Object.freeze
|
|
113
|
+
|
|
114
|
+
type Chain = readonly (readonly [dimension: string, value: string])[]
|
|
115
|
+
|
|
116
|
+
function chainOf(map: ReadonlyMap<string, string>): Chain {
|
|
117
|
+
const links: (readonly [string, string])[] = []
|
|
118
|
+
apply(mapForEach, map, [
|
|
119
|
+
(value: string, key: string) => {
|
|
120
|
+
links[links.length] = freeze([key, value])
|
|
121
|
+
},
|
|
122
|
+
])
|
|
123
|
+
return freeze(links)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A fresh Map of the chain: whoever holds it may change it, and the next resolution is a new copy again. */
|
|
127
|
+
function mapOf(chain: Chain): Map<string, string> {
|
|
128
|
+
const map = new CapturedMap<string, string>()
|
|
129
|
+
for (let index = 0; index < chain.length; index++) {
|
|
130
|
+
const link = chain[index]
|
|
131
|
+
if (link !== undefined) {
|
|
132
|
+
apply(mapSet, map, [link[0], link[1]])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return map
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function copyOf(values: readonly string[]): readonly string[] {
|
|
139
|
+
const copy: string[] = []
|
|
140
|
+
for (let index = 0; index < values.length; index++) {
|
|
141
|
+
copy[index] = values[index] ?? ''
|
|
142
|
+
}
|
|
143
|
+
return freeze(copy)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A frozen copy of the container grants, by index: a later patch to Array methods does not change what was minted. */
|
|
147
|
+
function containerGrantsOf(containers: readonly ContainerGrant[]): readonly ContainerGrant[] {
|
|
148
|
+
const copy: ContainerGrant[] = []
|
|
149
|
+
for (let index = 0; index < containers.length; index++) {
|
|
150
|
+
const container = containers[index]
|
|
151
|
+
if (container !== undefined) {
|
|
152
|
+
copy[copy.length] = freeze({ dimension: container.dimension, values: copyOf(container.values) })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return freeze(copy)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface RefFacts {
|
|
159
|
+
readonly dimension: string
|
|
160
|
+
readonly action: string
|
|
161
|
+
readonly principal: string
|
|
162
|
+
readonly id: string
|
|
163
|
+
readonly chain: Chain
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface Bound {
|
|
167
|
+
readonly grant: CapabilityGrant
|
|
168
|
+
readonly frame: RequestFrame
|
|
169
|
+
/** The issuer that minted it; only that issuer's authority accepts it. */
|
|
170
|
+
readonly issuer: object
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let readRef: (value: unknown) => Bound | undefined = () => undefined
|
|
174
|
+
let readWithin: (value: unknown) => Bound | undefined = () => undefined
|
|
175
|
+
let readApp: (value: unknown) => Bound | undefined = () => undefined
|
|
176
|
+
|
|
177
|
+
/** One authorized object for one action, in one request. `chain` holds the containers the object lies in. */
|
|
178
|
+
export class Ref<D extends string, A extends string> implements RefCapability<D, A> {
|
|
179
|
+
declare readonly '~capability': { readonly kind: 'ref'; readonly dimension: D; readonly action: A }
|
|
180
|
+
readonly #dimension: D
|
|
181
|
+
readonly #action: A
|
|
182
|
+
readonly #facts: RefFacts
|
|
183
|
+
readonly #frame: RequestFrame
|
|
184
|
+
readonly #issuer: object
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
token: typeof MINT,
|
|
188
|
+
issuer: object,
|
|
189
|
+
frame: RequestFrame,
|
|
190
|
+
dimension: D,
|
|
191
|
+
action: A,
|
|
192
|
+
principal: string,
|
|
193
|
+
id: string,
|
|
194
|
+
chain: ReadonlyMap<string, string>,
|
|
195
|
+
) {
|
|
196
|
+
if (token !== MINT) {
|
|
197
|
+
throw new CapabilityRefusedError('not_minted', 'a Ref is minted by the framework only; declare the access form in fabrika.contract.ts')
|
|
198
|
+
}
|
|
199
|
+
this.#dimension = dimension
|
|
200
|
+
this.#action = action
|
|
201
|
+
this.#facts = freeze({ dimension, action, principal, id, chain: chainOf(chain) })
|
|
202
|
+
this.#frame = frame
|
|
203
|
+
this.#issuer = issuer
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get dimension(): D {
|
|
207
|
+
return this.#dimension
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get action(): A {
|
|
211
|
+
return this.#action
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The object's id: the value of its dimension. */
|
|
215
|
+
get value(): string {
|
|
216
|
+
return this.#facts.id
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The containers of the object, by dimension (`workspace → w1` for a note); a copy. */
|
|
220
|
+
get chain(): ReadonlyMap<string, string> {
|
|
221
|
+
return mapOf(this.#facts.chain)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
static {
|
|
225
|
+
readRef = (value) => {
|
|
226
|
+
if (typeof value !== 'object' || value === null || !(#facts in value)) {
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
const facts = value.#facts
|
|
230
|
+
const grant: RefGrant = freeze({
|
|
231
|
+
kind: 'ref',
|
|
232
|
+
dimension: facts.dimension,
|
|
233
|
+
action: facts.action,
|
|
234
|
+
principal: facts.principal,
|
|
235
|
+
id: facts.id,
|
|
236
|
+
chain: mapOf(facts.chain),
|
|
237
|
+
})
|
|
238
|
+
return { grant, frame: value.#frame, issuer: value.#issuer }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The values of one dimension in which the caller holds one action, in one request; `all` for a global grant. The
|
|
245
|
+
* containers whose grants also authorise it stay separate, so the data layer matches them against each row's chain.
|
|
246
|
+
*/
|
|
247
|
+
export class Within<D extends string, A extends string> implements WithinCapability<D, A> {
|
|
248
|
+
declare readonly '~capability': { readonly kind: 'within'; readonly dimension: D; readonly action: A }
|
|
249
|
+
readonly #dimension: D
|
|
250
|
+
readonly #action: A
|
|
251
|
+
readonly #grant: WithinGrant
|
|
252
|
+
readonly #frame: RequestFrame
|
|
253
|
+
readonly #issuer: object
|
|
254
|
+
|
|
255
|
+
constructor(
|
|
256
|
+
token: typeof MINT,
|
|
257
|
+
issuer: object,
|
|
258
|
+
frame: RequestFrame,
|
|
259
|
+
dimension: D,
|
|
260
|
+
action: A,
|
|
261
|
+
principal: string,
|
|
262
|
+
values: readonly string[] | 'all',
|
|
263
|
+
containers: readonly ContainerGrant[],
|
|
264
|
+
) {
|
|
265
|
+
if (token !== MINT) {
|
|
266
|
+
throw new CapabilityRefusedError('not_minted', 'a Within is minted by the framework only; declare the access form in fabrika.contract.ts')
|
|
267
|
+
}
|
|
268
|
+
const held = values === 'all' ? freeze([]) : containerGrantsOf(containers)
|
|
269
|
+
let count = values === 'all' ? 0 : values.length
|
|
270
|
+
for (let index = 0; index < held.length; index++) {
|
|
271
|
+
count += held[index]?.values.length ?? 0
|
|
272
|
+
}
|
|
273
|
+
if (count > MAX_WITHIN_VALUES) {
|
|
274
|
+
throw new CapabilityLimitError(
|
|
275
|
+
`the caller holds '${action}' in ${count} scope values for a ${dimension} list; a Within carries at most ${MAX_WITHIN_VALUES}`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
this.#dimension = dimension
|
|
279
|
+
this.#action = action
|
|
280
|
+
this.#grant = freeze({
|
|
281
|
+
kind: 'within',
|
|
282
|
+
dimension,
|
|
283
|
+
action,
|
|
284
|
+
principal,
|
|
285
|
+
values: values === 'all' ? 'all' : copyOf(values),
|
|
286
|
+
containers: held,
|
|
287
|
+
})
|
|
288
|
+
this.#frame = frame
|
|
289
|
+
this.#issuer = issuer
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
get dimension(): D {
|
|
293
|
+
return this.#dimension
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
get action(): A {
|
|
297
|
+
return this.#action
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The values of this dimension itself. */
|
|
301
|
+
get values(): readonly string[] | 'all' {
|
|
302
|
+
return this.#grant.values
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The containers in which the caller also holds the action: `organization → [o1]` for a workspace list. */
|
|
306
|
+
get containers(): readonly ContainerGrant[] {
|
|
307
|
+
return this.#grant.containers
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
static {
|
|
311
|
+
readWithin = (value) =>
|
|
312
|
+
typeof value === 'object' && value !== null && #grant in value ? { grant: value.#grant, frame: value.#frame, issuer: value.#issuer } : undefined
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One action held as a global grant, in one request: what an `app` endpoint's `ctx.db.app` is built from. The proxy
|
|
318
|
+
* admitted the request only for a caller who holds the action without a scope.
|
|
319
|
+
*/
|
|
320
|
+
export class AppCapability<A extends string> implements AppCapabilityType<A> {
|
|
321
|
+
declare readonly '~capability': { readonly kind: 'app'; readonly action: A }
|
|
322
|
+
readonly #action: A
|
|
323
|
+
readonly #grant: AppGrant
|
|
324
|
+
readonly #frame: RequestFrame
|
|
325
|
+
readonly #issuer: object
|
|
326
|
+
|
|
327
|
+
constructor(token: typeof MINT, issuer: object, frame: RequestFrame, action: A, principal: string) {
|
|
328
|
+
if (token !== MINT) {
|
|
329
|
+
throw new CapabilityRefusedError('not_minted', 'an app capability is minted by the framework only; declare access: { action, app: true }')
|
|
330
|
+
}
|
|
331
|
+
this.#action = action
|
|
332
|
+
this.#grant = freeze({ kind: 'app', action, principal })
|
|
333
|
+
this.#frame = frame
|
|
334
|
+
this.#issuer = issuer
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
get action(): A {
|
|
338
|
+
return this.#action
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
static {
|
|
342
|
+
readApp = (value) =>
|
|
343
|
+
typeof value === 'object' && value !== null && #grant in value ? { grant: value.#grant, frame: value.#frame, issuer: value.#issuer } : undefined
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Application code could otherwise redefine a getter that the framework or a handler reads.
|
|
348
|
+
freeze(Ref.prototype)
|
|
349
|
+
freeze(Within.prototype)
|
|
350
|
+
freeze(AppCapability.prototype)
|
|
351
|
+
|
|
352
|
+
/** Makes the capabilities one authority accepts; a runtime's is held by the `boot` call that made it alone. */
|
|
353
|
+
export interface CapabilityMint {
|
|
354
|
+
ref<D extends string, A extends string>(
|
|
355
|
+
frame: RequestFrame,
|
|
356
|
+
dimension: D,
|
|
357
|
+
action: A,
|
|
358
|
+
principal: string,
|
|
359
|
+
id: string,
|
|
360
|
+
chain: ReadonlyMap<string, string>,
|
|
361
|
+
): Ref<D, A>
|
|
362
|
+
within<D extends string, A extends string>(
|
|
363
|
+
frame: RequestFrame,
|
|
364
|
+
dimension: D,
|
|
365
|
+
action: A,
|
|
366
|
+
principal: string,
|
|
367
|
+
values: readonly string[] | 'all',
|
|
368
|
+
containers?: readonly ContainerGrant[],
|
|
369
|
+
): Within<D, A>
|
|
370
|
+
app<A extends string>(frame: RequestFrame, action: A, principal: string): AppCapability<A>
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A mint and the one authority that accepts what it mints. */
|
|
374
|
+
export interface CapabilityIssuer {
|
|
375
|
+
readonly mint: CapabilityMint
|
|
376
|
+
readonly authority: CapabilityAuthority
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A new issuer: its authority refuses every capability another issuer minted. Every `boot` makes one and binds its
|
|
381
|
+
* data layer and its deps to the authority; a test that needs a capability no access check gave makes another, whose
|
|
382
|
+
* capabilities reach only stores bound to that issuer's authority.
|
|
383
|
+
*/
|
|
384
|
+
export function createCapabilityIssuer(): CapabilityIssuer {
|
|
385
|
+
const issuer = freeze({})
|
|
386
|
+
const mint: CapabilityMint = freeze({
|
|
387
|
+
ref<D extends string, A extends string>(
|
|
388
|
+
frame: RequestFrame,
|
|
389
|
+
dimension: D,
|
|
390
|
+
action: A,
|
|
391
|
+
principal: string,
|
|
392
|
+
id: string,
|
|
393
|
+
chain: ReadonlyMap<string, string>,
|
|
394
|
+
): Ref<D, A> {
|
|
395
|
+
return new Ref(MINT, issuer, frame, dimension, action, principal, id, chain)
|
|
396
|
+
},
|
|
397
|
+
within<D extends string, A extends string>(
|
|
398
|
+
frame: RequestFrame,
|
|
399
|
+
dimension: D,
|
|
400
|
+
action: A,
|
|
401
|
+
principal: string,
|
|
402
|
+
values: readonly string[] | 'all',
|
|
403
|
+
containers: readonly ContainerGrant[] = [],
|
|
404
|
+
): Within<D, A> {
|
|
405
|
+
return new Within(MINT, issuer, frame, dimension, action, principal, values, containers)
|
|
406
|
+
},
|
|
407
|
+
app<A extends string>(frame: RequestFrame, action: A, principal: string): AppCapability<A> {
|
|
408
|
+
return new AppCapability(MINT, issuer, frame, action, principal)
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
const authority: CapabilityAuthority = freeze({
|
|
412
|
+
resolve(capability: unknown): CapabilityGrant {
|
|
413
|
+
const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability)
|
|
414
|
+
if (bound === undefined || bound.issuer !== issuer) {
|
|
415
|
+
throw new CapabilityRefusedError('not_minted', 'not a capability the framework minted: pass the ctx.target or ctx.within of this request')
|
|
416
|
+
}
|
|
417
|
+
const { grant, frame } = bound
|
|
418
|
+
const what = `${describeGrant(grant)} for '${grant.action}'`
|
|
419
|
+
if (frame.ended) {
|
|
420
|
+
throw new CapabilityRefusedError(
|
|
421
|
+
'ended',
|
|
422
|
+
`the capability (${what}) belongs to request ${frame.id}, which has ended; never keep a capability past its request`,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
if (currentRequest.getStore() !== frame) {
|
|
426
|
+
throw new CapabilityRefusedError(
|
|
427
|
+
'foreign_work',
|
|
428
|
+
`the capability (${what}) belongs to request ${frame.id}, not to the one running now; never share a capability`,
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
return grant
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
fence(): LeaseFence | null {
|
|
435
|
+
return currentRequest.getStore()?.fence ?? null
|
|
436
|
+
},
|
|
437
|
+
})
|
|
438
|
+
return freeze({ mint, authority })
|
|
439
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// A form's challenge, verified on the server before its row is written. The provider list is closed: Turnstile's
|
|
2
|
+
// siteverify is the only endpoint the framework asks, with the secret from the application variable the contract
|
|
3
|
+
// names. A token verifies only for this host and this form's action, while it is fresh; the dispatcher then records
|
|
4
|
+
// it, so its second use is a replay whatever the provider says.
|
|
5
|
+
|
|
6
|
+
import type { ChallengeProvider } from '@fabrika/auth-core'
|
|
7
|
+
import { z } from 'zod/v4'
|
|
8
|
+
import { readBoundedBytes } from './bounded'
|
|
9
|
+
|
|
10
|
+
export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
|
11
|
+
|
|
12
|
+
export const CHALLENGE_LIMITS = {
|
|
13
|
+
/** The provider's answer, the body read included. */
|
|
14
|
+
timeoutMs: 5000,
|
|
15
|
+
maxAnswerBytes: 16 * 1024,
|
|
16
|
+
/** Turnstile's tokens are at most 2048 characters. */
|
|
17
|
+
maxTokenLength: 2048,
|
|
18
|
+
/** A token is valid for 300 s after the widget issued it; a recorded one is kept as long, plus the skew. */
|
|
19
|
+
maxAgeMs: 300_000,
|
|
20
|
+
maxSkewMs: 60_000,
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type ChallengeFetch = (request: Request) => Promise<Response>
|
|
24
|
+
|
|
25
|
+
/** `failed` is the visitor's (400); `unavailable` and `misconfigured` are the platform's or the operator's (503). */
|
|
26
|
+
export type ChallengeOutcome = 'passed' | 'failed' | 'unavailable' | 'misconfigured'
|
|
27
|
+
|
|
28
|
+
export interface ChallengeCheck {
|
|
29
|
+
readonly token: string
|
|
30
|
+
/** The host the form was submitted to: the page the widget ran on must be on it. */
|
|
31
|
+
readonly hostname: string
|
|
32
|
+
/** The form's name, which its widget renders as `action`. */
|
|
33
|
+
readonly action: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChallengeVerifier {
|
|
37
|
+
readonly provider: ChallengeProvider
|
|
38
|
+
verify(check: ChallengeCheck): Promise<ChallengeOutcome>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A token as the widget issues it: printable ASCII without spaces, within the provider's length. */
|
|
42
|
+
const TOKEN = new RegExp(`^[\\x21-\\x7e]{1,${CHALLENGE_LIMITS.maxTokenLength}}$`)
|
|
43
|
+
|
|
44
|
+
// Not strict: siteverify adds fields (`cdata`, `metadata`) the decision does not read.
|
|
45
|
+
const siteverifyAnswer = z.object({
|
|
46
|
+
success: z.boolean(),
|
|
47
|
+
'error-codes': z.array(z.string()).optional(),
|
|
48
|
+
challenge_ts: z.string().optional(),
|
|
49
|
+
hostname: z.string().optional(),
|
|
50
|
+
action: z.string().optional(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** Error codes that say the secret, not the visitor's token, is wrong. */
|
|
54
|
+
const SECRET_ERRORS = ['missing-input-secret', 'invalid-input-secret']
|
|
55
|
+
|
|
56
|
+
/** Turnstile's siteverify under one deadline and a byte budget; `now` is the clock a token's age is read with. */
|
|
57
|
+
export function turnstileVerifier(secret: string, fetch: ChallengeFetch, now: () => number = Date.now): ChallengeVerifier {
|
|
58
|
+
const verifier: ChallengeVerifier = {
|
|
59
|
+
provider: 'turnstile',
|
|
60
|
+
async verify(check: ChallengeCheck): Promise<ChallengeOutcome> {
|
|
61
|
+
if (!TOKEN.test(check.token)) {
|
|
62
|
+
return 'failed'
|
|
63
|
+
}
|
|
64
|
+
const deadline = new AbortController()
|
|
65
|
+
const timer = setTimeout(() => deadline.abort(), CHALLENGE_LIMITS.timeoutMs)
|
|
66
|
+
let raw: Uint8Array<ArrayBuffer> | null
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(
|
|
69
|
+
new Request(TURNSTILE_VERIFY_URL, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
72
|
+
body: new URLSearchParams({ secret, response: check.token }),
|
|
73
|
+
redirect: 'manual',
|
|
74
|
+
signal: deadline.signal,
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
|
|
78
|
+
if (response.status !== 200 || type !== 'application/json') {
|
|
79
|
+
await response.body?.cancel()
|
|
80
|
+
return 'unavailable'
|
|
81
|
+
}
|
|
82
|
+
raw = await readBoundedBytes(response.body, { maxBytes: CHALLENGE_LIMITS.maxAnswerBytes, signal: deadline.signal })
|
|
83
|
+
} catch {
|
|
84
|
+
return 'unavailable'
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer)
|
|
87
|
+
}
|
|
88
|
+
const answer = raw === null ? null : parseAnswer(raw)
|
|
89
|
+
if (answer === null) {
|
|
90
|
+
return 'unavailable'
|
|
91
|
+
}
|
|
92
|
+
if (!answer.success) {
|
|
93
|
+
const codes = answer['error-codes'] ?? []
|
|
94
|
+
if (codes.some((code) => SECRET_ERRORS.includes(code))) {
|
|
95
|
+
return 'misconfigured'
|
|
96
|
+
}
|
|
97
|
+
return codes.includes('internal-error') ? 'unavailable' : 'failed'
|
|
98
|
+
}
|
|
99
|
+
const issued = Date.parse(answer.challenge_ts ?? '')
|
|
100
|
+
const age = now() - issued
|
|
101
|
+
const fresh = Number.isFinite(issued) && age <= CHALLENGE_LIMITS.maxAgeMs && age >= -CHALLENGE_LIMITS.maxSkewMs
|
|
102
|
+
return fresh && answer.hostname === check.hostname && answer.action === check.action ? 'passed' : 'failed'
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze(verifier)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseAnswer(raw: Uint8Array<ArrayBuffer>): z.infer<typeof siteverifyAnswer> | null {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = siteverifyAnswer.safeParse(JSON.parse(new TextDecoder().decode(raw)))
|
|
111
|
+
return parsed.success ? parsed.data : null
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|