@fabrika/app 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -356,10 +356,10 @@ export function EditTask(
356
356
  - `Form` shows its `error`: a summary on top, and each `fields[name]` under the `Field` of that `name`. `Field` gives
357
357
  its control the `name`, an `id`, its label and `aria-describedby`; the control needs nothing else. `Form` hands
358
358
  `onSubmit` the `FormData`: read the fields by name, and leave the checking to the procedure's schema.
359
- - A call that is no procedure — a file collection's `api.files.<name>.upload(owner, file)`, `list`, `download` —
360
- runs through `useAction(async (input) => …)`, with the same state and error union; it refetches only what
359
+ - A call that is no procedure of a client runs through `useAction(async (input) => …)`, with the same state and error union; it refetches only what
361
360
  `invalidates` names.
362
- - An attachment procedure (`output: attachment(…)`) downloads through `useDownload(api.reports.exportProject)`:
361
+ - An attachment procedure (`output: attachment(…)`) or a file procedure (`output: file('<kind>')`) downloads through
362
+ `useDownload(api.reports.exportProject)`:
363
363
  `mutate(input)` saves the file and answers it, with the same state and error union. It runs on the provider's client,
364
364
  so a screen test downloads over the kit; never call `download(api.…)` of the module's client from a screen.
365
365
 
@@ -737,9 +737,9 @@ if (postgres !== null) {
737
737
 
738
738
  - `web/__tests__/dom.ts` comes first: imports run in order, and React reads `window` when it loads. `installDom` keeps
739
739
  Bun's `fetch`, `AbortController`, `URL`, `Blob`, `File`, `FormData` and its `ReadableStream`, `WritableStream` and
740
- `TransformStream`, because the clients, the kit, a form's challenge, a form's file and a download run on them, and
740
+ `TransformStream`, because the clients, the kit, a challenge, a file field and a download run on them, and
741
741
  turns navigation off, so a download's link click leaves the page for the next test. A DOM registration of your own
742
- keeps the same classes of Bun, or a form with a file fails in the kit. Run the tests with `bun test --isolate`, which
742
+ keeps the same classes of Bun, or an input with a file fails in the kit. Run the tests with `bun test --isolate`, which
743
743
  gives each file its own globals.
744
744
  - `await type('Title', 'report')` types into the control whose label (or `aria-label`) is `Title` as React reads a
745
745
  person typing; a select takes the option's value. `controlByLabel(label)` answers the control, and
@@ -73,7 +73,8 @@ export default serve(contract, { database: null })
73
73
  - `defineApp(vocabulary)` declares no tables, so `serve` takes `database: null`, and only then. A handler has no
74
74
  `ctx.db`, `ctx.sql` or `unscoped` (a type error that names `defineApp(vocabulary, tables)`); it answers from the
75
75
  caller's grants (`ctx.within.values`, `ctx.principal`), the member directory, or a connector.
76
- - Forms, jobs and file collections keep their rows in tables, so an application gets them with its first table.
76
+ - Jobs, queue tables, file columns and public procedures that record a row all need tables, so an application gets
77
+ them with its first table.
77
78
 
78
79
  ```ts fabrika.config.ts
79
80
  import { compileContract, schemaFromContract } from '@fabrika/app/contract'
@@ -149,7 +149,7 @@ INSERT INTO priorities (code, label, rank) VALUES ('low', 'Low', 1), ('normal',
149
149
  - Codegen types: `date` is a `YYYY-MM-DD` string; `timestamptz` reads a `Date` and takes a `Date` or a strict ISO 8601 string with `Z` or an offset;
150
150
  `int8` and `numeric` are strings; an enum is its literal union. JSON, `bytea`, arrays and domains are refused.
151
151
  - Plain SQL of the application's own schema only. Refused: another schema (`public.`, `app_…`), any `fabrika_*`
152
- object (except a file collection's table, `references/files.md`), `GRANT`, roles, `CREATE EXTENSION`, `COPY`,
152
+ object (a file column references `fabrika_files (id)`, `references/files.md`), `GRANT`, roles, `CREATE EXTENSION`, `COPY`,
153
153
  `SET`, transaction control, `SECURITY DEFINER`, a domain, `serial` and sequences (use `GENERATED ALWAYS AS IDENTITY`),
154
154
  a function overload, and calls outside the reviewed built-ins.
155
155
  - Never edit, rename or delete a committed migration; add a new one. Change a schema by expand, switch, contract, and
@@ -223,6 +223,13 @@ export const tables = defineTables(vocabulary, columns)({
223
223
  insert: ['row_count'],
224
224
  createdBy: 'created_by',
225
225
  },
226
+ task_attachments: {
227
+ scope: { child: 'tasks' },
228
+ actions: { read: 'tasks.read', insert: 'tasks.write', delete: 'tasks.write' },
229
+ insert: ['file'],
230
+ files: { file: 'attachments' },
231
+ createdBy: 'created_by',
232
+ },
226
233
  priorities: {
227
234
  scope: { reference: 'the priority codes; a migration changes them' },
228
235
  actions: { read: 'tasks.read' },
@@ -244,7 +251,9 @@ export const tables = defineTables(vocabulary, columns)({
244
251
  - The key (`id`), the container column (`<container>_id`), a child's column (`<parent dimension>_id`) and the chain
245
252
  are derived. State `in: { column }` or `column` only off that convention.
246
253
  - `actions` maps `read`, `insert`, `update` and `delete` to vocabulary actions. `insert` and `update` list the columns
247
- that those actions write; nothing else is writable.
254
+ that those actions write; nothing else is writable. Each operation names one action or a list, any of which allows
255
+ it: a table that staff and a public procedure both read names both (`read: ['crm.read', 'catalog.read']`). Never let
256
+ a role hold a public procedure's action to share a table: list the actions.
248
257
  - A dimension or child table may also list `appRead: ['<action>', …]`, actions besides `read` with which an `app: true`
249
258
  endpoint lists every row, read-only (an export, a bulk send), with the columns of that action, and
250
259
  `appCount: ['<action>', …]`, actions with which it only counts rows (`count({ where })`) and reads none, no aggregate
@@ -252,6 +261,19 @@ export const tables = defineTables(vocabulary, columns)({
252
261
  - `columns: { <column>: { read: '<action>' } }` hides a column from every capability of another action, in the data
253
262
  and in the types. `columns: { <column>: { write: '<action>' } }` lets only that action update the column (and that
254
263
  action updates nothing else); the column is then not in `update`.
264
+ - `files: { <column>: '<kind>' }` makes a column a file column of a kind the contract declares (`c.files`):
265
+ `task_attachments.file` holds the files of a task (`references/files.md`). Its migration writes
266
+ `<column> UUID REFERENCES fabrika_files (id)` and an index the column leads; the checker's `file-column-migration`
267
+ refuses a file column without either.
268
+ - `queue: { attempts: '<column>', due: '<column>', maxAttempts? }` makes the table a queue a job claims
269
+ (`references/jobs.md`). It needs `version` and an `update` action; `<due>` is a timestamp with a default of `now()`
270
+ that allows `null`, `<attempts>` an integer with a default of 0. The data layer writes both; an insert may set
271
+ `<due>`, never `<attempts>`.
272
+ - `insert(values, { onConflict: { columns, do: 'nothing' | 'update' } })` writes idempotently (a webhook delivery, an
273
+ unsubscribe): `columns` must be a unique index the migrations create (`UNIQUE`, a primary key, or a
274
+ `CREATE UNIQUE INDEX` without `WHERE`), which the checker's `on-conflict-index` verifies. `'nothing'` needs the
275
+ insert action and answers `{ inserted: false }` on a conflict; `'update'` also needs the update action and writes
276
+ only the table's update columns.
255
277
  - A column has one write action. A state that two actions change is two columns: in `references/public-intake.md` the
256
278
  link holder's `candidates.verify` sets `verified_at`, HR's `candidates.review` sets `status`, and `logic.ts` derives
257
279
  the stage HR sees from both (`stageOf`).
@@ -9,7 +9,10 @@ longer writes. Commit them; never edit, format or import them (`generated-test-h
9
9
  - `src/__tests__/endpoint-access.test.ts`: every `on` and `in` endpoint refuses another scope's object (404 or 403);
10
10
  every `on` and `over` endpoint, and every `in` endpoint that declares `sql`, answers nothing of another scope; a
11
11
  caller with only an endpoint's write action, plus what IAM requires of it (below), reaches its own object (200). An
12
- `app` or `creates` endpoint refuses a holder of its action in one scope only (403) and answers a global holder.
12
+ `app` or `creates` endpoint refuses a holder of its action in one scope only (403) and answers a global holder. An
13
+ endpoint of several `capabilities` adds a denied case per capability. A public procedure answers anyone without a
14
+ credential, or, behind a challenge, refuses a caller without a solved one. A file input is sent as a generated file
15
+ of its kind; a `GET` procedure's input goes in its URL.
13
16
  - Their kit stands in for IAM, as `testKit` does (below): it creates a scope, assigns or invites a member and issues a
14
17
  link under IAM's rules for the caller's grants. A repeated invitation answers the open one, `resend` restarts its
15
18
  7 days, and an organization's address is refused for an external role (`internal_mailbox`). So the caller of an endpoint with the dimension's `members.manage`
@@ -34,8 +37,8 @@ export const members = { 'api.tasks.assign': ['assignee'] }
34
37
  ```
35
38
 
36
39
  - `export const fixtures = { '<endpoint id>': { … } }` is an input for an endpoint whose schema the scaffold cannot
37
- sample, such as a `regex` or a webhook's bind:
38
- `{ 'webhooks.github.bind': { scope: 'acme', account: 'installation:1' } }`. The checker names each missing one,
40
+ sample, such as a `regex` (a webhook's account):
41
+ `{ 'api.integrations.connectGithub': { orgId: 'acme', account: 'installation:1' } }`. The checker names each missing one,
39
42
  and one of an endpoint that does not exist (`endpoint-fixture`). The access form's `from` field is
40
43
  replaced by the seeded scope.
41
44
  - `members['<endpoint id>']` lists the input fields that name a picked person the handler checks with
@@ -284,7 +287,7 @@ if (postgres !== null) {
284
287
  - `kit.runJob('<job>')` runs a cron job once and answers `{ status: 'succeeded', attempts }`. A job's `ctx.links`
285
288
  reaches the kit's stand-in IAM under the job's own key, so a job that declares `issues` issues and revokes links in a
286
289
  plain `testKit` and under `fabrika app dev`, as IAM allows it: for its declared mount only.
287
- - A form or a mutation that declares `then` starts its job in the background of the call. `kit.drain()` waits for
290
+ - A mutation that kicks (`kicks`, `ctx.kick`) starts its job in the background of the call. `kit.drain()` waits for
288
291
  those runs, and `kit.runJob` first waits for them; every generated access case drains before it answers. The kicked
289
292
  run may start at once, so arm a fault before the call, never between the call and the run.
290
293
  - `kit.mail` is the stand-in for every job's mail transport (`MemoryEmailSender` of `@fabrika/app/testing`), so a test
@@ -297,7 +300,7 @@ if (postgres !== null) {
297
300
  without it the application does not boot. With `scan: true`, an upload stays `quarantined` until
298
301
  `kit.runJob(FILE_SCAN_JOB)` (`@fabrika/app/implement`) runs with a `scanner` passed to `testKit`: a `FileScanner`
299
302
  stand-in, or the real ClamAV one, `clamscanScanner()` of `@fabrika/app/bun`.
300
- - A form's challenge token is single use: a second submission with the same token answers 400 `challenge_failed`,
303
+ - A challenge token is single use: a second submission with the same token answers 400 `challenge_failed`,
301
304
  also when the test's `challengeFetch` answers success. Send a fresh token with each submission
302
305
  (`crypto.randomUUID()`). `passingChallenge` (`@fabrika/app/testing`) is a `challengeFetch` that passes every token;
303
306
  a stand-in of your own receives the check (`hostname`, `action`) a passing answer must confirm as its second argument.
@@ -3,9 +3,10 @@
3
3
  Each of these lets someone in who is not a signed-in member of the scope. Ask the human before you add one, and say
4
4
  what it lets that caller do.
5
5
 
6
- ## Webhooks (GitHub)
6
+ ## Webhooks
7
7
 
8
8
  ```ts src/github/endpoints.ts
9
+ import { WebhookSecret } from '@fabrika/app/contract'
9
10
  import { z } from 'zod/v4'
10
11
  import { app } from '../app-kit'
11
12
 
@@ -27,19 +28,71 @@ export const github = app.webhook('/hooks/github', {
27
28
  name: input.repository.full_name,
28
29
  })
29
30
  })
