@fabrika/app 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +167 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -8,25 +8,29 @@ architecture, and a refusal's allowed form is the one fix that passes. There is
|
|
|
8
8
|
|
|
9
9
|
The refusals agents meet most, and their fix:
|
|
10
10
|
|
|
11
|
-
| Rule
|
|
12
|
-
|
|
|
13
|
-
| `literal`
|
|
14
|
-
| `contract`
|
|
15
|
-
| `package-replacement`
|
|
16
|
-
| `generated-test-hash`
|
|
17
|
-
| `endpoint-fixture`
|
|
18
|
-
| `raw-sql`
|
|
19
|
-
| `forbidden-import`
|
|
20
|
-
| `forbidden-global`
|
|
21
|
-
| `env-read`
|
|
22
|
-
| `client-identity`
|
|
23
|
-
| `type-assertion`
|
|
24
|
-
| `sensitive-log`
|
|
25
|
-
| `migration-name`
|
|
26
|
-
| `test-only-import`
|
|
27
|
-
| `spa-server-import`
|
|
28
|
-
| `shared-import`
|
|
29
|
-
| `skipped-test`
|
|
11
|
+
| Rule | Means | Fix |
|
|
12
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
13
|
+
| `literal` | the shell (contract, vocabulary, tables, groups, schemas) holds a computed value or call | literal objects, `z.*` calls and imported literal consts; move logic into `.handle(…)` |
|
|
14
|
+
| `contract` | the contract does not compile (a reserved role, an unknown action, a wrong field) | the message names the field |
|
|
15
|
+
| `package-replacement` | `package.json` names the app under `@fabrika/*`, or a dependency is `workspace:*` | another name; a registry range such as `^0.0.4` |
|
|
16
|
+
| `generated-test-hash` | a generated test is missing, edited or stale | `bunx fabrika db test-scaffold`, and commit what it writes |
|
|
17
|
+
| `endpoint-fixture` | an endpoint's input cannot be sampled, a fixture or seed names no endpoint, or a member, invitation or link field no endpoint that reads, cancels or revokes one | a literal in `src/__tests__/endpoint-access.fixtures.ts` (`references/tests.md`) |
|
|
18
|
+
| `raw-sql` | a driver's `sql` (imported from `bun`, Kysely, `postgres`) | the handles, or `sql: 'read'` and `ctx.sql` |
|
|
19
|
+
| `forbidden-import` | a driver, an S3 client, `jose`, `node:http`, `child_process` | the handles, a file column with `ctx.files`, a connector |
|
|
20
|
+
| `forbidden-global` | `Bun.sql`, `Bun.serve`, `Bun.spawn`; `globalThis`, `window`, `self`, `global` or `Bun` in a server module | the handles; state lives in a table, not in a global; `crypto.randomUUID()` for an id |
|
|
21
|
+
| `env-read` | `process.env`, `Bun.env` | `defineDeps(build)`, which receives the application's variables |
|
|
22
|
+
| `client-identity` | an input field `userId`, `ownerId`, `createdBy`, `tenantId`, `workspaceId` | the access form's `from`, `ctx.principal.id`, or `createdBy` in the policy |
|
|
23
|
+
| `type-assertion` | `as T` (other than `as const`), `<T>x` | fix the type; `fromRow` parses a row into a schema's type |
|
|
24
|
+
| `sensitive-log` | a log of an error object, a request, headers or the environment | log a short fixed message |
|
|
25
|
+
| `migration-name` | a migration not named `YYYYMMDDHHMMSS_slug.sql` (UTC) | `bunx fabrika db new <slug>` |
|
|
26
|
+
| `test-only-import` | `@fabrika/app/testing`, `@fabrika/app/react/testing` or `@fabrika/db/testing` outside a test file | import them only from `*.test.ts` or `__tests__/` |
|
|
27
|
+
| `spa-server-import` | the SPA loads a server module | the SPA imports `@fabrika/app/client`, the contract, `src/schemas.ts` and `src/shared/` |
|
|
28
|
+
| `shared-import` | a module under `src/shared/` loads a slice, `src/db/`, `@fabrika/*` or a builtin | a shared module imports `zod/v4` and other shared modules only |
|
|
29
|
+
| `skipped-test` | `.skip`, `.only`, `.todo` | run the test |
|
|
30
|
+
| `file-column-migration` | a file column without `REFERENCES fabrika_files (id)` or without an index it leads | both, in a new migration (`references/table-and-migration.md`) |
|
|
31
|
+
| `on-conflict-index` | `insert(…, { onConflict: { columns } })` whose columns are no unique index the migrations create | a `UNIQUE` constraint or a `CREATE UNIQUE INDEX` without `WHERE` on exactly those columns |
|
|
32
|
+
| `cross-app-fetch` | a `fetch` of an absolute URL under no origin a site declares in `egress` | `egress: ['https://…']` on the site that calls it (`references/endpoint.md`) |
|
|
33
|
+
| `url-endpoint` | `ctx.url('<id>')` names no procedure of the contract | the endpoint id `<mount>.<group>.<procedure>` |
|
|
30
34
|
|
|
31
35
|
The hints:
|
|
32
36
|
|
|
@@ -67,10 +71,9 @@ A kit error lands on the line of the mistake, in one or two lines that name the
|
|
|
67
71
|
|
|
68
72
|
## Runtime answers
|
|
69
73
|
|
|
70
|
-
A procedure
|
|
71
|
-
|
|
72
|
-
`{
|
|
73
|
-
check (`{ status: 'ok' }`) and the SPA's browser configuration answer without one.
|
|
74
|
+
A procedure and a webhook answer JSON in an envelope: a 200 is `{ result: <output> }`, any other status
|
|
75
|
+
`{ error: { type, message } }`. The typed client unwraps it; a plain `fetch` (a test) reads `result`. A file answer,
|
|
76
|
+
a health check (`{ status: 'ok' }`) and the SPA's browser configuration answer without one.
|
|
74
77
|
|
|
75
78
|
| Answer | Comes from |
|
|
76
79
|
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
# references/files.md — file
|
|
1
|
+
# references/files.md — file kinds and file columns
|
|
2
2
|
|
|
3
|
-
A file
|
|
4
|
-
|
|
3
|
+
A file is a value in a table column. The contract declares a file kind (`c.files`): which types, how large, whether it
|
|
4
|
+
is scanned. A table declares a file column of that kind, and the table's policy is the file's authority: reading the
|
|
5
|
+
column under the table's `read` action is what lets a procedure answer the file, and writing it needs the action that
|
|
6
|
+
writes the column. There are no generated file endpoints: each operation is a procedure of a few lines.
|
|
5
7
|
|
|
6
8
|
```ts fabrika.contract.ts
|
|
7
9
|
import { app } from './src/app-kit'
|
|
10
|
+
import { archive } from './src/archiving/endpoints'
|
|
11
|
+
import { attachments } from './src/attachments/endpoints'
|
|
8
12
|
import { comments } from './src/comments/endpoints'
|
|
13
|
+
import { erase } from './src/erasure/endpoints'
|
|
9
14
|
import { members } from './src/members/endpoints'
|
|
10
15
|
import { priorities } from './src/priorities/endpoints'
|
|
11
16
|
import { projects } from './src/projects/endpoints'
|
|
12
17
|
import { reports } from './src/reports/endpoints'
|
|
13
|
-
import { archive } from './src/tasks/archive'
|
|
14
18
|
import { tasks } from './src/tasks/endpoints'
|
|
15
|
-
import { erase } from './src/tasks/erase'
|
|
16
19
|
|
|
17
20
|
export const contract = app.contract((c) => ({
|
|
18
21
|
api: c.rpc('/api', {
|
|
@@ -22,16 +25,12 @@ export const contract = app.contract((c) => ({
|
|
|
22
25
|
reports,
|
|
23
26
|
members,
|
|
24
27
|
priorities,
|
|
28
|
+
attachments,
|
|
25
29
|
}),
|
|
26
30
|
files: {
|
|
27
31
|
attachments: c.files({
|
|
28
|
-
owner: 'tasks',
|
|
29
|
-
read: 'tasks.read',
|
|
30
|
-
write: 'tasks.write',
|
|
31
|
-
delete: 'tasks.write',
|
|
32
|
-
maxBytes: 5242880,
|
|
33
32
|
types: ['image/png', 'image/jpeg', 'application/pdf', 'text/plain'],
|
|
34
|
-
|
|
33
|
+
maxBytes: 5242880,
|
|
35
34
|
}),
|
|
36
35
|
},
|
|
37
36
|
jobs: { archive, erase },
|
|
@@ -39,20 +38,131 @@ export const contract = app.contract((c) => ({
|
|
|
39
38
|
}))
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
The
|
|
41
|
+
The table that holds the files is a child of `tasks`, with a file column of the kind `attachments`:
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
```sql db/migrations/20261012100000_attachments.sql
|
|
44
|
+
CREATE TABLE task_attachments (
|
|
45
|
+
id UUID PRIMARY KEY,
|
|
46
|
+
task_id UUID NOT NULL,
|
|
47
|
+
project_id UUID NOT NULL,
|
|
48
|
+
org_id TEXT NOT NULL,
|
|
49
|
+
file UUID NOT NULL REFERENCES fabrika_files (id),
|
|
50
|
+
created_by TEXT NOT NULL,
|
|
51
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
52
|
+
FOREIGN KEY (task_id, project_id, org_id) REFERENCES tasks (id, project_id, org_id) ON UPDATE CASCADE ON DELETE CASCADE
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX task_attachments_task ON task_attachments (task_id);
|
|
55
|
+
CREATE INDEX task_attachments_file ON task_attachments (file);
|
|
56
|
+
```
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
first, each `{ id, name, type, size, status, createdAt }`, at most `limit` (1–100, default 50) after the id `after`.
|
|
58
|
+
Its policy in `src/db/tables.ts` (`references/table-and-migration.md`) names the column and its kind:
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
```text
|
|
61
|
+
task_attachments: {
|
|
62
|
+
scope: { child: 'tasks' },
|
|
63
|
+
actions: { read: 'tasks.read', insert: 'tasks.write', delete: 'tasks.write' },
|
|
64
|
+
insert: ['file'],
|
|
65
|
+
files: { file: 'attachments' },
|
|
66
|
+
createdBy: 'created_by',
|
|
67
|
+
},
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- A file column is `UUID REFERENCES fabrika_files (id)` with an index on it; the checker's `file-column-migration`
|
|
71
|
+
verifies both for every column the policy names in `files`. `fabrika_files` is the framework's table: never write
|
|
72
|
+
it.
|
|
73
|
+
- A file kind (`c.files({ types, maxBytes, scan? })`) has no owner, no actions and no endpoint. The table's actions
|
|
74
|
+
are the file's authority, and the approver reads "`tasks.write` writes `task_attachments.file` (attachments)".
|
|
75
|
+
- A file reaches what its row reaches, now: a moved task moves its files. Deleting the row (here with its task, by the
|
|
76
|
+
cascade) or clearing a nullable column removes the reference; the framework's sweep deletes a file no column
|
|
77
|
+
references.
|
|
78
|
+
- A file column takes only a file reference: an input file (`file('<kind>')`), a `ctx.files.put` of this request, or a
|
|
79
|
+
reference this request read from a file column under that table's `read` action. A string id is refused.
|
|
80
|
+
|
|
81
|
+
```ts src/attachments/endpoints.ts
|
|
82
|
+
import { file } from '@fabrika/app/contract'
|
|
83
|
+
import { z } from 'zod/v4'
|
|
84
|
+
import { app } from '../app-kit'
|
|
85
|
+
|
|
86
|
+
const Upload = z.strictObject({ taskId: z.uuid(), file: file('attachments') })
|
|
87
|
+
const OfTask = z.strictObject({ taskId: z.uuid() })
|
|
88
|
+
const OneFile = z.strictObject({ taskId: z.uuid(), id: z.uuid() })
|
|
89
|
+
const Saved = z.strictObject({ id: z.uuid() })
|
|
90
|
+
const Empty = z.strictObject({})
|
|
91
|
+
const Attachments = z.strictObject({
|
|
92
|
+
items: z.array(z.strictObject({
|
|
93
|
+
id: z.uuid(),
|
|
94
|
+
name: z.nullable(z.string()),
|
|
95
|
+
type: z.string(),
|
|
96
|
+
size: z.int(),
|
|
97
|
+
status: z.enum(['pending', 'quarantined', 'live', 'rejected']),
|
|
98
|
+
})),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
export const attachments = app.group({ callers: ['human'] }, (r) => ({
|
|
102
|
+
upload: r.mutation({
|
|
103
|
+
input: Upload,
|
|
104
|
+
output: Saved,
|
|
105
|
+
access: { action: 'tasks.write', on: 'task', from: 'taskId' },
|
|
106
|
+
})
|
|
107
|
+
.handle(({ input, ctx }) =>
|
|
108
|
+
ctx.db.target.task_attachments.insert({ file: input.file })
|
|
109
|
+
),
|
|
110
|
+
list: r.query({
|
|
111
|
+
input: OfTask,
|
|
112
|
+
output: Attachments,
|
|
113
|
+
access: { action: 'tasks.read', on: 'task', from: 'taskId' },
|
|
114
|
+
})
|
|
115
|
+
.handle(async ({ ctx }) => ({
|
|
116
|
+
items: (await ctx.db.target.task_attachments.list()).items.map((row) => ({
|
|
117
|
+
id: row.id,
|
|
118
|
+
name: row.file.name,
|
|
119
|
+
type: row.file.type,
|
|
120
|
+
size: row.file.size,
|
|
121
|
+
status: row.file.status,
|
|
122
|
+
})),
|
|
123
|
+
})),
|
|
124
|
+
download: r.query({
|
|
125
|
+
input: OneFile,
|
|
126
|
+
output: file('attachments'),
|
|
127
|
+
access: { action: 'tasks.read', on: 'task', from: 'taskId' },
|
|
128
|
+
})
|
|
129
|
+
.handle(async ({ input, ctx }) =>
|
|
130
|
+
(await ctx.db.target.task_attachments.get(input.id)).file
|
|
131
|
+
),
|
|
132
|
+
remove: r.mutation({
|
|
133
|
+
input: OneFile,
|
|
134
|
+
output: Empty,
|
|
135
|
+
access: { action: 'tasks.write', on: 'task', from: 'taskId' },
|
|
136
|
+
})
|
|
137
|
+
.handle(async ({ input, ctx }) => {
|
|
138
|
+
await ctx.db.target.task_attachments.remove(input.id)
|
|
139
|
+
return {}
|
|
140
|
+
}),
|
|
141
|
+
}))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- An input field `file('<kind>')` of a mutation makes the body multipart; the file is bounded by the kind's `types`
|
|
145
|
+
(sniffed, not trusted) and `maxBytes`. `z.optional(file(k))` is an optional file, `z.array(file(k)).max(n)` up to
|
|
146
|
+
`n` files, each a top-level field; one request carries at most 20 files. The body limit grows by each file at its kind's `maxBytes`, up to 48
|
|
147
|
+
MiB (`limits.maxBytes`, `references/endpoint.md`). The JSON `input` part takes up to that whole limit, and the
|
|
148
|
+
input part and the files together never exceed it: a large input beside its files declares a `limits.maxBytes` that
|
|
149
|
+
covers both. The handler writes the reference into the column, in its
|
|
150
|
+
transaction; a rolled-back request keeps no file.
|
|
151
|
+
- A row read through the data layer carries a file reference in each file column, with `id`, `name`, `type`, `size`
|
|
152
|
+
and `status`, so a list shows them without a call per file.
|
|
153
|
+
- A query whose output is `file('<kind>')` returns the reference, and the framework streams the file. It is a `GET`
|
|
154
|
+
with its input in flat query parameters. A mutation with a file output is a `POST` that answers the file after its
|
|
155
|
+
commit, so a download that writes an audit row is a mutation. The handler never returns a `Response`. A `pending`
|
|
156
|
+
or `quarantined` file answers 409 `file_not_ready`, a `rejected` one 409 `file_rejected`, and one the sweep deleted
|
|
157
|
+
404.
|
|
158
|
+
- A public image (a catalog picture in a page or an e-mail) is a public `GET` query with `http: { cache: 'immutable' }`
|
|
159
|
+
whose input names the file as well as its row (`{ product, file }`), so a new image changes the URL. The handler
|
|
160
|
+
refuses a file id that is not the row's.
|
|
161
|
+
|
|
162
|
+
The SPA calls them through the typed client, never a hand-written `fetch`. `api.attachments.download.url(input)` is
|
|
163
|
+
the URL of a `GET` file procedure for a same-origin page (`<img src>`, `<a download>`), and
|
|
164
|
+
`download(api.attachments.download, input)` of `@fabrika/app/client` fetches it with the caller's credential and
|
|
165
|
+
answers a file (`blob()`, `stream()`, `save()`).
|
|
56
166
|
|
|
57
167
|
```ts web/attachments.ts
|
|
58
168
|
import { createClient, RpcError } from '@fabrika/app/client'
|
|
@@ -60,18 +170,20 @@ import { contract } from '../fabrika.contract'
|
|
|
60
170
|
|
|
61
171
|
const api = createClient(contract, 'api')
|
|
62
172
|
|
|
63
|
-
/** Uploads `file` to the task, then lists its files
|
|
173
|
+
/** Uploads `file` to the task, then lists its files, each a link to its download. */
|
|
64
174
|
export const attach = async (
|
|
65
175
|
taskId: string,
|
|
66
176
|
file: File,
|
|
67
177
|
list: HTMLElement,
|
|
68
178
|
): Promise<void> => {
|
|
69
179
|
try {
|
|
70
|
-
await api.
|
|
71
|
-
const page = await api.
|
|
180
|
+
await api.attachments.upload({ taskId, file })
|
|
181
|
+
const page = await api.attachments.list({ taskId })
|
|
72
182
|
list.replaceChildren(
|
|
73
183
|
...page.items.map((item) =>
|
|
74
|
-
Object.assign(document.createElement('
|
|
184
|
+
Object.assign(document.createElement('a'), {
|
|
185
|
+
href: api.attachments.download.url({ taskId, id: item.id }),
|
|
186
|
+
download: item.name ?? 'file',
|
|
75
187
|
textContent: `${item.name ?? 'file'} (${item.status})`,
|
|
76
188
|
})
|
|
77
189
|
),
|
|
@@ -84,43 +196,35 @@ export const attach = async (
|
|
|
84
196
|
}
|
|
85
197
|
```
|
|
86
198
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
199
|
+
A link holder's client has a key, which never goes into a URL: a link page uses `download`. `ResultOf<typeof
|
|
200
|
+
api.tasks.get>` and `InputOf<typeof api.tasks.create>` of `@fabrika/app/client` name a procedure's answer and input for
|
|
201
|
+
a view that maps them.
|
|
90
202
|
|
|
91
203
|
## From a handler or a job: `ctx.files`
|
|
92
204
|
|
|
93
|
-
`ctx.files
|
|
94
|
-
call takes a `Ref` of the owner (a task here), for one of the collection's actions:
|
|
205
|
+
`ctx.files` of a mutation, a webhook, a job and a job's `tx` has two calls; a query's has `open` only:
|
|
95
206
|
|
|
96
|
-
| Call
|
|
97
|
-
|
|
|
98
|
-
| `
|
|
99
|
-
| `open(
|
|
100
|
-
| `removeAll(owner)` | `delete` | how many files it marked removed |
|
|
207
|
+
| Call | Does |
|
|
208
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
209
|
+
| `put(kind, bytes, { type, name? })` | stores a new file of the kind and answers its reference; it is pending until a column holds it |
|
|
210
|
+
| `open(ref, { maxBytes? })` | `{ id, name, type, size, bytes }` of a live file, read whole |
|
|
101
211
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`maxBytes
|
|
105
|
-
is checked when the call runs, not by the types: a name the contract does not declare is a 500.
|
|
212
|
+
`open` takes a reference the site read from a file column under that table's `read` action. It answers 409
|
|
213
|
+
`file_not_ready` until the scan called the file clean, 409 `file_rejected` for a rejected one, and 413 for a file over
|
|
214
|
+
`maxBytes` (the kind's by default), before any byte is read. Pass the size you hold in memory.
|
|
106
215
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`read`: never mint or widen a capability to list.
|
|
216
|
+
A job reads each task's rows with a `Ref` it minted and opens each live file, here to hand it to an archive service of
|
|
217
|
+
`deps`. A file can also leave as an e-mail attachment (`attachments` of `ctx.mail.send`, `references/jobs.md`):
|
|
110
218
|
|
|
111
|
-
|
|
112
|
-
`deps`. `ctx.mail` carries no attachments, so a file leaves through a service of the application's own:
|
|
113
|
-
|
|
114
|
-
```ts src/tasks/archive.ts
|
|
219
|
+
```ts src/archiving/endpoints.ts
|
|
115
220
|
import { app } from '../app-kit'
|
|
116
221
|
import { archiver } from '../archive'
|
|
117
222
|
|
|
118
|
-
/** The largest report the archive takes in one call: the
|
|
223
|
+
/** The largest report the archive takes in one call: the kind's maxBytes. */
|
|
119
224
|
const REPORT_MAX_BYTES = 5242880
|
|
120
225
|
|
|
121
226
|
export const archive = app.cron('0 6 * * *', {
|
|
122
227
|
actions: ['tasks.read'],
|
|
123
|
-
scope: 'app',
|
|
124
228
|
})
|
|
125
229
|
.handle(async ({ ctx }) => {
|
|
126
230
|
const store = archiver.get()
|
|
@@ -128,12 +232,11 @@ export const archive = app.cron('0 6 * * *', {
|
|
|
128
232
|
const done = await tasks.list({ where: [['status', '=', 'done']], limit: 50 })
|
|
129
233
|
for (const task of done.items) {
|
|
130
234
|
const owner = await ctx.ref('tasks.read', 'task', task.id)
|
|
131
|
-
const
|
|
132
|
-
const page = await files.list(owner)
|
|
235
|
+
const rows = await ctx.db.on(owner).task_attachments.list()
|
|
133
236
|
const reports = []
|
|
134
|
-
for (const
|
|
237
|
+
for (const row of rows.items.filter((item) => item.file.status === 'live')) {
|
|
135
238
|
reports.push(
|
|
136
|
-
await files.open(
|
|
239
|
+
await ctx.files.open(row.file, { maxBytes: REPORT_MAX_BYTES }),
|
|
137
240
|
)
|
|
138
241
|
}
|
|
139
242
|
ctx.signal.throwIfAborted()
|
|
@@ -207,17 +310,15 @@ export default serve(contract, { database: scoped(tables), deps: archiver })
|
|
|
207
310
|
|
|
208
311
|
The `PUT` under the task's id makes a repeated run replace the same object, so a retried attempt stores nothing twice.
|
|
209
312
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
framework's sweep deletes the removed files' objects within the hour, and so the objects of a deleted owner's files.
|
|
313
|
+
An erasure removes the rows that hold the files in the same transaction as its other writes: it keeps both or
|
|
314
|
+
neither. The framework's sweep then deletes each file no column references, within the hour.
|
|
213
315
|
|
|
214
|
-
```ts src/
|
|
316
|
+
```ts src/erasure/endpoints.ts
|
|
215
317
|
import { app } from '../app-kit'
|
|
216
318
|
|
|
217
319
|
/** Blanks the title of every task closed more than a year ago, and removes its attachments with it. */
|
|
218
320
|
export const erase = app.cron('30 2 * * *', {
|
|
219
321
|
actions: ['tasks.read', 'tasks.write'],
|
|
220
|
-
scope: 'app',
|
|
221
322
|
})
|
|
222
323
|
.handle(async ({ ctx }) => {
|
|
223
324
|
const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(
|
|
@@ -236,33 +337,42 @@ export const erase = app.cron('30 2 * * *', {
|
|
|
236
337
|
for (const task of stale.items) {
|
|
237
338
|
const ref = await ctx.ref('tasks.write', 'task', task.id)
|
|
238
339
|
await ctx.transaction(async (tx) => {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
await
|
|
340
|
+
const target = tx.db.on(ref)
|
|
341
|
+
const locked = await target.getForUpdate()
|
|
342
|
+
await target.update({ title: '' }, { expectVersion: locked.version })
|
|
343
|
+
let page = await target.task_attachments.list({ limit: 100 })
|
|
344
|
+
while (page.items.length > 0) {
|
|
345
|
+
for (const row of page.items) {
|
|
346
|
+
await target.task_attachments.remove(row.id)
|
|
347
|
+
}
|
|
348
|
+
page = await target.task_attachments.list({ limit: 100 })
|
|
349
|
+
}
|
|
242
350
|
})
|
|
243
351
|
}
|
|
244
352
|
})
|
|
245
353
|
```
|
|
246
354
|
|
|
247
|
-
-
|
|
248
|
-
-
|
|
355
|
+
- Never write a sweep or delete an object yourself: a file goes when no column references it.
|
|
356
|
+
- **A handover writes the new reference before it deletes the old one, in one transaction.** Written in two
|
|
357
|
+
transactions, the sweep can delete the file between them. The same covers a file two records share, a versioned
|
|
358
|
+
document (a versions table with a file column) and a thumbnail (a second column of another kind): there is no move or
|
|
359
|
+
copy operation.
|
|
360
|
+
- A file referenced from a dimension-scoped table belongs to that top-level scope: a reference from another one is
|
|
361
|
+
refused.
|
|
249
362
|
|
|
250
363
|
## Malware scan
|
|
251
364
|
|
|
252
|
-
`scan: true` on a
|
|
253
|
-
installs ClamAV and runs the scan job.
|
|
254
|
-
`rejected`;
|
|
365
|
+
`scan: true` on a kind holds every file of it `quarantined` until ClamAV answers clean; on Zerops the platform
|
|
366
|
+
installs ClamAV and runs the scan job. A file answer is 409 `file_not_ready` until then and 409 `file_rejected` once
|
|
367
|
+
the file is `rejected`; a list shows each file's `status`, so show it rather than a broken link. In a test, the scan runs with
|
|
255
368
|
`kit.runJob(FILE_SCAN_JOB)` and a `scanner` passed to `testKit` (`references/tests.md`); the generated tests pass their
|
|
256
369
|
own seeded files.
|
|
257
370
|
|
|
258
|
-
- A scanning
|
|
371
|
+
- A scanning kind admits at most 100 MiB per file (`maxBytes`) and not `application/gzip`; an empty upload is
|
|
259
372
|
400 `empty`.
|
|
260
373
|
- A ZIP or Office document must start with the ZIP header. Any other file that holds a ZIP signature anywhere is
|
|
261
374
|
rejected, even a rare harmless PDF or image; that is the fail-closed cost.
|
|
262
|
-
-
|
|
263
|
-
Only a table created by an earlier two-state `fileTableSql` (its check names `'pending', 'live'` alone) needs
|
|
264
|
-
`fileScanSql('<name>')` of `@fabrika/db` in a new migration, and `fabrika-check` names it. On a current table that
|
|
265
|
-
migration fails: it adds the constraint, columns and index again.
|
|
375
|
+
- Turning `scan` on needs no migration: the framework's table holds the scan states.
|
|
266
376
|
- ClamAV holds about 1 GB of signatures in memory. On Zerops the deploy service must declare
|
|
267
377
|
`verticalAutoscaling: { minRam: 2 }` (GB), or the build refuses the application. Zerops ignores a changed `verticalAutoscaling` of an existing service on a re-import:
|
|
268
378
|
raise an existing service's RAM through Zerops itself.
|
|
@@ -280,73 +390,13 @@ own seeded files.
|
|
|
280
390
|
}
|
|
281
391
|
```
|
|
282
392
|
|
|
283
|
-
##
|
|
284
|
-
|
|
285
|
-
Each collection has a table `fabrika_files_<name>`, created by an application migration. Write exactly the DDL that
|
|
286
|
-
`fileTableSql` of `@fabrika/db` returns. `key` and each `chain` entry are `{ column, type }`, with `type` `'uuid'` or
|
|
287
|
-
`'text'` as the owner table declares the column, the nearest container first:
|
|
288
|
-
|
|
289
|
-
```bash
|
|
290
|
-
bun -e "import { fileTableSql } from '@fabrika/db'; console.log(fileTableSql('attachments', { table: 'tasks', key: { column: 'id', type: 'uuid' }, chain: [{ column: 'project_id', type: 'uuid' }, { column: 'org_id', type: 'text' }] }))"
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
```sql db/migrations/20261012100000_attachments.sql
|
|
294
|
-
CREATE TABLE fabrika_files_attachments (
|
|
295
|
-
id UUID PRIMARY KEY,
|
|
296
|
-
owner_id UUID,
|
|
297
|
-
project_id UUID,
|
|
298
|
-
org_id TEXT,
|
|
299
|
-
status TEXT NOT NULL CONSTRAINT fabrika_files_attachments_status CHECK (status IN ('pending', 'live', 'quarantined', 'rejected')),
|
|
300
|
-
object_key TEXT NOT NULL UNIQUE,
|
|
301
|
-
size BIGINT,
|
|
302
|
-
content_type TEXT NOT NULL,
|
|
303
|
-
sha256 TEXT,
|
|
304
|
-
original_name TEXT,
|
|
305
|
-
created_by TEXT NOT NULL,
|
|
306
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
307
|
-
deleted_at TIMESTAMPTZ,
|
|
308
|
-
object_version TEXT,
|
|
309
|
-
scan_attempts INTEGER NOT NULL DEFAULT 0,
|
|
310
|
-
scan_after TIMESTAMPTZ,
|
|
311
|
-
FOREIGN KEY (owner_id, project_id, org_id) REFERENCES tasks (id, project_id, org_id) ON UPDATE CASCADE ON DELETE SET NULL
|
|
312
|
-
);
|
|
313
|
-
CREATE INDEX fabrika_files_attachments_owner ON fabrika_files_attachments (owner_id) WHERE deleted_at IS NULL;
|
|
314
|
-
CREATE INDEX fabrika_files_attachments_removable ON fabrika_files_attachments (created_at) WHERE deleted_at IS NOT NULL OR owner_id IS NULL OR status = 'pending';
|
|
315
|
-
CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_after) WHERE status = 'quarantined';
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
- The owner table needs `UNIQUE (<key>, <chain columns>)`; `tasks` has `UNIQUE (id, project_id, org_id)`.
|
|
319
|
-
- Codegen leaves the table out, and no policy names it: only the framework reads it.
|
|
320
|
-
- The name is lowercase snake_case, at most 39 characters.
|
|
321
|
-
- `link: true` serves a link mount's holders, for the mount's own object: `POST <prefix>/api/files.<name>.upload`,
|
|
322
|
-
`POST <prefix>/api/files.<name>.list` (no `owner`) and `GET <prefix>/api/files.<name>/:fileId` under every link mount bound to the owner's dimension, which must grant the
|
|
323
|
-
collection's `write` and `read`. The owner is the link's object, never a parameter.
|
|
324
|
-
- `link: ['completion']` serves the named link mounts only. Name them when the owner's dimension has a link meant
|
|
325
|
-
for something else (a one-click answer): a mount the list does not name has no file route and need not grant
|
|
326
|
-
the collection's actions. Its holder gets 403 from the proxy, which finds no endpoint for the path.
|
|
327
|
-
|
|
328
|
-
## A file with a public form
|
|
329
|
-
|
|
330
|
-
A visitor without an account reaches no collection endpoint. A form takes the file with its submission instead
|
|
331
|
-
(`references/public-intake.md` builds it): `files: { photo: { collection: 'photos' } }` on `c.form`, into a collection
|
|
332
|
-
with `scan: true`, beside a challenge. The file waits owned by nobody, and nothing reads it; its id lands in the inbox
|
|
333
|
-
column of its field (`photo UUID`). The job that drains the inbox creates the record and attaches the file in the same
|
|
334
|
-
transaction, with a `Ref` of the new record for the collection's `write` action, which `tx.ref` locates in that
|
|
335
|
-
transaction:
|
|
336
|
-
|
|
337
|
-
```text
|
|
338
|
-
const [created] = await tx.sql(Id)`insert into candidates (…) values (…) on conflict (email) do nothing returning id`
|
|
339
|
-
if (created !== undefined && row.photo !== null) {
|
|
340
|
-
await tx.files('photos').attach(await tx.ref('candidates.attach', 'candidate', created.id), row.photo)
|
|
341
|
-
}
|
|
342
|
-
```
|
|
393
|
+
## A file from a public procedure
|
|
343
394
|
|
|
344
|
-
|
|
345
|
-
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
- A file nobody attached is deleted by the sweep job an hour after its hold of seven days ended.
|
|
395
|
+
A visitor without an account sends a file as a field of a public procedure's input
|
|
396
|
+
(`references/public-intake.md` builds it): `photo: z.optional(file('photos'))`, into a kind with `scan: true`, beside
|
|
397
|
+
a challenge and `admission.rate.bytesPerHour`. The handler writes the reference into a file column of an application
|
|
398
|
+
table (`registrations.photo`), and the job that claims the row writes the same reference into the record's column in
|
|
399
|
+
the transaction that deletes the row. The file is never copied; the sweep deletes it once no column references it.
|
|
350
400
|
|
|
351
401
|
## Storage
|
|
352
402
|
|