@fabrika/app 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -0,0 +1,454 @@
1
+ // The runtime of file kinds (`c.files({ types, maxBytes, scan? })`): a stored file is a value in a table column, and
2
+ // the table's policy is its authority. This module stages a file (an incoming part or a `ctx.files.put`): its pending
3
+ // row first, on a connection of its own, then its object, sniffed and bounded by the kind. It serves and opens a file
4
+ // only through a reference the work holds and may read, and only a live one, by its scanned version. The sweep
5
+ // deletes the files no declared column references, and the scan job decides about the quarantined ones (`scan.ts`).
6
+
7
+ import type { ContractArtifact, FileKindArtifact } from '@fabrika/auth-core'
8
+ import {
9
+ type FileBinding,
10
+ type FileColumnRef,
11
+ fileHoldingOf,
12
+ fileReference,
13
+ frameworkTablesOf,
14
+ holdsFile,
15
+ isScopedDatabase,
16
+ QueryRefusedError,
17
+ type StoredFiles,
18
+ } from '@fabrika/db/framework'
19
+ import { type FileObjectStore, type StoredObject, uuidv7 } from '@fabrika/platform'
20
+ import type { FileRef } from '../contract/file'
21
+ import { HttpError, NotFoundError } from '../errors'
22
+ import { CapabilityRefusedError } from '../implement/capability'
23
+ import { ServerFault } from '../implement/faults'
24
+ import type { ImplementationDatabase } from '../implement/implement'
25
+ import type { OperationsReporter } from '../jobs/report'
26
+ import type { OpenedFile } from './handle'
27
+ import { type FileScanner, runScanPass, type ScanningKind } from './scan'
28
+ import { bytesAreOfType, SNIFF_BYTES, SNIFFABLE_TYPES } from './sniff'
29
+ import { cleanName, digesting, isNamed, readExactly, readHead } from './streams'
30
+ import { FileDownload } from './treatment'
31
+
32
+ /**
33
+ * The framework's sweep job: hourly, it deletes the files no declared column references and the pending ones past the
34
+ * grace, so none waits for the next request. A reserved name with `-`, which no `c.cron` name can take;
35
+ * `@fabrika/provider-zerops` writes it into the crontab on this schedule (`ZEROPS_FILE_SWEEP_JOB`).
36
+ */
37
+ export const FILE_SWEEP_JOB = 'fabrika-file-sweep'
38
+ export const FILE_SWEEP_SCHEDULE = '41 * * * *'
39
+ export const FILE_SWEEP_TIMEOUT_MS = 10 * 60_000
40
+ /** How long a staged file waits for its first reference before the sweep may delete it. */
41
+ export const FILE_PENDING_GRACE_MS = 60 * 60 * 1000
42
+ /** How many files one sweep batch handles; the next batch, or the next run, takes the rest. */
43
+ const SWEEP_BATCH = 20
44
+
45
+ /** One file part of a multipart body, as the multipart reader hands it over. */
46
+ export interface FilePart {
47
+ readonly filename: string | null
48
+ /** The media type the part declares; the bytes must be of it. */
49
+ readonly contentType: string | null
50
+ readonly body: ReadableStream<Uint8Array>
51
+ }
52
+
53
+ /** What `ctx.files.put` takes: the bytes, and the type they must be of. */
54
+ export interface PutOptions {
55
+ readonly type: string
56
+ readonly name?: string
57
+ }
58
+
59
+ /** The bytes `ctx.files.put` writes: in memory, or a stream a large file comes from. */
60
+ export type PutBody = Uint8Array | ReadableStream<Uint8Array> | Blob
61
+
62
+ /**
63
+ * One request's or job attempt's files: the holder its file references belong to, the id a pending file it stages
64
+ * carries, and what it staged, so a known rollback deletes what nothing claimed.
65
+ */
66
+ export class FileWork {
67
+ readonly holder: object
68
+ readonly request: string
69
+ readonly #staged: string[] = []
70
+
71
+ constructor(holder: object, request: string) {
72
+ this.holder = holder
73
+ this.request = request
74
+ }
75
+
76
+ staged(id: string): void {
77
+ this.#staged.push(id)
78
+ }
79
+
80
+ get stagedIds(): readonly string[] {
81
+ return [...this.#staged]
82
+ }
83
+ }
84
+
85
+ interface Kind {
86
+ readonly name: string
87
+ readonly artifact: FileKindArtifact
88
+ }
89
+
90
+ export class StoredFileKinds {
91
+ readonly #kinds: ReadonlyMap<string, Kind>
92
+ readonly #store: FileObjectStore
93
+ readonly #files: StoredFiles
94
+ readonly #columns: readonly FileColumnRef[]
95
+
96
+ constructor(kinds: readonly Kind[], store: FileObjectStore, files: StoredFiles, columns: readonly FileColumnRef[]) {
97
+ this.#kinds = new Map(kinds.map((kind) => [kind.name, kind]))
98
+ this.#store = store
99
+ this.#files = files
100
+ this.#columns = columns
101
+ }
102
+
103
+ /** Whether the contract declares kind `name`. */
104
+ has(name: string): boolean {
105
+ return this.#kinds.has(name)
106
+ }
107
+
108
+ /** Whether any kind scans, so the scan job runs. */
109
+ get scans(): boolean {
110
+ return [...this.#kinds.values()].some((kind) => kind.artifact.scan === true)
111
+ }
112
+
113
+ /** What the data layer claims and checks a site's file references with. */
114
+ binding(work: FileWork): FileBinding {
115
+ return {
116
+ holder: work.holder,
117
+ request: work.request,
118
+ graceMs: FILE_PENDING_GRACE_MS,
119
+ scans: (kind) => this.#kinds.get(kind)?.artifact.scan === true,
120
+ }
121
+ }
122
+
123
+ #kind(where: string, name: unknown): Kind {
124
+ const kind = typeof name === 'string' ? this.#kinds.get(name) : undefined
125
+ if (kind === undefined) {
126
+ throw new ServerFault(
127
+ 'file_kind_unknown',
128
+ `${where}: '${String(name)}' is no file kind of the contract (kinds: ${[...this.#kinds.keys()].join(', ')})`,
129
+ )
130
+ }
131
+ return kind
132
+ }
133
+
134
+ /**
135
+ * One incoming file of a request's file field, staged for `work`: its declared type must be one the kind admits and
136
+ * its first bytes that type. `null` for an empty part without a file name: a file input the visitor left empty.
137
+ */
138
+ async receive(work: FileWork, kind: string, part: FilePart): Promise<FileRef | null> {
139
+ const { head, stream } = await readHead(part.body, SNIFF_BYTES)
140
+ if (head.byteLength === 0 && (part.filename === null || part.filename === '')) {
141
+ return null
142
+ }
143
+ return await this.#stage(work, this.#kind('an input file', kind), part.contentType, cleanName(part.filename ?? ''), head, stream)
144
+ }
145
+
146
+ /** `ctx.files.put`: a file of `kind` this work creates, staged like an incoming one; its reference is the work's. */
147
+ async put(work: FileWork, kind: unknown, body: PutBody, options: PutOptions): Promise<FileRef> {
148
+ const declared = this.#kind('ctx.files.put', kind)
149
+ if (typeof options !== 'object' || options === null || typeof options.type !== 'string') {
150
+ throw new ServerFault('file_put', "ctx.files.put takes the file's options: { type, name? }")
151
+ }
152
+ const stream = body instanceof Uint8Array ? bytesStream(body) : body instanceof Blob ? body.stream() : body
153
+ const read = await readHead(stream, SNIFF_BYTES)
154
+ return await this.#stage(work, declared, options.type, cleanName(options.name ?? ''), read.head, read.stream)
155
+ }
156
+
157
+ /**
158
+ * The pending row comes first, on a connection of its own, and is the cleanup intent: whatever fails after it — the
159
+ * write, a lost answer of the store, the record of its size — leaves a row the sweep finds.
160
+ */
161
+ async #stage(
162
+ work: FileWork,
163
+ kind: Kind,
164
+ type: string | null,
165
+ name: string | null,
166
+ head: Uint8Array,
167
+ stream: ReadableStream<Uint8Array>,
168
+ ): Promise<FileRef> {
169
+ const { artifact } = kind
170
+ if (type === null || !artifact.types.includes(type)) {
171
+ await stream.cancel()
172
+ throw new HttpError(415, 'unsupported_media_type', `files of kind '${kind.name}' are ${artifact.types.join(', ')}`)
173
+ }
174
+ if (!bytesAreOfType(type, head)) {
175
+ await stream.cancel()
176
+ throw new HttpError(415, 'unsupported_media_type', `The bytes of the file are not ${type}`)
177
+ }
178
+ if (head.byteLength === 0 && artifact.scan === true) {
179
+ // A scanner gives no verdict on nothing, and a file goes live only on a clean verdict.
180
+ await stream.cancel()
181
+ throw new HttpError(400, 'empty', `files of kind '${kind.name}' are scanned, so none is empty`)
182
+ }
183
+ const id = uuidv7()
184
+ const objectKey = `${kind.name}/${id}`
185
+ await this.#files.stage({ id, kind: kind.name, objectKey, contentType: type, name, request: work.request })
186
+ work.staged(id)
187
+ // The request's body deadline bounds an incoming file; a put's caller bounds its own stream.
188
+ const body = digesting(stream, null)
189
+ try {
190
+ const { size } = await this.#store.write(objectKey, body.stream, { contentType: type, maxBytes: artifact.maxBytes })
191
+ const sha256 = body.digest()
192
+ await this.#files.written(id, { size, sha256 })
193
+ return fileReference({ id, kind: kind.name, name, type, size, status: 'pending' }, work.holder, true)
194
+ } catch (error) {
195
+ if (isNamed(error, 'ObjectExistsError')) {
196
+ // The key holds an object this work did not write: never delete it; the sweep removes the row.
197
+ throw new ServerFault('file_key_taken', `files of kind '${kind.name}' found an object already under a new file's key`)
198
+ }
199
+ await this.#abandon(work, id)
200
+ if (isNamed(error, 'ObjectTooLargeError')) {
201
+ throw new HttpError(413, 'too_large', `files of kind '${kind.name}' hold at most ${artifact.maxBytes} bytes`)
202
+ }
203
+ throw error
204
+ } finally {
205
+ body.stop()
206
+ }
207
+ }
208
+
209
+ /** One staged file nothing claimed: its row and the record of its object in one statement, then the object. */
210
+ async #abandon(work: FileWork, id: string): Promise<void> {
211
+ let step = 'row_delete_failed'
212
+ try {
213
+ const objectKey = await this.#files.abandon(id, work.request)
214
+ if (objectKey === null) {
215
+ return
216
+ }
217
+ step = 'object_delete_failed'
218
+ await this.#store.delete(objectKey)
219
+ step = 'deletion_record_failed'
220
+ await this.#files.forgetDeletion(objectKey)
221
+ } catch {
222
+ console.error(`fabrika: file cleanup: ${step}`)
223
+ }
224
+ }
225
+
226
+ /**
227
+ * After a known rollback: every file the work staged and nothing claimed goes now. After an uncertain outcome (a lost
228
+ * commit answer, a crash) nothing is deleted here; the sweep decides from the database.
229
+ */
230
+ async release(work: FileWork): Promise<void> {
231
+ for (const id of work.stagedIds) {
232
+ await this.#abandon(work, id)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The file a reference names, as a byte consumer reads it: only through a reference the work holds and may read,
238
+ * and only a live one. A pending or quarantined file is 409 `file_not_ready`, a rejected one 409 `file_rejected`, and
239
+ * one the sweep deleted since the read 404.
240
+ */
241
+ async #object(
242
+ work: FileWork,
243
+ where: string,
244
+ reference: unknown,
245
+ ): Promise<{ readonly file: FileRef & { readonly kind: string }; readonly object: StoredObject }> {
246
+ const holding = fileHoldingOf(reference)
247
+ if (holding === undefined || !holdsFile(work.holder, holding.id)) {
248
+ throw new CapabilityRefusedError(
249
+ 'wrong_capability',
250
+ `${where} takes a file reference this request holds and may read: an input file, a put, or a file column read under its table's read action`,
251
+ )
252
+ }
253
+ const file = await this.#files.find(holding.id)
254
+ if (file === null) {
255
+ throw new NotFoundError('The file is no longer stored')
256
+ }
257
+ if (file.status === 'pending' || file.status === 'quarantined') {
258
+ throw new HttpError(409, 'file_not_ready', 'The file is not ready yet; try again in a few minutes')
259
+ }
260
+ if (file.status === 'rejected') {
261
+ throw new HttpError(409, 'file_rejected', 'The file was refused by the malware scan')
262
+ }
263
+ const scans = this.#kinds.get(file.kind)?.artifact.scan === true
264
+ const object = scans
265
+ ? file.objectVersion === null ? null : await this.#store.readVersion(file.objectKey, file.objectVersion)
266
+ : await this.#store.read(file.objectKey)
267
+ if (object === null && scans) {
268
+ // No byte of a replaced object is served: the scan decides about the bytes stored now.
269
+ await this.#files.requarantine(file.id, file.objectVersion)
270
+ throw new HttpError(409, 'file_not_ready', 'The file is not ready yet; try again in a few minutes')
271
+ }
272
+ if (object === null) {
273
+ throw new ServerFault('file_object_missing', `the object of a file of kind '${file.kind}' is missing from storage`)
274
+ }
275
+ if (object.size !== file.size) {
276
+ await object.body.cancel()
277
+ throw new ServerFault('file_object_mismatch', `the object of a file of kind '${file.kind}' has another size than its row records`)
278
+ }
279
+ return { file, object }
280
+ }
281
+
282
+ /** `ctx.files.open(ref)`: the bytes of a live file, read whole, at most `maxBytes` (the kind's by default). */
283
+ async open(work: FileWork, reference: unknown, options: { readonly maxBytes?: number } = {}): Promise<OpenedFile> {
284
+ const { file, object } = await this.#object(work, 'ctx.files.open', reference)
285
+ const maxBytes = options.maxBytes ?? this.#kinds.get(file.kind)?.artifact.maxBytes ?? file.size
286
+ if (file.size > maxBytes) {
287
+ await object.body.cancel()
288
+ throw new HttpError(413, 'too_large', `The file has ${file.size} bytes, more than the ${maxBytes} this site reads`)
289
+ }
290
+ const bytes = await readExactly(object.body, file.size)
291
+ if (bytes === null) {
292
+ throw new ServerFault('file_object_mismatch', `the object of a file of kind '${file.kind}' has another size than its row records`)
293
+ }
294
+ return Object.freeze({ id: file.id, name: file.name, type: file.type, size: file.size, bytes })
295
+ }
296
+
297
+ /** The answer of a procedure whose output is a file: the file the handler returned, streamed. */
298
+ async download(work: FileWork, endpoint: string, kind: string, reference: unknown): Promise<FileDownload> {
299
+ const holding = fileHoldingOf(reference)
300
+ if (holding !== undefined && holding.kind !== kind) {
301
+ throw new ServerFault('file_output_kind', `'${endpoint}' answers files of kind '${kind}', and its handler returned a file of another kind`)
302
+ }
303
+ const { file, object } = await this.#object(work, `'${endpoint}'`, reference)
304
+ return new FileDownload({ body: object.body, size: file.size, contentType: file.type, name: file.name })
305
+ }
306
+
307
+ /**
308
+ * The sweep: it finishes deletions a crash interrupted, adopts the migrated files a declared column references, then
309
+ * deletes, batch by batch, every file past the grace that no declared column references: its row and the record of
310
+ * its object in one transaction (the foreign key keeps a referenced one), then the object, then the record.
311
+ */
312
+ async sweep(signal: AbortSignal): Promise<void> {
313
+ let step = 'rows_read_failed'
314
+ try {
315
+ for (let keys = await this.#files.unfinishedDeletions(SWEEP_BATCH); keys.length > 0 && !signal.aborted;) {
316
+ for (const key of keys) {
317
+ step = 'object_delete_failed'
318
+ await this.#store.delete(key)
319
+ step = 'deletion_record_failed'
320
+ await this.#files.forgetDeletion(key)
321
+ }
322
+ step = 'rows_read_failed'
323
+ keys = keys.length === SWEEP_BATCH ? await this.#files.unfinishedDeletions(SWEEP_BATCH) : []
324
+ }
325
+ await this.#files.adoptReferenced(this.#columns)
326
+ for (let more = true; more && !signal.aborted;) {
327
+ step = 'rows_read_failed'
328
+ const batch = await this.#files.unreferenced(this.#columns, SWEEP_BATCH, FILE_PENDING_GRACE_MS)
329
+ for (const id of batch) {
330
+ step = 'row_delete_failed'
331
+ const key = await this.#files.remove(id, FILE_PENDING_GRACE_MS)
332
+ if (key === null) {
333
+ continue
334
+ }
335
+ step = 'object_delete_failed'
336
+ await this.#store.delete(key)
337
+ step = 'deletion_record_failed'
338
+ await this.#files.forgetDeletion(key)
339
+ }
340
+ more = batch.length === SWEEP_BATCH
341
+ }
342
+ } catch {
343
+ console.error(`fabrika: stored files sweep: ${step}`)
344
+ throw new ServerFault('file_sweep_failed', 'the sweep of stored files failed; the next run retries it')
345
+ }
346
+ }
347
+
348
+ /** One scan pass over the quarantined files of every scanning kind: the scan job's part for kinds. */
349
+ async scanPass(scanner: FileScanner | null, reporter: OperationsReporter, signal: AbortSignal): Promise<void> {
350
+ const files = this.#files
351
+ const scanning: ScanningKind[] = [...this.#kinds.values()].filter((kind) => kind.artifact.scan === true).map((kind) => ({
352
+ name: kind.name,
353
+ table: {
354
+ quarantined: (limit: number, now: Date) => files.quarantined([kind.name], limit, now),
355
+ promote: (id: string, sha256: string, version: string) => files.promote(id, sha256, version),
356
+ deferScan: (id: string, attempts: number, after: Date) => files.deferScan(id, attempts, after),
357
+ reject: (id: string) => files.reject(id),
358
+ },
359
+ }))
360
+ await runScanPass({ collections: scanning, store: this.#store, scanner, reporter, signal })
361
+ }
362
+ }
363
+
364
+ /** Where `ctx.files.put` and `.open` run: the site's name, its liveness check, and whether it may create a file. */
365
+ export interface StoredFileSite {
366
+ readonly where: string
367
+ readonly check: () => void
368
+ /** A query reads only, so it puts no file. */
369
+ readonly writes: boolean
370
+ }
371
+
372
+ /** `ctx.files.put` and `.open` of one site, bound to its work. */
373
+ export function storedFilesHandle(
374
+ kinds: StoredFileKinds | null,
375
+ work: FileWork,
376
+ site: StoredFileSite,
377
+ ): {
378
+ readonly put: (kind: unknown, body: PutBody, options: PutOptions) => Promise<FileRef>
379
+ readonly open: (file: unknown, options?: { readonly maxBytes?: number }) => Promise<OpenedFile>
380
+ } {
381
+ const declared = (): StoredFileKinds => {
382
+ if (kinds === null) {
383
+ throw new ServerFault('file_kind_unknown', `${site.where}: ctx.files.put and open need a file kind, and the contract declares none`)
384
+ }
385
+ return kinds
386
+ }
387
+ return Object.freeze({
388
+ put: async (kind: unknown, body: PutBody, options: PutOptions) => {
389
+ site.check()
390
+ if (!site.writes) {
391
+ throw new QueryRefusedError('read_only', `${site.where}: a query creates no file; put it in a mutation, a job or a webhook`)
392
+ }
393
+ return await declared().put(work, kind, body, options)
394
+ },
395
+ open: async (file: unknown, options: { readonly maxBytes?: number } = {}) => {
396
+ site.check()
397
+ return await declared().open(work, file, options)
398
+ },
399
+ })
400
+ }
401
+
402
+ /** Bytes in memory as the stream the object store writes. */
403
+ function bytesStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
404
+ return new ReadableStream<Uint8Array>({
405
+ start(controller) {
406
+ if (bytes.byteLength > 0) {
407
+ controller.enqueue(bytes)
408
+ }
409
+ controller.close()
410
+ },
411
+ })
412
+ }
413
+
414
+ /** The file columns of every table policy: where the sweep looks a file up. */
415
+ function fileColumns(artifact: ContractArtifact): FileColumnRef[] {
416
+ return Object.entries(artifact.tables).flatMap(([table, policy]) => Object.keys(policy.files ?? {}).map((column) => ({ table, column })))
417
+ }
418
+
419
+ /**
420
+ * The file kinds of `artifact`, or `null` when it declares none. Every reason a kind cannot be served goes into
421
+ * `problems`, so `boot` refuses to start rather than take a file it cannot check.
422
+ */
423
+ export function storedFileKinds(
424
+ artifact: ContractArtifact,
425
+ database: ImplementationDatabase | null,
426
+ store: FileObjectStore | undefined,
427
+ problems: string[],
428
+ ): StoredFileKinds | null {
429
+ const entries = Object.entries(artifact.fileKinds ?? {})
430
+ if (entries.length === 0) {
431
+ return null
432
+ }
433
+ if (store === undefined) {
434
+ problems.push('file kinds are declared, and the runtime adapter supplies no object storage (FABRIKA_APP_STORAGE_* on Zerops)')
435
+ }
436
+ if (database === null || !isScopedDatabase(database)) {
437
+ problems.push('file kinds need the scoped(tables) data layer of @fabrika/db: a stored file is a value in a table column')
438
+ return null
439
+ }
440
+ for (const [name, kind] of entries) {
441
+ for (const type of kind.types.filter((candidate) => !SNIFFABLE_TYPES.includes(candidate))) {
442
+ problems.push(`files '${name}' admits '${type}', whose bytes the framework cannot verify (allowed: ${SNIFFABLE_TYPES.join(', ')})`)
443
+ }
444
+ }
445
+ if (store === undefined) {
446
+ return null
447
+ }
448
+ return new StoredFileKinds(
449
+ entries.map(([name, artifact]) => ({ name, artifact })),
450
+ store,
451
+ frameworkTablesOf(database).files,
452
+ fileColumns(artifact),
453
+ )
454
+ }
package/src/files/scan.ts CHANGED
@@ -1,11 +1,11 @@
1
- // The scan pass of `scan: true` collections: a framework job (`FILE_SCAN_JOB`) that takes a batch of quarantined files,
1
+ // The scan pass of `scan: true` file kinds: a framework job (`FILE_SCAN_JOB`) that takes a batch of quarantined files,
2
2
  // hands their objects to a `FileScanner`, and writes each verdict under the job's lease. It fails closed: a file goes
