@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,669 @@
1
+ // The runtime of `c.files`: the generated upload, download, list and remove endpoints, the locator of every
2
+ // `file:<name>` dimension, the scan pass of `scan: true` collections (`scan.ts`), and the sweep that deletes the objects
3
+ // of removed, orphaned and abandoned files. Access is the core's:
4
+ // the dispatcher checks the access form and mints the target, and every database operation here takes that target,
5
+ // so `@fabrika/db` scopes it by the whole chain the access check saw.
6
+
7
+ import type { ContractArtifact, CredentialedEndpoint, EndpointRow, FileCollectionArtifact } from '@fabrika/auth-core'
8
+ import type { CapabilityAuthority, RefGrant } from '@fabrika/db/capability'
9
+ import {
10
+ type FileCollectionTable,
11
+ fileTableName,
12
+ fileTablesOf,
13
+ isScopedDatabase,
14
+ QueryRefusedError,
15
+ type Session,
16
+ type StoredFile,
17
+ } from '@fabrika/db/framework'
18
+ import { type FileObjectStore, type StoredObject, uuidv7 } from '@fabrika/platform'
19
+ import { z } from 'zod/v4'
20
+ import { FILE_LIMITS } from '../contract/defaults'
21
+ import { FileLinkListInput, FileListInput, FileListOutput } from '../contract/file-schemas'
22
+ import { HttpError, jsonResponse, NotFoundError } from '../errors'
23
+ import { type CapabilityMint, CapabilityRefusedError, type RequestFrame } from '../implement/capability'
24
+ import { NO_FACILITIES, type ServedEndpoint } from '../implement/dispatch'
25
+ import { ServerFault } from '../implement/faults'
26
+ import type { ContractDatabase, ImplementationDatabase } from '../implement/implement'
27
+ import type { OperationsReporter } from '../jobs/report'
28
+ import type { FileCollectionHandle, FileCollections as FilesOfSite, FileListing, FileListPage, OpenedFile, OpenOptions } from './handle'
29
+ import { type FileScanner, runScanPass } from './scan'
30
+ import { bytesAreOfType, SNIFF_BYTES, SNIFFABLE_TYPES } from './sniff'
31
+ import { FileDownload } from './treatment'
32
+
33
+ /** Which step of a file cleanup or sweep failed: the whole of what its log line says about the failure. */
34
+ type FileCleanupStep = 'rows_read_failed' | 'object_delete_failed' | 'row_delete_failed'
35
+
36
+ /** How many removable files one sweep handles; the next remove picks up the rest. */
37
+ const SWEEP_BATCH = 20
38
+ const MAX_NAME_CODE_POINTS = 255
39
+ /** An upload whose body has not arrived by then fails, so its pending row is abandoned well before the grace ends. */
40
+ export const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000
41
+ /** A pending row older than this is an upload that will never complete; the sweep deletes its object and the row. */
42
+ export const PENDING_GRACE_MS = 60 * 60 * 1000
43
+ /**
44
+ * The framework's sweep job: hourly, it deletes the objects of every collection's removed and orphaned files and of
45
+ * abandoned uploads, so none waits for the next request. A reserved name with `-`, which no `c.cron` name can take;
46
+ * `@fabrika/provider-zerops` writes it into the crontab on this schedule (`ZEROPS_FILE_SWEEP_JOB`).
47
+ */
48
+ export const FILE_SWEEP_JOB = 'fabrika-file-sweep'
49
+ export const FILE_SWEEP_SCHEDULE = '41 * * * *'
50
+ export const FILE_SWEEP_TIMEOUT_MS = 10 * 60_000
51
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
52
+
53
+ const ById = z.strictObject({ id: z.uuid() })
54
+
55
+ /** Where `ctx.files` runs: the site's frame, the session its statements run in, and whether it may remove. */
56
+ export interface FileSite {
57
+ /** `job 'erase'`, an endpoint id: named in a refusal. */
58
+ readonly where: string
59
+ readonly frame: RequestFrame
60
+ /** Throws once the site's work ended, or when called from other work. */
61
+ readonly check: () => void
62
+ /** The invocation's transaction, a job's fenced transaction, or a job's statement-at-a-time session. */
63
+ readonly session: () => Promise<Session>
64
+ /** `null` where `removeAll` runs (a mutation, a job's transaction); otherwise why it does not. */
65
+ readonly readOnly: string | null
66
+ }
67
+
68
+ interface Collection {
69
+ readonly name: string
70
+ readonly artifact: FileCollectionArtifact
71
+ readonly dimension: string
72
+ /** The owner table's dimension, which `file:<name>` lies in. */
73
+ readonly ownerDimension: string
74
+ readonly table: FileCollectionTable
75
+ /** The link mounts bound to the owner's dimension, for a `link: true` collection: each serves its link holders. */
76
+ readonly linkMounts: readonly string[]
77
+ }
78
+
79
+ /** The media type of a `Content-Type` value, without parameters, lower-cased. */
80
+ function mediaType(header: string | null): string | null {
81
+ const type = header?.split(';')[0]?.trim().toLowerCase()
82
+ return type === undefined || type === '' ? null : type
83
+ }
84
+
85
+ /** The client's file name, metadata only: at most 255 code points, never half a character, no control characters. */
86
+ export function originalName(request: Request): string | null {
87
+ const raw = new URL(request.url).searchParams.get('name') ?? ''
88
+ const name = Array.from(raw.replace(/\p{Cc}/gu, '').trim()).slice(0, MAX_NAME_CODE_POINTS).join('')
89
+ return name === '' ? null : name
90
+ }
91
+
92
+ /** The first `size` bytes of `body`, and a stream that yields the whole body again. */
93
+ async function readHead(
94
+ body: ReadableStream<Uint8Array>,
95
+ size: number,
96
+ ): Promise<{ readonly head: Uint8Array; readonly stream: ReadableStream<Uint8Array> }> {
97
+ const reader = body.getReader()
98
+ const chunks: Uint8Array[] = []
99
+ let length = 0
100
+ let done = false
101
+ while (length < size) {
102
+ const next = await reader.read()
103
+ if (next.done) {
104
+ done = true
105
+ break
106
+ }
107
+ chunks.push(next.value)
108
+ length += next.value.byteLength
109
+ }
110
+ const buffered = new Uint8Array(length)
111
+ let offset = 0
112
+ for (const chunk of chunks) {
113
+ buffered.set(chunk, offset)
114
+ offset += chunk.byteLength
115
+ }
116
+ const stream = new ReadableStream<Uint8Array>({
117
+ start(controller) {
118
+ if (buffered.byteLength > 0) {
119
+ controller.enqueue(buffered)
120
+ }
121
+ if (done) {
122
+ controller.close()
123
+ }
124
+ },
125
+ async pull(controller) {
126
+ const next = await reader.read()
127
+ if (next.done) {
128
+ controller.close()
129
+ } else {
130
+ controller.enqueue(next.value)
131
+ }
132
+ },
133
+ cancel: (reason) => reader.cancel(reason),
134
+ })
135
+ return { head: buffered.subarray(0, size), stream }
136
+ }
137
+
138
+ /** The body as it is stored: hashed on the way, and failed once `timeoutMs` passes before it ends. */
139
+ function digesting(body: ReadableStream<Uint8Array>, timeoutMs: number): {
140
+ readonly stream: ReadableStream<Uint8Array>
141
+ digest(): string
142
+ timedOut(): boolean
143
+ stop(): void
144
+ } {
145
+ const hasher = new Bun.CryptoHasher('sha256')
146
+ let timer: ReturnType<typeof setTimeout> | undefined
147
+ let expired = false
148
+ const stream = body.pipeThrough(
149
+ new TransformStream<Uint8Array, Uint8Array>({
150
+ start(controller) {
151
+ timer = setTimeout(() => {
152
+ expired = true
153
+ controller.error(new Error('the upload did not finish in time'))
154
+ }, timeoutMs)
155
+ },
156
+ transform(chunk, controller) {
157
+ hasher.update(chunk)
158
+ controller.enqueue(chunk)
159
+ },
160
+ flush() {
161
+ clearTimeout(timer)
162
+ },
163
+ }),
164
+ )
165
+ return { stream, digest: () => hasher.digest('hex'), timedOut: () => expired, stop: () => clearTimeout(timer) }
166
+ }
167
+
168
+ /** The whole body when it holds exactly `size` bytes; `null` as soon as it proves longer, or once it ends shorter. */
169
+ async function readExactly(body: ReadableStream<Uint8Array>, size: number): Promise<Uint8Array | null> {
170
+ const bytes = new Uint8Array(size)
171
+ let length = 0
172
+ const reader = body.getReader()
173
+ try {
174
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
175
+ if (length + next.value.byteLength > size) {
176
+ return null
177
+ }
178
+ bytes.set(next.value, length)
179
+ length += next.value.byteLength
180
+ }
181
+ } finally {
182
+ reader.releaseLock()
183
+ }
184
+ return length === size ? bytes : null
185
+ }
186
+
187
+ function isNamed(error: unknown, name: string): boolean {
188
+ return error instanceof Error && error.name === name
189
+ }
190
+
191
+ export class FileCollections {
192
+ readonly #collections: readonly Collection[]
193
+ readonly #byDimension: ReadonlyMap<string, Collection>
194
+ readonly #store: FileObjectStore
195
+ readonly #capabilities: CapabilityAuthority
196
+ readonly #mint: CapabilityMint
197
+
198
+ constructor(collections: readonly Collection[], store: FileObjectStore, capabilities: CapabilityAuthority, mint: CapabilityMint) {
199
+ this.#collections = collections
200
+ this.#byDimension = new Map(collections.map((collection) => [collection.dimension, collection]))
201
+ this.#store = store
202
+ this.#capabilities = capabilities
203
+ this.#mint = mint
204
+ }
205
+
206
+ /** `ctx.files` of one site: each collection's handle, by its name. */
207
+ site(site: FileSite): FilesOfSite<FileCollectionHandle> {
208
+ const handles = new Map(this.#collections.map((collection) => [collection.name, this.#handle(collection, site)]))
209
+ return (name) => {
210
+ site.check()
211
+ const handle = typeof name === 'string' ? handles.get(name) : undefined
212
+ if (handle === undefined) {
213
+ throw new ServerFault(
214
+ 'file_collection_unknown',
215
+ `${site.where}: ctx.files('${String(name)}') names no collection of the contract (collections: ${[...handles.keys()].join(', ')})`,
216
+ )
217
+ }
218
+ return handle
219
+ }
220
+ }
221
+
222
+ #handle(collection: Collection, site: FileSite): FileCollectionHandle {
223
+ return Object.freeze({
224
+ list: async (owner: unknown, page: FileListPage = {}) => {
225
+ site.check()
226
+ this.#ownerGrant(collection, site, owner, ['read'])
227
+ return await this.#listOwned(collection, site, owner, page)
228
+ },
229
+ open: async (owner: unknown, id: unknown, options: OpenOptions) => {
230
+ site.check()
231
+ const grant = this.#ownerGrant(collection, site, owner, ['read'])
232
+ return await this.#open(collection, site, grant, id, options)
233
+ },
234
+ removeAll: async (owner: unknown) => {
235
+ site.check()
236
+ if (site.readOnly !== null) {
237
+ throw new QueryRefusedError('read_only', `${site.where}: files '${collection.name}' removeAll: ${site.readOnly}`)
238
+ }
239
+ this.#ownerGrant(collection, site, owner, ['delete'])
240
+ return await collection.table.removeOwned(owner, new Date(), await site.session())
241
+ },
242
+ })
243
+ }
244
+
245
+ /**
246
+ * The grant of `owner`: a `Ref` of the collection's owner, of the work running now, for one of the collection's
247
+ * `actions`: `read` lists and opens, `delete` removes. A write capability alone reads no file's name or bytes.
248
+ */
249
+ #ownerGrant(collection: Collection, site: FileSite, owner: unknown, actions: readonly ('read' | 'write' | 'delete')[]): RefGrant {
250
+ const grant = this.#capabilities.resolve(owner)
251
+ const needed = actions.map((action) => collection.artifact[action])
252
+ if (grant.kind !== 'ref' || grant.dimension !== collection.ownerDimension || !needed.includes(grant.action)) {
253
+ throw new CapabilityRefusedError(
254
+ 'wrong_capability',
255
+ `${site.where}: files '${collection.name}' takes a Ref of ${collection.ownerDimension} for ${
256
+ needed.map((action) => `'${action}'`).join(' or ')
257
+ }, got one for '${grant.action}'`,
258
+ )
259
+ }
260
+ return grant
261
+ }
262
+
263
+ async #listOwned(collection: Collection, site: FileSite, owner: unknown, page: FileListPage): Promise<FileListing> {
264
+ const limit = page.limit ?? FILE_LIMITS.listPageDefault
265
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > FILE_LIMITS.listPageMax) {
266
+ throw new QueryRefusedError('list_page', `${site.where}: files '${collection.name}' lists 1 to ${FILE_LIMITS.listPageMax} files a page`)
267
+ }
268
+ if (page.after !== undefined && !UUID.test(page.after)) {
269
+ throw new QueryRefusedError('list_page', `${site.where}: files '${collection.name}' takes the id of a file as after`)
270
+ }
271
+ const listed = await collection.table.list(owner, { after: page.after ?? null, limit }, await site.session())
272
+ return Object.freeze({ items: Object.freeze([...listed.items]), next: listed.next })
273
+ }
274
+
275
+ /**
276
+ * A file of the owner `grant` names, read whole: located now, it must lie beneath that owner where the `Ref` says,
277
+ * and is then read under a file `Ref` of the same principal and action, as the download endpoint reads it.
278
+ */
279
+ async #open(collection: Collection, site: FileSite, grant: RefGrant, id: unknown, options: OpenOptions): Promise<OpenedFile> {
280
+ const maxBytes = options.maxBytes
281
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
282
+ throw new QueryRefusedError('invalid_argument', `${site.where}: files '${collection.name}' open takes maxBytes, a positive whole number`)
283
+ }
284
+ if (typeof id !== 'string' || !UUID.test(id)) {
285
+ throw new NotFoundError()
286
+ }
287
+ const located = await collection.table.locate(id, await site.session())
288
+ const beneath = located !== null && located.get(grant.dimension) === grant.id
289
+ && [...grant.chain].every(([dimension, value]) => located.get(dimension) === value)
290
+ if (located === null || !beneath) {
291
+ throw new NotFoundError()
292
+ }
293
+ const target = this.#mint.ref(site.frame, collection.dimension, grant.action, grant.principal, id, located)
294
+ const file = await collection.table.find(target, await site.session())
295
+ if (file.status === 'quarantined') {
296
+ throw new HttpError(409, 'quarantined', 'The file is being checked for malware; try again in a few minutes')
297
+ }
298
+ if (file.status === 'rejected') {
299
+ throw new NotFoundError()
300
+ }
301
+ if (file.size > maxBytes) {
302
+ throw new HttpError(413, 'too_large', `The file has ${file.size} bytes, more than the ${maxBytes} this site reads`)
303
+ }
304
+ const object = collection.artifact.scan === true ? await this.#scannedObject(collection, target, file) : await this.#store.read(file.objectKey)
305
+ if (object === null) {
306
+ throw new ServerFault('file_object_missing', `the object of a file in '${collection.name}' is missing from storage`)
307
+ }
308
+ const bytes = object.size === file.size ? await readExactly(object.body, file.size) : null
309
+ if (bytes === null) {
310
+ await object.body.cancel()
311
+ throw new ServerFault('file_object_mismatch', `the object of a file in '${collection.name}' has another size than its row records`)
312
+ }
313
+ return Object.freeze({ id: file.id, name: file.originalName, type: file.contentType, size: file.size, bytes })
314
+ }
315
+
316
+ /** The `file:<name>` dimensions; the dispatcher locates them like a table's. */
317
+ get dimensions(): readonly string[] {
318
+ return [...this.#byDimension.keys()]
319
+ }
320
+
321
+ /** The application's database, with the locator extended to every file dimension: a live file's row, read now. */
322
+ locating(database: ContractDatabase): ContractDatabase {
323
+ return {
324
+ tables: database.tables,
325
+ locate: (dimension, id) => {
326
+ const collection = this.#byDimension.get(dimension)
327
+ if (collection === undefined) {
328
+ return database.locate(dimension, id)
329
+ }
330
+ return UUID.test(id) ? collection.table.locate(id) : Promise.resolve(null)
331
+ },
332
+ }
333
+ }
334
+
335
+ /** The served endpoints of every collection, by endpoint id. */
336
+ endpoints(rows: ReadonlyMap<string, EndpointRow>, problems: string[]): ReadonlyMap<string, ServedEndpoint> {
337
+ const served = new Map<string, ServedEndpoint>()
338
+ for (const collection of this.#collections) {
339
+ const row = (suffix: string, id = `files.${collection.name}.${suffix}`): CredentialedEndpoint | null => {
340
+ const found = rows.get(id)
341
+ if (found === undefined || found.requirement === null) {
342
+ problems.push(`files '${collection.name}' has no '${id}' endpoint row`)
343
+ return null
344
+ }
345
+ return found
346
+ }
347
+ const upload = row('upload')
348
+ const download = row('download')
349
+ const list = row('list')
350
+ const remove = row('remove')
351
+ if (upload !== null) {
352
+ served.set(upload.id, { kind: 'file', row: upload, serve: (request, target) => this.#upload(collection, request, target) })
353
+ }
354
+ if (download !== null) {
355
+ served.set(download.id, { kind: 'file', row: download, serve: (_request, target) => this.#download(collection, target) })
356
+ }
357
+ if (list !== null) {
358
+ served.set(list.id, {
359
+ kind: 'procedure',
360
+ row: list,
361
+ input: FileListInput,
362
+ output: FileListOutput,
363
+ handler: async ({ input, ctx }) => this.#list(collection, 'target' in ctx ? ctx.target : undefined, input),
364
+ facilities: NO_FACILITIES,
365
+ })
366
+ }
367
+ for (const mount of collection.linkMounts) {
368
+ const linkRow = (suffix: string): CredentialedEndpoint | null => row(suffix, `${mount}.files.${collection.name}.${suffix}`)
369
+ const linkUpload = linkRow('upload')
370
+ const linkDownload = linkRow('download')
371
+ const linkList = linkRow('list')
372
+ if (linkList !== null) {
373
+ served.set(linkList.id, {
374
+ kind: 'procedure',
375
+ row: linkList,
376
+ input: FileLinkListInput,
377
+ output: FileListOutput,
378
+ handler: async ({ input, ctx }) => this.#list(collection, 'target' in ctx ? ctx.target : undefined, input),
379
+ facilities: NO_FACILITIES,
380
+ })
381
+ }
382
+ if (linkUpload !== null) {
383
+ served.set(linkUpload.id, { kind: 'file', row: linkUpload, serve: (request, target) => this.#upload(collection, request, target) })
384
+ }
385
+ if (linkDownload !== null) {
386
+ served.set(linkDownload.id, {
387
+ kind: 'file',
388
+ row: linkDownload,
389
+ serve: (_request, target) => this.#download(collection, target),
390
+ beneath: collection.dimension,
391
+ })
392
+ }
393
+ }
394
+ if (remove !== null) {
395
+ served.set(remove.id, {
396
+ kind: 'procedure',
397
+ row: remove,
398
+ input: ById,
399
+ output: ById,
400
+ handler: async ({ ctx }) => this.#remove(collection, 'target' in ctx ? ctx.target : undefined),
401
+ facilities: NO_FACILITIES,
402
+ })
403
+ }
404
+ }
405
+ return served
406
+ }
407
+
408
+ /**
409
+ * The pending row comes first and is the intent to clean the object up: whatever fails after it — the write, a
410
+ * lost answer of the store, the owner moving, the final write of the row — leaves a row the sweep finds.
411
+ */
412
+ async #upload(collection: Collection, request: Request, owner: unknown): Promise<Response> {
413
+ const { artifact, table } = collection
414
+ const type = mediaType(request.headers.get('Content-Type'))
415
+ if (type === null || !artifact.types.includes(type)) {
416
+ throw new HttpError(415, 'unsupported_media_type', `files '${collection.name}' admits ${artifact.types.join(', ')}`)
417
+ }
418
+ if (Number(request.headers.get('Content-Length') ?? 0) > artifact.maxBytes) {
419
+ throw tooLarge(collection)
420
+ }
421
+ // A quarantined or rejected file holds its place too: uploads waiting for the scan are not a way past the limit.
422
+ if (artifact.maxPerOwner !== null && (await table.countHeld(owner)) >= artifact.maxPerOwner) {
423
+ throw tooMany(collection)
424
+ }
425
+ const { head, stream } = await readHead(request.body ?? new Blob([]).stream(), SNIFF_BYTES)
426
+ if (!bytesAreOfType(type, head)) {
427
+ await stream.cancel()
428
+ throw new HttpError(415, 'unsupported_media_type', `The bytes of the file are not ${type}`)
429
+ }
430
+ const id = uuidv7()
431
+ const objectKey = `${collection.name}/${id}`
432
+ await table.begin(owner, { id, objectKey, contentType: type, originalName: originalName(request), createdAt: new Date() })
433
+ const body = digesting(stream, UPLOAD_TIMEOUT_MS)
434
+ try {
435
+ const { size } = await this.#store.write(objectKey, body.stream, { contentType: type, maxBytes: artifact.maxBytes })
436
+ if (size === 0 && artifact.scan === true) {
437
+ // A scanner gives no verdict on nothing, and a file goes live only on a clean verdict.
438
+ throw new HttpError(400, 'empty', `files '${collection.name}' scans its files and admits no empty file`)
439
+ }
440
+ await table.complete(owner, id, { size, sha256: body.digest() }, artifact.scan === true ? 'quarantined' : 'live')
441
+ } catch (error) {
442
+ if (isNamed(error, 'ObjectExistsError')) {
443
+ // The key holds an object this upload did not write: never delete it, only the pending row.
444
+ await table.abandon(id)
445
+ throw new ServerFault('file_key_taken', `files '${collection.name}' found an object already under a new file's key`)
446
+ }
447
+ await this.#abandon(collection, id, objectKey)
448
+ if (isNamed(error, 'ObjectTooLargeError')) {
449
+ throw tooLarge(collection)
450
+ }
451
+ if (body.timedOut()) {
452
+ throw new HttpError(408, 'timeout', `The upload did not finish within ${UPLOAD_TIMEOUT_MS / 1000} seconds`)
453
+ }
454
+ throw error
455
+ } finally {
456
+ body.stop()
457
+ }
458
+ // Counted again once held: two concurrent uploads may both have passed the first count.
459
+ if (artifact.maxPerOwner !== null && (await table.countHeld(owner)) > artifact.maxPerOwner) {
460
+ await table.retract(owner, id, new Date())
461
+ await this.#sweep(collection)
462
+ throw tooMany(collection)
463
+ }
464
+ return jsonResponse({ result: { id } }, 200)
465
+ }
466
+
467
+ /**
468
+ * Deletes the object, then the pending row; a failure of either leaves the row for the sweep. The log names the step
469
+ * that failed, never the error: a storage or driver error's name and message are not the framework's.
470
+ */
471
+ async #abandon(collection: Collection, id: string, objectKey: string): Promise<void> {
472
+ let step: FileCleanupStep = 'object_delete_failed'
473
+ try {
474
+ await this.#store.delete(objectKey)
475
+ step = 'row_delete_failed'
476
+ await collection.table.abandon(id)
477
+ } catch {
478
+ console.error(`fabrika: files '${collection.name}' cleanup: ${step}`)
479
+ }
480
+ }
481
+
482
+ /** Only a live file is served: a quarantined one answers 409 until the scan pass decides, a rejected one 404. */
483
+ async #download(collection: Collection, target: unknown): Promise<FileDownload> {
484
+ const file = await collection.table.find(target)
485
+ if (file.status === 'quarantined') {
486
+ throw new HttpError(409, 'quarantined', 'The file is being checked for malware; try again in a few minutes')
487
+ }
488
+ if (file.status === 'rejected') {
489
+ throw new NotFoundError()
490
+ }
491
+ const object = collection.artifact.scan === true ? await this.#scannedObject(collection, target, file) : await this.#store.read(file.objectKey)
492
+ if (object === null) {
493
+ throw new ServerFault('file_object_missing', `the object of a file in '${collection.name}' is missing from storage`)
494
+ }
495
+ if (object.size !== file.size) {
496
+ await object.body.cancel()
497
+ throw new ServerFault('file_object_mismatch', `the object of a file in '${collection.name}' has another size than its row records`)
498
+ }
499
+ return new FileDownload({ body: object.body, size: file.size, contentType: file.contentType, name: file.originalName })
500
+ }
501
+
502
+ /**
503
+ * The object of a live file of a scanning collection, only as the version the scan passed, in one conditional read:
504
+ * no byte of a replaced object is served. A file without that version — replaced, or live before the collection
505
+ * scanned — is quarantined again, so the scan job decides about the bytes stored now.
506
+ */
507
+ async #scannedObject(collection: Collection, target: unknown, file: StoredFile): Promise<StoredObject> {
508
+ const object = file.objectVersion === null ? null : await this.#store.readVersion(file.objectKey, file.objectVersion)
509
+ if (object === null) {
510
+ await collection.table.requarantine(target, file.objectVersion)
511
+ throw new HttpError(409, 'quarantined', 'The file is being checked for malware; try again in a few minutes')
512
+ }
513
+ return object
514
+ }
515
+
516
+ async #list(collection: Collection, owner: unknown, input: Readonly<Record<string, unknown>>): Promise<z.input<typeof FileListOutput>> {
517
+ // The dispatcher parsed the input with `FileListInput` (`FileLinkListInput` for a link holder); the target is the owner.
518
+ const after = input['after']
519
+ const limit = input['limit']
520
+ const page = await collection.table.list(owner, {
521
+ after: typeof after === 'string' ? after : null,
522
+ limit: typeof limit === 'number' ? limit : FILE_LIMITS.listPageDefault,
523
+ })
524
+ return {
525
+ items: page.items.map((file) => ({ ...file, createdAt: file.createdAt.toISOString() })),
526
+ next: page.next,
527
+ }
528
+ }
529
+
530
+ async #remove(collection: Collection, target: unknown): Promise<{ readonly id: string }> {
531
+ const grant = this.#capabilities.resolve(target)
532
+ if (grant.kind !== 'ref') {
533
+ throw new ServerFault('contract_inconsistent', `files '${collection.name}' remove was handed a Within`)
534
+ }
535
+ await collection.table.markRemoved(target, new Date())
536
+ await this.#sweep(collection)
537
+ return { id: grant.id }
538
+ }
539
+
540
+ /**
541
+ * Deletes the objects of one batch of removed, orphaned and abandoned files, then their rows. Retry-safe: a row stays
542
+ * until its object is gone, so a failed delete is retried by the next sweep. It never fails the request that
543
+ * triggered it; it answers whether a full batch went (`more`), a smaller one (`done`), or a step failed.
544
+ */
545
+ async #sweep(collection: Collection): Promise<'more' | 'done' | 'failed'> {
546
+ const pendingBefore = new Date(Date.now() - PENDING_GRACE_MS)
547
+ let step: FileCleanupStep = 'rows_read_failed'
548
+ try {
549
+ const batch = await collection.table.removable(SWEEP_BATCH, pendingBefore)
550
+ for (const file of batch) {
551
+ step = 'object_delete_failed'
552
+ await this.#store.delete(file.objectKey)
553
+ step = 'row_delete_failed'
554
+ await collection.table.purge(file.id, pendingBefore)
555
+ }
556
+ return batch.length === SWEEP_BATCH ? 'more' : 'done'
557
+ } catch {
558
+ console.error(`fabrika: files '${collection.name}' sweep: ${step}`)
559
+ return 'failed'
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Sweeps every collection, batch by batch, until none holds a removable file or `signal` aborts: the body of the sweep
565
+ * job. A collection whose sweep failed waits for the next run, and the run fails once every other one was swept.
566
+ */
567
+ async sweep(signal: AbortSignal): Promise<void> {
568
+ const failed: string[] = []
569
+ for (const collection of this.#collections) {
570
+ let state: 'more' | 'done' | 'failed' = 'more'
571
+ while (state === 'more' && !signal.aborted) {
572
+ state = await this.#sweep(collection)
573
+ }
574
+ if (state === 'failed') {
575
+ failed.push(collection.name)
576
+ }
577
+ }
578
+ if (failed.length > 0) {
579
+ throw new ServerFault('file_sweep_failed', `the sweep of files ${failed.map((name) => `'${name}'`).join(', ')} failed; the next run retries it`)
580
+ }
581
+ }
582
+
583
+ /** Whether a collection scans its uploads, so the scan job runs. */
584
+ get scans(): boolean {
585
+ return this.#collections.some((collection) => collection.artifact.scan === true)
586
+ }
587
+
588
+ /** One scan pass over every `scan: true` collection, then their sweep: the body of the scan job. */
589
+ async scanPass(scanner: FileScanner | null, reporter: OperationsReporter, signal: AbortSignal): Promise<void> {
590
+ const scanning = this.#collections.filter((collection) => collection.artifact.scan === true)
591
+ await runScanPass({ collections: scanning, store: this.#store, scanner, reporter, signal })
592
+ for (const collection of scanning) {
593
+ await this.#sweep(collection)
594
+ }
595
+ }
596
+ }
597
+
598
+ function tooLarge(collection: Collection): HttpError {
599
+ return new HttpError(413, 'too_large', `files '${collection.name}' admits at most ${collection.artifact.maxBytes} bytes per file`)
600
+ }
601
+
602
+ function tooMany(collection: Collection): HttpError {
603
+ return new HttpError(
604
+ 409,
605
+ 'limit',
606
+ `files '${collection.name}' admits at most ${collection.artifact.maxPerOwner} files per ${collection.artifact.owner}`,
607
+ )
608
+ }
609
+
610
+ /**
611
+ * The file collections of `artifact`, or `null` when it declares none. Every reason a collection cannot be served
612
+ * goes into `problems`, so `boot` refuses to start rather than answer an upload it cannot check.
613
+ */
614
+ /** Whether a collection's `link` reaches its files through the link mount `mount`. */
615
+ const servesLink = (link: boolean | readonly string[], mount: string): boolean => (typeof link === 'boolean' ? link : link.includes(mount))
616
+
617
+ export function fileCollections(
618
+ artifact: ContractArtifact,
619
+ database: ImplementationDatabase | null,
620
+ store: FileObjectStore | undefined,
621
+ capabilities: CapabilityAuthority,
622
+ mint: CapabilityMint,
623
+ problems: string[],
624
+ ): FileCollections | null {
625
+ const entries = Object.entries(artifact.files)
626
+ if (entries.length === 0) {
627
+ return null
628
+ }
629
+ if (store === undefined) {
630
+ problems.push('files are declared, and the runtime adapter supplies no object storage (FABRIKA_APP_STORAGE_* on Zerops)')
631
+ }
632
+ if (database === null || !isScopedDatabase(database)) {
633
+ problems.push('files need the scoped(tables) data layer of @fabrika/db: a file row lives beside its owner row')
634
+ return null
635
+ }
636
+ const tables = fileTablesOf(database)
637
+ const collections: Collection[] = []
638
+ for (const [name, collection] of entries) {
639
+ if (fileTableName(name) === null) {
640
+ problems.push(`files '${name}' needs a lowercase snake_case name of at most 39 characters: it names the table fabrika_files_<name>`)
641
+ continue
642
+ }
643
+ for (const type of collection.types.filter((candidate) => !SNIFFABLE_TYPES.includes(candidate))) {
644
+ problems.push(`files '${name}' admits '${type}', whose bytes the framework cannot verify (allowed: ${SNIFFABLE_TYPES.join(', ')})`)
645
+ }
646
+ const dimension = `file:${name}`
647
+ try {
648
+ const table = tables.collection({
649
+ name,
650
+ owner: collection.owner,
651
+ dimension,
652
+ actions: { read: collection.read, write: collection.write, delete: collection.delete },
653
+ scan: collection.scan === true,
654
+ })
655
+ const ownerDimension = artifact.dimensions[dimension]?.in ?? null
656
+ if (ownerDimension === null) {
657
+ problems.push(`files '${name}' has no dimension ${dimension} inside its owner's in the compiled contract`)
658
+ continue
659
+ }
660
+ const linkMounts = Object.entries(artifact.mounts).flatMap(([mount, declared]) =>
661
+ declared.kind === 'link' && declared.dimension === ownerDimension && servesLink(collection.link, mount) ? [mount] : []
662
+ )
663
+ collections.push({ name, artifact: collection, dimension, ownerDimension, table, linkMounts })
664
+ } catch (error) {
665
+ problems.push(error instanceof Error ? error.message : `files '${name}' cannot be served`)
666
+ }
667
+ }
668
+ return store === undefined ? null : new FileCollections(collections, store, capabilities, mint)
669
+ }