@fabrika/app 0.3.3 → 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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. 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
+ }