@fabrika/app 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -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 +746 -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 +308 -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 +331 -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 +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -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 +824 -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 +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -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 +760 -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 +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,77 @@
1
+ // `ctx.files`: a handler's and a job's handle on the contract's file collections. Types only, so the browser-safe
2
+ // contract can name them; `collections.ts` builds the handle. Every operation takes a `Ref` of the owner for one of the
3
+ // collection's actions, so a file reaches what its owner reaches, as through the generated endpoints.
4
+
5
+ import type { Unavailable } from '@fabrika/db/tables'
6
+ import type { Ref } from '../implement/capability'
7
+
8
+ /** A held file's state: served when `live`, `409` while `quarantined`, gone when `rejected`. */
9
+ export type HeldFileStatus = 'live' | 'quarantined' | 'rejected'
10
+
11
+ /** One file of an owner, as `list` answers it. */
12
+ export interface ListedFile {
13
+ readonly id: string
14
+ /** The name the uploader gave it, metadata only. */
15
+ readonly name: string | null
16
+ /** The sniffed media type its bytes were checked against. */
17
+ readonly type: string
18
+ readonly size: number
19
+ readonly status: HeldFileStatus
20
+ readonly createdAt: Date
21
+ }
22
+
23
+ /** A page of an owner's files, oldest first; `next` is the `after` of the following page, `null` after the last. */
24
+ export interface FileListing {
25
+ readonly items: readonly ListedFile[]
26
+ readonly next: string | null
27
+ }
28
+
29
+ export interface FileListPage {
30
+ /** The id of the last file of the previous page. */
31
+ readonly after?: string
32
+ /** 1–100, 50 by default. */
33
+ readonly limit?: number
34
+ }
35
+
36
+ /** A live file read whole: what an e-mail attaches. */
37
+ export interface OpenedFile {
38
+ readonly id: string
39
+ readonly name: string | null
40
+ readonly type: string
41
+ readonly size: number
42
+ readonly bytes: Uint8Array
43
+ }
44
+
45
+ export interface OpenOptions {
46
+ /** The most bytes the caller holds in memory; a larger file is refused (413 `too_large`) before it is read. */
47
+ readonly maxBytes: number
48
+ }
49
+
50
+ /** What every site reaches of one collection: its owner's files. */
51
+ export interface FileCollectionReader {
52
+ /**
53
+ * The owner's held files, oldest first; `owner` is a `Ref` of the collection's owner for its `read` action. A
54
+ * handler that must see the uploads holds that action.
55
+ */
56
+ list(owner: Ref<string, string>, page?: FileListPage): Promise<FileListing>
57
+ /**
58
+ * The live file `id` of the owner, read whole; `owner` is a `Ref` for the collection's `read` action. A file of
59
+ * another owner, a rejected or a removed one is 404, a quarantined one 409 `quarantined`, one over `maxBytes` 413.
60
+ */
61
+ open(owner: Ref<string, string>, id: string, options: OpenOptions): Promise<OpenedFile>
62
+ }
63
+
64
+ /** A site that writes also removes: a mutation, and a job inside `ctx.transaction`. */
65
+ export interface FileCollectionHandle extends FileCollectionReader {
66
+ /**
67
+ * Marks every file of the owner removed, in this site's transaction, and answers how many; `owner` is a `Ref` for the
68
+ * collection's `delete` action. The framework's sweep deletes their objects after the commit.
69
+ */
70
+ removeAll(owner: Ref<string, string>): Promise<number>
71
+ }
72
+
73
+ /** A site that only reads: a query, and a job outside `ctx.transaction`. */
74
+ export type FileCollectionReadHandle<Why extends string> = FileCollectionReader & { readonly removeAll: Unavailable<Why> }
75
+
76
+ /** `ctx.files(<collection>)`: the handle of one collection of the contract, by its name in `files`. */
77
+ export type FileCollections<H> = (collection: string) => H
@@ -0,0 +1,308 @@
1
+ // The scan pass of `scan: true` collections: a framework job (`FILE_SCAN_JOB`) that takes a batch of quarantined files,
2
+ // hands their objects to a `FileScanner`, and writes each verdict under the job's lease. It fails closed: a file goes
3
+ // live only with a completed clean verdict over the exact bytes its upload recorded, and the download then serves only
4
+ // that object version. An archive the scanner cannot read in full is rejected before it is scanned (`archive.ts`). No
5
+ // scanner, a scanner error or a timeout leave files quarantined; a file that gets no verdict is retried later, alone,
6
+ // and rejected after `SCAN_LIMITS.maxAttempts`. The scanner comes from the runtime adapter (`clamscan.ts` on Bun).
7
+
8
+ import type { FileCollectionTable, QuarantinedFile } from '@fabrika/db/framework'
9
+ import type { FileObjectStore } from '@fabrika/platform'
10
+ import type { OperationsReporter } from '../jobs/report'
11
+ import { type ArchiveRefusal, checkArchive } from './archive'
12
+ import { ZIP_TYPES } from './sniff'
13
+
14
+ /** The reserved name of the scan job: a `c.cron` job name cannot contain `-`, so no application job takes it. */
15
+ export const FILE_SCAN_JOB = 'fabrika-file-scan'
16
+ export const FILE_SCAN_SCHEDULE = '* * * * *'
17
+
18
+ export const SCAN_LIMITS = {
19
+ /** One pass scans at most this many files, and at most `batchBytes` of them; the next pass takes the rest. */
20
+ batchFiles: 20,
21
+ batchBytes: 256 * 1024 * 1024,
22
+ /** The scanner's whole run — reading the objects, the scan, the cleanup — is stopped by then. */
23
+ scanTimeoutMs: 5 * 60_000,
24
+ /** The attempt's deadline; it outlasts the scanner's, so the verdicts are written before it. */
25
+ jobTimeoutMs: 10 * 60_000,
26
+ /** A file that got no verdict this many times is rejected. */
27
+ maxAttempts: 5,
28
+ /** The wait before the next try of a file without a verdict: doubled per try, at most `retryMaxMs`. */
29
+ retryBaseMs: 60_000,
30
+ retryMaxMs: 60 * 60_000,
31
+ } as const
32
+
33
+ /** What the scanner decided about one file. Only `clean` promotes; `infected` rejects; anything else is retried. */
34
+ export type ScanVerdict = { readonly kind: 'clean' } | { readonly kind: 'infected'; readonly signature: string } | { readonly kind: 'unscanned' }
35
+
36
+ /** Why a whole batch got no verdict. Fixed codes only: a scanner's own output is never reported. */
37
+ export type ScanFailure =
38
+ | 'scanner_missing'
39
+ | 'signatures_missing'
40
+ | 'signatures_stale'
41
+ | 'scanner_failed'
42
+ | 'timeout'
43
+ | 'output_overflow'
44
+
45
+ /** The failures of the scanner's own installation: no file is to blame, so none of them counts as a file's try. */
46
+ const INSTALLATION_FAILURES: ReadonlySet<ScanFailure> = new Set(['scanner_missing', 'signatures_missing', 'signatures_stale'])
47
+
48
+ export type ScanBatchResult =
49
+ | { readonly ok: true; readonly verdicts: ReadonlyMap<string, ScanVerdict> }
50
+ | { readonly ok: false; readonly failure: ScanFailure }
51
+
52
+ export interface ScanObject {
53
+ /** The file's id: a UUID. */
54
+ readonly id: string
55
+ /**
56
+ * The object's bytes. It throws when they are missing, differ from the upload's, are an archive the scanner cannot
57
+ * read in full, or `signal` aborts.
58
+ */
59
+ open(signal: AbortSignal): Promise<ReadableStream<Uint8Array>>
60
+ }
61
+
62
+ export interface FileScanner {
63
+ /**
64
+ * A verdict per file id, or why the batch got none. A file whose `open` threw has no verdict. Stops once `signal`
65
+ * aborts. Never throws.
66
+ */
67
+ scan(objects: readonly ScanObject[], signal: AbortSignal): Promise<ScanBatchResult>
68
+ }
69
+
70
+ export interface ScanningCollection {
71
+ readonly name: string
72
+ readonly table: FileCollectionTable
73
+ }
74
+
75
+ interface Candidate {
76
+ readonly collection: ScanningCollection
77
+ readonly file: QuarantinedFile
78
+ }
79
+
80
+ /** How reading one file's object ended; only `verified` lets a clean verdict promote it, as that version. */
81
+ type ReadState =
82
+ | { readonly kind: 'unread' }
83
+ | { readonly kind: 'verified'; readonly version: string }
84
+ | { readonly kind: 'missing' }
85
+ | { readonly kind: 'mismatch' }
86
+ | { readonly kind: 'unsafe'; readonly refusal: ArchiveRefusal }
87
+
88
+ /** A signature name as Operations shows it: ClamAV's names are plain, anything else is not repeated. */
89
+ function signatureName(signature: string): string {
90
+ return /^[A-Za-z0-9._:/-]{1,128}$/.test(signature) ? signature : 'unnamed'
91
+ }
92
+
93
+ const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`
94
+
95
+ /** The next try of a file that has had `attempts` tries: one minute, doubled per try, at most an hour. */
96
+ export function retryDelayMs(attempts: number): number {
97
+ return Math.min(SCAN_LIMITS.retryMaxMs, SCAN_LIMITS.retryBaseMs * 2 ** Math.max(0, attempts - 1))
98
+ }
99
+
100
+ /** The whole body, at most `size` bytes; the read stops, and the stream is cancelled, past it or on `signal`. */
101
+ async function readExactly(body: ReadableStream<Uint8Array>, size: number, signal: AbortSignal): Promise<Uint8Array<ArrayBuffer> | null> {
102
+ const reader = body.getReader()
103
+ const aborted = new Promise<null>((resolve) => signal.addEventListener('abort', () => resolve(null), { once: true }))
104
+ const bytes = new Uint8Array(size)
105
+ let length = 0
106
+ try {
107
+ for (let next = await Promise.race([reader.read(), aborted]); next !== null && !next.done; next = await Promise.race([reader.read(), aborted])) {
108
+ if (length + next.value.byteLength > size) return null
109
+ bytes.set(next.value, length)
110
+ length += next.value.byteLength
111
+ }
112
+ return signal.aborted || length !== size ? null : bytes
113
+ } finally {
114
+ await reader.cancel().catch(() => undefined)
115
+ }
116
+ }
117
+
118
+ export interface ScanPassInput {
119
+ readonly collections: readonly ScanningCollection[]
120
+ readonly store: FileObjectStore
121
+ /** `null` when the runtime adapter supplies none: every quarantined file stays quarantined. */
122
+ readonly scanner: FileScanner | null
123
+ readonly reporter: OperationsReporter
124
+ readonly signal: AbortSignal
125
+ readonly now?: () => Date
126
+ }
127
+
128
+ /**
129
+ * The batch: the due files that waited longest first, across every scanning collection, so a busy collection or a
130
+ * stream of new uploads never holds back an older file. A file that has failed before is scanned alone, so it cannot
131
+ * take others down with it; new files go together, within the batch bounds.
132
+ */
133
+ async function batch(collections: readonly ScanningCollection[], now: Date): Promise<Candidate[]> {
134
+ const due: Candidate[] = []
135
+ for (const collection of collections) {
136
+ for (const file of await collection.table.quarantined(SCAN_LIMITS.batchFiles, now)) due.push({ collection, file })
137
+ }
138
+ due.sort((left, right) => left.file.dueSince.getTime() - right.file.dueSince.getTime())
139
+ const [first] = due
140
+ if (first === undefined || first.file.scanAttempts > 0) return first === undefined ? [] : [first]
141
+ const candidates: Candidate[] = []
142
+ let bytes = 0
143
+ for (const candidate of due) {
144
+ const full = candidates.length >= SCAN_LIMITS.batchFiles || (candidates.length > 0 && bytes + candidate.file.size > SCAN_LIMITS.batchBytes)
145
+ if (full || candidate.file.scanAttempts > 0) break
146
+ candidates.push(candidate)
147
+ bytes += candidate.file.size
148
+ }
149
+ return candidates
150
+ }
151
+
152
+ function names(candidates: readonly Candidate[]): string {
153
+ return [...new Set(candidates.map((candidate) => `'${candidate.collection.name}'`))].join(', ')
154
+ }
155
+
156
+ class ScanPass {
157
+ readonly #input: ScanPassInput
158
+ readonly #states = new Map<string, ReadState>()
159
+ readonly #now: () => Date
160
+
161
+ constructor(input: ScanPassInput) {
162
+ this.#input = input
163
+ this.#now = input.now ?? (() => new Date())
164
+ }
165
+
166
+ /**
167
+ * The object's bytes, read whole, checked against the upload's size and digest, and checked as an archive. Its
168
+ * outcome is the file's read state; the bytes reach the scanner only when they are verified.
169
+ */
170
+ async load(file: QuarantinedFile, signal: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
171
+ const object = await this.#input.store.read(file.objectKey, signal)
172
+ if (object === null) {
173
+ this.#states.set(file.id, { kind: 'missing' })
174
+ throw new Error('the object is missing')
175
+ }
176
+ const bytes = object.size === file.size ? await readExactly(object.body, file.size, signal) : null
177
+ if (object.size !== file.size) await object.body.cancel()
178
+ if (bytes === null || new Bun.CryptoHasher('sha256').update(bytes).digest('hex') !== file.sha256) {
179
+ if (!signal.aborted) this.#states.set(file.id, { kind: 'mismatch' })
180
+ throw new Error('the object differs from its upload')
181
+ }
182
+ const archive = checkArchive(bytes, ZIP_TYPES.includes(file.contentType))
183
+ if (!archive.ok) {
184
+ this.#states.set(file.id, { kind: 'unsafe', refusal: archive.refusal })
185
+ throw new Error('the scanner cannot read this archive in full')
186
+ }
187
+ this.#states.set(file.id, { kind: 'verified', version: object.version })
188
+ return bytes
189
+ }
190
+
191
+ async run(): Promise<void> {
192
+ const { scanner, signal } = this.#input
193
+ const candidates = await batch(this.#input.collections, this.#now())
194
+ if (candidates.length === 0) return
195
+ const none = new Map<string, ScanVerdict>()
196
+ if (scanner === null) {
197
+ console.error('fabrika: the file scan has no scanner; quarantined files stay quarantined')
198
+ await this.#failed('scanner_missing', candidates)
199
+ return
200
+ }
201
+ const objects = candidates.map(({ file }): ScanObject => ({
202
+ id: file.id,
203
+ open: async (stop) => new Blob([await this.load(file, stop)]).stream(),
204
+ }))
205
+ const result = await scanner.scan(objects, signal)
206
+ if (!result.ok) {
207
+ console.error(`fabrika: the file scan did not complete: ${result.failure}`)
208
+ await this.#failed(result.failure, candidates)
209
+ // A broken installation is no file's fault; a run that failed over its files counts a try for each.
210
+ return this.#settle(candidates, none, !INSTALLATION_FAILURES.has(result.failure))
211
+ }
212
+ await this.#settle(candidates, result.verdicts, true)
213
+ }
214
+
215
+ async #failed(failure: ScanFailure | 'delete_failed', files: readonly Candidate[]): Promise<void> {
216
+ await this.#input.reporter.report({
217
+ fingerprint: ['fabrika-file-scan-failed', failure],
218
+ type: 'FileScanFailed',
219
+ message: `the scan of ${plural(files.length, 'quarantined file')} of files ${names(files)} did not complete: ${failure}; they stay quarantined`,
220
+ tags: { code: failure },
221
+ })
222
+ }
223
+
224
+ /**
225
+ * Each file's outcome, written under the lease; a write that fails throws, and the run fails with it. A file without
226
+ * a decisive outcome is tried again later when `countTry`, and otherwise left as it is.
227
+ */
228
+ async #settle(candidates: readonly Candidate[], verdicts: ReadonlyMap<string, ScanVerdict>, countTry: boolean): Promise<void> {
229
+ const unscanned: Candidate[] = []
230
+ for (const candidate of candidates) {
231
+ const { collection, file } = candidate
232
+ const state = this.#states.get(file.id) ?? { kind: 'unread' }
233
+ const verdict = verdicts.get(file.id) ?? { kind: 'unscanned' }
234
+ if (state.kind === 'missing') {
235
+ // Its object is gone: a rejection whose delete succeeded before its row was written, or a lost object.
236
+ await this.#reject(candidate, 'object_missing', `files '${collection.name}': file ${file.id} has no object to scan and is rejected`)
237
+ } else if (state.kind === 'unsafe') {
238
+ await this.#reject(candidate, `Fabrika.Archive.${state.refusal}`, `files '${collection.name}': file ${file.id} is rejected: ${state.refusal}`)
239
+ } else if (verdict.kind === 'infected') {
240
+ const signature = signatureName(verdict.signature)
241
+ await this.#reject(candidate, signature, `files '${collection.name}': file ${file.id} is rejected: ${signature}`)
242
+ } else if (state.kind === 'verified' && verdict.kind === 'clean') {
243
+ await collection.table.promote(file.id, file.sha256, state.version)
244
+ } else {
245
+ unscanned.push(candidate)
246
+ }
247
+ }
248
+ if (countTry) await this.#retryLater(unscanned)
249
+ }
250
+
251
+ /**
252
+ * Deletes the object, then marks the row rejected. A failed delete leaves it quarantined, unserved, and waiting like
253
+ * a retry, so the next try deletes it again and failing deletes never fill every batch.
254
+ */
255
+ async #reject(candidate: Candidate, signature: string, message: string): Promise<void> {
256
+ const { collection, file } = candidate
257
+ try {
258
+ await this.#input.store.delete(file.objectKey)
259
+ } catch {
260
+ await this.#failed('delete_failed', [candidate])
261
+ const attempts = file.scanAttempts + 1
262
+ await collection.table.deferScan(file.id, attempts, new Date(this.#now().getTime() + retryDelayMs(attempts)))
263
+ return
264
+ }
265
+ await collection.table.reject(file.id)
266
+ await this.#input.reporter.report({
267
+ fingerprint: ['fabrika-file-rejected', collection.name],
268
+ type: 'FileRejected',
269
+ message,
270
+ tags: { collection: collection.name, file: file.id, signature },
271
+ })
272
+ }
273
+
274
+ /** A try without a verdict: the file waits, then is tried alone; past the last try it is rejected. */
275
+ async #retryLater(candidates: readonly Candidate[]): Promise<void> {
276
+ const counts = new Map<string, number>()
277
+ for (const candidate of candidates) {
278
+ const { collection, file } = candidate
279
+ const attempts = file.scanAttempts + 1
280
+ if (attempts >= SCAN_LIMITS.maxAttempts) {
281
+ await this.#reject(
282
+ candidate,
283
+ 'Fabrika.Unscannable',
284
+ `files '${collection.name}': file ${file.id} got no verdict in ${plural(attempts, 'attempt')} and is rejected`,
285
+ )
286
+ continue
287
+ }
288
+ await collection.table.deferScan(file.id, attempts, new Date(this.#now().getTime() + retryDelayMs(attempts)))
289
+ counts.set(collection.name, (counts.get(collection.name) ?? 0) + 1)
290
+ }
291
+ for (const [collection, count] of counts) {
292
+ await this.#input.reporter.report({
293
+ fingerprint: ['fabrika-file-unscanned', collection],
294
+ type: 'FileUnscanned',
295
+ message: `files '${collection}': ${plural(count, 'file')} got no clean verdict; each is tried again later, alone`,
296
+ tags: { collection },
297
+ })
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * One pass. A verdict write that fails (the lease lost, the database gone) throws, so the runner reports the run as
304
+ * failed; everything the scanner decides is reported here and never throws.
305
+ */
306
+ export function runScanPass(input: ScanPassInput): Promise<void> {
307
+ return new ScanPass(input).run()
308
+ }
@@ -0,0 +1,70 @@
1
+ // The type of an upload, read from its first bytes. The request's `Content-Type` only says what the client claims;
2
+ // a file is stored only when its bytes are that type. A collection may admit only the types listed here, because
3
+ // the framework cannot verify any other.
4
+
5
+ /** How many leading bytes an upload is judged by. */
6
+ export const SNIFF_BYTES = 512
7
+
8
+ interface Signature {
9
+ readonly types: readonly string[]
10
+ matches(head: Uint8Array): boolean
11
+ }
12
+
13
+ function bytesAt(head: Uint8Array, offset: number, expected: readonly number[]): boolean {
14
+ return head.byteLength >= offset + expected.length && expected.every((byte, index) => head[offset + index] === byte)
15
+ }
16
+
17
+ function asciiAt(head: Uint8Array, offset: number, text: string): boolean {
18
+ return bytesAt(head, offset, [...text].map((char) => char.charCodeAt(0)))
19
+ }
20
+
21
+ const ZIP = [0x50, 0x4b, 0x03, 0x04]
22
+
23
+ /** The ZIP-based types: a ZIP archive, and an Office Open XML document, which is one. */
24
+ export const ZIP_TYPES: readonly string[] = [
25
+ 'application/zip',
26
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
27
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
28
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
29
+ ]
30
+
31
+ const BINARY_SIGNATURES: readonly Signature[] = [
32
+ { types: ['image/png'], matches: (head) => bytesAt(head, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },
33
+ { types: ['image/jpeg'], matches: (head) => bytesAt(head, 0, [0xff, 0xd8, 0xff]) },
34
+ { types: ['image/gif'], matches: (head) => asciiAt(head, 0, 'GIF87a') || asciiAt(head, 0, 'GIF89a') },
35
+ { types: ['image/webp'], matches: (head) => asciiAt(head, 0, 'RIFF') && asciiAt(head, 8, 'WEBP') },
36
+ { types: ['application/pdf'], matches: (head) => asciiAt(head, 0, '%PDF-') },
37
+ { types: ['application/gzip'], matches: (head) => bytesAt(head, 0, [0x1f, 0x8b]) },
38
+ {
39
+ // An Office Open XML document is a zip archive; its bytes cannot tell it from any other.
40
+ types: ZIP_TYPES,
41
+ matches: (head) => bytesAt(head, 0, ZIP),
42
+ },
43
+ ]
44
+
45
+ /** Types judged as text: UTF-8 without NUL bytes, and no binary signature. SVG is text, and always an attachment. */
46
+ const TEXT_TYPES: readonly string[] = ['text/plain', 'text/csv', 'text/markdown', 'application/json', 'image/svg+xml']
47
+
48
+ export const SNIFFABLE_TYPES: readonly string[] = [...BINARY_SIGNATURES.flatMap((signature) => signature.types), ...TEXT_TYPES]
49
+
50
+ function isText(head: Uint8Array): boolean {
51
+ if (head.includes(0) || BINARY_SIGNATURES.some((signature) => signature.matches(head))) {
52
+ return false
53
+ }
54
+ try {
55
+ // `stream: true` tolerates a character the sniff window cut in half.
56
+ new TextDecoder('utf-8', { fatal: true }).decode(head, { stream: true })
57
+ return true
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ /** Whether the first bytes of an upload are of `type`; always false for a type the framework cannot verify. */
64
+ export function bytesAreOfType(type: string, head: Uint8Array): boolean {
65
+ const signature = BINARY_SIGNATURES.find((candidate) => candidate.types.includes(type))
66
+ if (signature !== undefined) {
67
+ return signature.matches(head)
68
+ }
69
+ return TEXT_TYPES.includes(type) && isText(head)
70
+ }
@@ -0,0 +1,48 @@
1
+ // The file treatment (ADR-0022 rule 2): the header policy a download carries instead of the application's. Only
2
+ // the inert images are shown inline; every other type, SVG and HTML included, is an attachment, and nothing a
3
+ // download contains can run as this origin. The proxy applies the same definition, from `@fabrika/proxy-core/headers`.
4
+
5
+ import { applyFileTreatment, fileDisposition, withResponseHeaders } from '@fabrika/proxy-core/headers'
6
+
7
+ /** A file the download endpoint answers. `ContractRuntime` turns it into the response, with the file treatment. */
8
+ export class FileDownload {
9
+ readonly body: ReadableStream<Uint8Array>
10
+ readonly size: number
11
+ /** The type recorded at upload, which its bytes were checked against; never a type a request sent. */
12
+ readonly contentType: string
13
+ readonly name: string | null
14
+
15
+ constructor(file: { readonly body: ReadableStream<Uint8Array>; readonly size: number; readonly contentType: string; readonly name: string | null }) {
16
+ this.body = file.body
17
+ this.size = file.size
18
+ this.contentType = file.contentType
19
+ this.name = file.name
20
+ }
21
+ }
22
+
23
+ /** `filename*` per RFC 8187: percent-encoded UTF-8, so no name can end the header or add a parameter. */
24
+ function filenameParameter(name: string): string {
25
+ const encoded = encodeURIComponent(name).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
26
+ return `; filename*=UTF-8''${encoded}`
27
+ }
28
+
29
+ export function contentDisposition(contentType: string, name: string | null): string {
30
+ return `${fileDisposition(contentType)}${name === null ? '' : filenameParameter(name)}`
31
+ }
32
+
33
+ export function fileResponse(file: FileDownload): Response {
34
+ const headers = new Headers({
35
+ 'Content-Type': file.contentType,
36
+ 'Content-Length': String(file.size),
37
+ 'Content-Disposition': contentDisposition(file.contentType, file.name),
38
+ // A Range request gets the whole file: ignoring Range is always a valid answer, and no partial path exists to get wrong.
39
+ 'Accept-Ranges': 'none',
40
+ })
41
+ applyFileTreatment(headers)
42
+ return new Response(file.body, { status: 200, headers })
43
+ }
44
+
45
+ /** An error of a download endpoint, with the file treatment in place of the header policy. */
46
+ export function withFileTreatment(response: Response): Response {
47
+ return withResponseHeaders(response, applyFileTreatment)
48
+ }
@@ -0,0 +1,53 @@
1
+ // `fromRow(Schema, row)`: a database row as an endpoint's output object; `fromPage(Schema, page)` maps a list's page.
2
+ // The row's snake_case columns become the schema's camelCase fields, a `Date` becomes its ISO text (its day for a
3
+ // `z.iso.date()` field), every column the schema does not declare is dropped, and the schema parses what is left — so a
4
+ // literal-string field the row types as plain text is checked, not trusted. A `date` column already arrives as
5
+ // `YYYY-MM-DD` text from the data layer.
6
+
7
+ import { z } from 'zod/v4'
8
+ import { project } from './implement/project'
9
+
10
+ /** `companyId` → `company_id`: the column a field is read from. */
11
+ export type Snake<S extends string> = S extends `${infer Head}${infer Tail}`
12
+ ? `${Head extends Lowercase<Head> ? Head : `_${Lowercase<Head>}`}${Snake<Tail>}`
13
+ : S
14
+
15
+ /** A row value a field accepts: text for a literal string (the schema checks it), a `Date` for ISO text. */
16
+ type Widened<V> = V extends string ? string | Date : V
17
+
18
+ /** The row `fromRow` takes for `S`: one column per declared field; any other column is allowed and dropped. */
19
+ export type RowInput<S extends z.ZodObject> = { readonly [K in keyof z.input<S> as Snake<K & string>]: Widened<z.input<S>[K]> }
20
+
21
+ function camel(column: string): string {
22
+ return column.replace(/_([a-z0-9])/g, (_match, next: string) => next.toUpperCase())
23
+ }
24
+
25
+ /**
26
+ * A `Date` as its field reads it: the day (`YYYY-MM-DD`, in UTC, as the data layer reads a `date` column) for a
27
+ * `z.iso.date()` field, ISO 8601 text otherwise.
28
+ */
29
+ function dateText(schema: z.ZodObject, field: string, value: Date): string {
30
+ let declared: z.core.$ZodType | undefined = Object.hasOwn(schema.shape, field) ? schema.shape[field] : undefined
31
+ while (declared instanceof z.ZodOptional || declared instanceof z.ZodNullable || declared instanceof z.ZodDefault) {
32
+ declared = declared.unwrap()
33
+ }
34
+ return declared instanceof z.ZodISODate ? value.toISOString().slice(0, 10) : value.toISOString()
35
+ }
36
+
37
+ /** The row as `schema`'s output; a row the schema refuses throws, as a handler's bug. */
38
+ export function fromRow<const S extends z.ZodObject>(schema: S, row: RowInput<S>): z.output<S> {
39
+ const fields: Record<string, unknown> = {}
40
+ for (const [column, value] of Object.entries(row)) {
41
+ const field = camel(column)
42
+ Object.defineProperty(fields, field, { value: value instanceof Date ? dateText(schema, field, value) : value, enumerable: true })
43
+ }
44
+ return z.parse(schema, project(schema, fields))
45
+ }
46
+
47
+ /** A list's page as a page output: each row through `fromRow`, and `next` as the list answered it. */
48
+ export function fromPage<const S extends z.ZodObject>(
49
+ schema: S,
50
+ page: { readonly items: readonly RowInput<S>[]; readonly next: string | null },
51
+ ): { items: z.output<S>[]; next: string | null } {
52
+ return { items: page.items.map((row) => fromRow(schema, row)), next: page.next }
53
+ }
@@ -0,0 +1,102 @@
1
+ // What the anonymous kinds need beyond the request pipeline: a body read under its limit (the proxy never sees a
2
+ // body, so the framework enforces it), the share SPA's fragment bootstrap and GitHub's delivery signature. A form's
3
+ // admission is `form-admission.ts`.
4
+
5
+ import { LINK_BOOTSTRAP_FILE, SHARE_KEY_GLOBAL } from '../contract/defaults'
6
+ import { HttpError } from '../errors'
7
+ import { readBoundedBytes } from './bounded'
8
+
9
+ /** The body as bytes, read until `maxBytes`; a longer one stops the read and is a 413. */
10
+ export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
11
+ const body = await readBoundedBytes(request.body, { maxBytes, declaredLength: request.headers.get('Content-Length') })
12
+ if (body === null) {
13
+ throw new HttpError(413, 'too_large', `The body is larger than ${maxBytes} bytes`)
14
+ }
15
+ return body
16
+ }
17
+
18
+ /**
19
+ * Runs before any other script of the share SPA: it takes the key out of the fragment, removes the fragment from the
20
+ * address with `history.replaceState`, and keeps the key in a closure. Telemetry that reads `location` later finds
21
+ * no key; the SPA reads it through `shareKey()` of `@fabrika/app/client`.
22
+ */
23
+ export const LINK_BOOTSTRAP_SCRIPT = `(function () {
24
+ var fragment = window.location.hash.slice(1)
25
+ var key = /^px_[A-Za-z0-9_-]+$/.test(fragment) ? fragment : null
26
+ if (window.location.hash !== '') {
27
+ window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search)
28
+ }
29
+ Object.defineProperty(window, '${SHARE_KEY_GLOBAL}', {
30
+ value: function () {
31
+ return key
32
+ },
33
+ writable: false,
34
+ configurable: false,
35
+ enumerable: false,
36
+ })
37
+ })()
38
+ `
39
+
40
+ /** The share SPA's bootstrap path under the link prefix. */
41
+ export function bootstrapPath(prefix: string): string {
42
+ return `${prefix}/${LINK_BOOTSTRAP_FILE}`
43
+ }
44
+
45
+ export function bootstrapResponse(): Response {
46
+ return new Response(LINK_BOOTSTRAP_SCRIPT, { status: 200, headers: { 'Content-Type': 'text/javascript; charset=utf-8' } })
47
+ }
48
+
49
+ /**
50
+ * The share SPA's document with the bootstrap as its first script: right after `<head>`, so it runs before every
51
+ * module script and every script the document loads. A response that is not HTML passes unchanged.
52
+ */
53
+ export async function withBootstrap(response: Response, prefix: string): Promise<Response> {
54
+ const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
55
+ if (type !== 'text/html') {
56
+ return response
57
+ }
58
+ const html = await response.text()
59
+ const tag = `<script src="${bootstrapPath(prefix)}"></script>`
60
+ const head = /<head(?:\s[^>]*)?>/i.exec(html)
61
+ const injected = head === null ? `${tag}${html}` : `${html.slice(0, head.index + head[0].length)}${tag}${html.slice(head.index + head[0].length)}`
62
+ const headers = new Headers(response.headers)
63
+ headers.delete('Content-Length')
64
+ return new Response(injected, { status: response.status, statusText: response.statusText, headers })
65
+ }
66
+
67
+ const SIGNATURE_PREFIX = 'sha256='
68
+ const HEX = /^[0-9a-f]{64}$/
69
+
70
+ /** Whether `X-Hub-Signature-256` is GitHub's HMAC-SHA256 of the raw body under `secret`, compared in constant time. */
71
+ export async function verifyGithubSignature(header: string | null, secret: string, body: Uint8Array<ArrayBuffer>): Promise<boolean> {
72
+ if (header === null || !header.startsWith(SIGNATURE_PREFIX)) {
73
+ return false
74
+ }
75
+ const hex = header.slice(SIGNATURE_PREFIX.length).toLowerCase()
76
+ if (!HEX.test(hex)) {
77
+ return false
78
+ }
79
+ const signature = new Uint8Array(32)
80
+ for (let index = 0; index < 32; index++) {
81
+ signature[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
82
+ }
83
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
84
+ return crypto.subtle.verify('HMAC', key, signature, body)
85
+ }
86
+
87
+ /** The GitHub account a payload names, as a binding records it: `installation:<id>` or `repository:<id>`. */
88
+ export function githubAccounts(payload: unknown): readonly string[] {
89
+ const accounts: string[] = []
90
+ for (const kind of ['installation', 'repository'] as const) {
91
+ const holder = ownField(payload, kind)
92
+ const id = ownField(holder, 'id')
93
+ if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) {
94
+ accounts.push(`${kind}:${id}`)
95
+ }
96
+ }
97
+ return accounts
98
+ }
99
+
100
+ function ownField(value: unknown, name: string): unknown {
101
+ return typeof value === 'object' && value !== null && Object.hasOwn(value, name) ? Reflect.get(value, name) : undefined
102
+ }