@fabrika/app 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,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
|
+
}
|