3
3
  // live only with a completed clean verdict over the exact bytes its upload recorded, and the download then serves only
4
4
  // that object version. An archive the scanner cannot read in full is rejected before it is scanned (`archive.ts`). No
5
5
  // scanner, a scanner error or a timeout leave files quarantined; a file that gets no verdict is retried later, alone,
6
6
  // and rejected after `SCAN_LIMITS.maxAttempts`. The scanner comes from the runtime adapter (`clamscan.ts` on Bun).
7
7
 
8
- import type { FileCollectionTable, QuarantinedFile } from '@fabrika/db/framework'
8
+ import type { QuarantinedStoredFile } from '@fabrika/db/framework'
9
9
  import type { FileObjectStore } from '@fabrika/platform'
10
10
  import type { OperationsReporter } from '../jobs/report'
11
11
  import { type ArchiveRefusal, checkArchive } from './archive'
@@ -67,14 +67,20 @@ export interface FileScanner {
67
67
  scan(objects: readonly ScanObject[], signal: AbortSignal): Promise<ScanBatchResult>
68
68
  }
69
69
 
70
- export interface ScanningCollection {
70
+ /** What one scanning file kind lets the scan pass do: take its due files, and write each verdict. */
71
+ export interface ScanningKind {
71
72
  readonly name: string
72
- readonly table: FileCollectionTable
73
+ readonly table: {
74
+ quarantined(limit: number, now: Date): Promise<readonly QuarantinedStoredFile[]>
75
+ promote(id: string, sha256: string, version: string): Promise<boolean>
76
+ deferScan(id: string, attempts: number, after: Date): Promise<boolean>
77
+ reject(id: string): Promise<boolean>
78
+ }
73
79
  }
74
80
 
75
81
  interface Candidate {
76
- readonly collection: ScanningCollection
77
- readonly file: QuarantinedFile
82
+ readonly collection: ScanningKind
83
+ readonly file: QuarantinedStoredFile
78
84
  }
79
85
 
80
86
  /** How reading one file's object ended; only `verified` lets a clean verdict promote it, as that version. */
@@ -116,7 +122,7 @@ async function readExactly(body: ReadableStream<Uint8Array>, size: number, signa
116
122
  }
117
123
 
118
124
  export interface ScanPassInput {
119
- readonly collections: readonly ScanningCollection[]
125
+ readonly collections: readonly ScanningKind[]
120
126
  readonly store: FileObjectStore
121
127
  /** `null` when the runtime adapter supplies none: every quarantined file stays quarantined. */
122
128
  readonly scanner: FileScanner | null
@@ -130,7 +136,7 @@ export interface ScanPassInput {
130
136
  * stream of new uploads never holds back an older file. A file that has failed before is scanned alone, so it cannot
131
137
  * take others down with it; new files go together, within the batch bounds.
132
138
  */
133
- async function batch(collections: readonly ScanningCollection[], now: Date): Promise<Candidate[]> {
139
+ async function batch(collections: readonly ScanningKind[], now: Date): Promise<Candidate[]> {
134
140
  const due: Candidate[] = []
135
141
  for (const collection of collections) {
136
142
  for (const file of await collection.table.quarantined(SCAN_LIMITS.batchFiles, now)) due.push({ collection, file })
@@ -167,7 +173,7 @@ class ScanPass {
167
173
  * The object's bytes, read whole, checked against the upload's size and digest, and checked as an archive. Its
168
174
  * outcome is the file's read state; the bytes reach the scanner only when they are verified.
169
175
  */
170
- async load(file: QuarantinedFile, signal: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
176
+ async load(file: QuarantinedStoredFile, signal: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
171
177
  const object = await this.#input.store.read(file.objectKey, signal)
172
178
  if (object === null) {
173
179
  this.#states.set(file.id, { kind: 'missing' })
@@ -0,0 +1,111 @@
1
+ // The byte streams of a stored file on its way in and out: the head the sniff reads, the digest and the deadline of a
2
+ // write, the exact read of a file a site opens, and the client's file name as metadata.
3
+
4
+ const MAX_NAME_CODE_POINTS = 255
5
+
6
+ /** The client's file name, metadata only: at most 255 code points, never half a character, no control characters. */
7
+ export function cleanName(raw: string): string | null {
8
+ const name = Array.from(raw.replace(/\p{Cc}/gu, '').trim()).slice(0, MAX_NAME_CODE_POINTS).join('')
9
+ return name === '' ? null : name
10
+ }
11
+
12
+ /** The first `size` bytes of `body`, and a stream that yields the whole body again. */
13
+ export async function readHead(
14
+ body: ReadableStream<Uint8Array>,
15
+ size: number,
16
+ ): Promise<{ readonly head: Uint8Array; readonly stream: ReadableStream<Uint8Array> }> {
17
+ const reader = body.getReader()
18
+ const chunks: Uint8Array[] = []
19
+ let length = 0
20
+ let done = false
21
+ while (length < size) {
22
+ const next = await reader.read()
23
+ if (next.done) {
24
+ done = true
25
+ break
26
+ }
27
+ chunks.push(next.value)
28
+ length += next.value.byteLength
29
+ }
30
+ const buffered = new Uint8Array(length)
31
+ let offset = 0
32
+ for (const chunk of chunks) {
33
+ buffered.set(chunk, offset)
34
+ offset += chunk.byteLength
35
+ }
36
+ const stream = new ReadableStream<Uint8Array>({
37
+ start(controller) {
38
+ if (buffered.byteLength > 0) {
39
+ controller.enqueue(buffered)
40
+ }
41
+ if (done) {
42
+ controller.close()
43
+ }
44
+ },
45
+ async pull(controller) {
46
+ const next = await reader.read()
47
+ if (next.done) {
48
+ controller.close()
49
+ } else {
50
+ controller.enqueue(next.value)
51
+ }
52
+ },
53
+ cancel: (reason) => reader.cancel(reason),
54
+ })
55
+ return { head: buffered.subarray(0, size), stream }
56
+ }
57
+
58
+ /** The body as it is stored: hashed on the way, and failed once `timeoutMs` (`null`: no timer) passes before it ends. */
59
+ export function digesting(body: ReadableStream<Uint8Array>, timeoutMs: number | null): {
60
+ readonly stream: ReadableStream<Uint8Array>
61
+ digest(): string
62
+ timedOut(): boolean
63
+ stop(): void
64
+ } {
65
+ const hasher = new Bun.CryptoHasher('sha256')
66
+ let timer: ReturnType<typeof setTimeout> | undefined
67
+ let expired = false
68
+ const stream = body.pipeThrough(
69
+ new TransformStream<Uint8Array, Uint8Array>({
70
+ start(controller) {
71
+ if (timeoutMs !== null) {
72
+ timer = setTimeout(() => {
73
+ expired = true
74
+ controller.error(new Error('the upload did not finish in time'))
75
+ }, timeoutMs)
76
+ }
77
+ },
78
+ transform(chunk, controller) {
79
+ hasher.update(chunk)
80
+ controller.enqueue(chunk)
81
+ },
82
+ flush() {
83
+ clearTimeout(timer)
84
+ },
85
+ }),
86
+ )
87
+ return { stream, digest: () => hasher.digest('hex'), timedOut: () => expired, stop: () => clearTimeout(timer) }
88
+ }
89
+
90
+ /** The whole body when it holds exactly `size` bytes; `null` as soon as it proves longer, or once it ends shorter. */
91
+ export async function readExactly(body: ReadableStream<Uint8Array>, size: number): Promise<Uint8Array | null> {
92
+ const bytes = new Uint8Array(size)
93
+ let length = 0
94
+ const reader = body.getReader()
95
+ try {
96
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
97
+ if (length + next.value.byteLength > size) {
98
+ return null
99
+ }
100
+ bytes.set(next.value, length)
101
+ length += next.value.byteLength
102
+ }
103
+ } finally {
104
+ reader.releaseLock()
105
+ }
106
+ return length === size ? bytes : null
107
+ }
108
+
109
+ export function isNamed(error: unknown, name: string): boolean {
110
+ return error instanceof Error && error.name === name
111
+ }
@@ -0,0 +1,7 @@
1
+ // The honeypot of a public procedure: a field its page hides, which a person leaves empty and a bot fills. The handler
2
+ // answers a filled one as it answers a real submission and does nothing, so the bot learns nothing from the answer.
3
+
4
+ /** Whether the hidden field was filled: `if (honeypot(input.website)) return {}`. */
5
+ export function honeypot(value: string | null | undefined): boolean {
6
+ return typeof value === 'string' && value !== ''
7
+ }