31
+
32
+ const Connect = z.strictObject({
33
+ orgId: z.string(),
34
+ account: z.string().regex(/^(installation|repository):\d+$/),
35
+ })
36
+
37
+ /** Binds an org to a GitHub account: answers the secret and the URL once. */
38
+ export const integrations = app.group({ callers: ['human'] }, (r) => ({
39
+ connectGithub: r.mutation({
40
+ input: Connect,
41
+ output: WebhookSecret,
42
+ access: { action: 'members.manage', on: 'org', from: 'orgId' },
43
+ binds: 'github',
44
+ })
45
+ .handle(({ input, ctx }) =>
46
+ ctx.webhooks('github').bind(ctx.target, input.account)
47
+ ),
48
+ }))
30
49
  ```
31
50
 
32
- - Mount it by name in the contract: `github,` beside `api`. It answers `POST /hooks/github/:bindingId`.
33
- - `boundTo` is a dimension with `members`. A holder of its `members.manage` binds an org to a GitHub account with the
34
- generated `POST /api/webhooks.github.bind` (`{ scope, account: 'installation:<id>' | 'repository:<id>' }`), which
35
- answers the secret and the URL once. The generated tests need a fixture for it (`references/tests.md`).
51
+ - Mount it by name in the contract: `github,` beside `api`, and the group in `api`. It answers
52
+ `POST /hooks/github/:bindingId`.
53
+ - A binding is made by a procedure that declares `binds: '<webhook>'` and calls `ctx.webhooks('<webhook>').bind(ref,
54
+ account)` with a capability on the bound value (`ctx.target`, an org here), in its transaction; `unbind(ref, id)`
55
+ removes one. A GitHub account is `installation:<id>` or `repository:<id>`. The answer carries the secret and the URL
56
+ once. The generated tests need a fixture for its input (`references/tests.md`).
36
57
  - The framework checks the signature, the account and the input (undeclared fields are dropped).
37
58
  `ctx.refs['<action>']` is a `Ref` of the bound org for each declared action.
38
- - It records the delivery key (a digest of the binding and the body) before the handler runs, and answers a body it
39
- already ran in the last 30 days without running it again. A handler that throws commits nothing, and the key is
40
- forgotten, so GitHub's redelivery runs it again from nothing. A process that dies after the record and before the
41
- commit keeps the key and loses the delivery. So make every effect idempotent, and key an external effect after the
42
- data work on `ctx.delivery.key`; never rely on exactly one run.
59
+ - It claims the delivery key (a digest of the binding and the body) under a lease before the handler runs, marks it
60
+ complete in the handler's transaction, and answers a body it already ran in the last 30 days without running it
61
+ again. A handler that throws commits nothing, and a process that dies before the commit leaves the lease to expire,
62
+ so GitHub's redelivery runs it again from nothing. So make every effect idempotent, and key an external effect after
63
+ the data work on `ctx.delivery.key`; never rely on exactly one run.
64
+ - An idempotent write needs no `sql`: `insert(values, { onConflict: { columns: ['<unique column>'], do: 'nothing' } })`
65
+ (or `do: 'update'`) on a unique index (`references/table-and-migration.md`).
66
+ - **The verifier.** `scheme` is a preset or a shape the framework computes against the binding's secret; never verify
67
+ a signature yourself:
68
+
69
+ | `scheme` | For |
70
+ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
71
+ | `'github-sha256'` | GitHub; it also checks the account |
72
+ | `'standard-webhooks'` | senders of the Standard Webhooks spec |
73
+ | `'stripe'` | Stripe |
74
+ | `'slack'` | Slack events, slash commands and interactivity (JSON or a form) |
75
+ | `'twilio'` | Twilio callbacks (a form, or JSON); signs the public URL |
76
+ | `{ hmac: { header, algorithm, encoding, prefix?, timestamp?, signed: 'body' \| 'timestamp.body', id? } }` | most other HMAC senders (Shopify, …) |
77
+ | `{ basic: true }` | a sender that sends the secret as a Basic password (an inbound mail API) |
78
+ | `{ bearer: true }` | a sender that sends the secret as `Authorization: Bearer <secret>` |
79
+
80
+ A signed timestamp must be within five minutes. Slack's URL check (`url_verification`) is answered for you. A form's
81
+ fields are the input, as strings or the scalars the schema reads.
82
+ - **A secret the provider issues.** Stripe, Slack and Twilio show their signing secret only after you register the
83
+ URL. Bind first, show the URL, then take the provider's secret in a second mutation with the same `binds` and call
84
+ `ctx.webhooks('<webhook>').setSecret(capability, id, secret)`; until then deliveries are refused (401).
85
+ `setSecret` answers `false` when the value has no binding of that id. A `basic` or `bearer` secret never contains
86
+ `px_`: the proxy removes a platform key from every request.
87
+ - **`boundTo: 'app'`** gives one binding for the whole application, for a provider with one endpoint per account
88
+ (Stripe). Its handler holds its `actions` application-wide, as a job does: `ctx.db.app('<action>')`. The procedure
89
+ that binds it takes an `app` capability: `access: { action, app: true }`, `binds`, and
90
+ `ctx.webhooks('<webhook>').bind(ctx.app, account)`.
91
+ - `limits: { maxBytes?, timeoutMs? }`: the body is 1 MiB by default and at most 48 MiB; the handler runs 10 s by
92
+ default, at most 60 s. A webhook may declare `kicks`, `signs`, `egress`, `sql` and `unscoped`, and `ctx.files.put`
93
+ stores a file a provider sends (an inbound e-mail's attachment) for a file column: raise `limits.maxBytes` to the
94
+ mail you accept.
95
+ - Not covered: a provider that validates its URL with a `GET`, or that needs a custom answer body.
43
96
 
44
97
  ## Share links
45
98
 
@@ -97,8 +150,8 @@ export const shared = app.link('/s', {
97
150
  that should work once records its use in the row: a `verify` sets `verified_at` once under `getForUpdate`, and a
98
151
  second call answers the same. IAM counts no uses.
99
152
  - A cron job may issue links too (`issues` on `app.cron`, `references/jobs.md`): a visitor without an account gets a
100
- link by e-mail from a job that drains a form's inbox. `references/public-intake.md` is that recipe end to end, with
101
- the files a link holder uploads (`link: true`, or `link: ['<mount>']` for some mounts only, `references/files.md`).
153
+ link by e-mail from a job that claims a public procedure's rows. `references/public-intake.md` is that recipe end to
154
+ end, with the files a link holder uploads through the link's own procedures (`references/files.md`).
102
155
 
103
156
  ## Connectors: another application's data
104
157
 
package/src/bun.ts CHANGED
@@ -251,7 +251,7 @@ const STORAGE_VARIABLES = {
251
251
  } as const
252
252
 
253
253
  /**
254
- * The file collections' object storage (Zerops object storage, path-style), when the platform wrote its variables;
254
+ * The stored files' object storage (Zerops object storage, path-style), when the platform wrote its variables;
255
255
  * a partial set stops the boot. The credentials are never quoted in a message.
256
256
  */
257
257
  export function openFileStorage(framework: Readonly<Record<string, string>>): FileObjectStore | undefined {
@@ -394,6 +394,7 @@ async function startContractServer(
394
394
  workloadTokens,
395
395
  requireProxySecret: true,
396
396
  localEmailProviders: LOCAL_EMAIL_PROVIDERS,
397
+ connections: databaseConnections(environment.framework[DATABASE_CONNECTIONS]) ?? DEFAULT_DATABASE_CONNECTIONS,
397
398
  ...(operationsFetch === undefined ? {} : { operationsFetch }),
398
399
  })
399
400
  // The work a request leaves running (a failure's report, a kicked run) is drained, within a bound, before the database closes.
@@ -464,7 +465,7 @@ export async function runContractJob(
464
465
  workloadTokens,
465
466
  requireProxySecret: true,
466
467
  localEmailProviders: LOCAL_EMAIL_PROVIDERS,
467
- // ClamAV on PATH, as the provider's build installs it for a `scan: true` collection; missing, the scan fails closed.
468
+ // ClamAV on PATH, as the provider's build installs it for a `scan: true` file kind; missing, the scan fails closed.
468
469
  scanner: clamscanScanner(),
469
470
  })
470
471
  .jobs.run(name, options)