@fabrika/app 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,14 +93,15 @@
93
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
94
  },
95
95
  "dependencies": {
96
- "@fabrika/auth": "0.7.0",
97
- "@fabrika/auth-core": "0.7.0",
98
- "@fabrika/db": "0.7.0",
99
- "@fabrika/email": "0.7.0",
100
- "@fabrika/jwks": "0.7.0",
101
- "@fabrika/platform": "0.7.0",
102
- "@fabrika/platform-node": "0.7.0",
103
- "@fabrika/proxy-core": "0.7.0",
96
+ "@fabrika/auth": "0.8.1",
97
+ "@fabrika/auth-core": "0.8.1",
98
+ "@fabrika/db": "0.8.1",
99
+ "@fabrika/email": "0.8.1",
100
+ "@fabrika/jwks": "0.8.1",
101
+ "@fabrika/platform": "0.8.1",
102
+ "@fabrika/platform-node": "0.8.1",
103
+ "@fabrika/proxy-core": "0.8.1",
104
+ "altcha-lib": "2.6.0",
104
105
  "jose": "^5.9.0"
105
106
  },
106
107
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.7.0
3
+ version: 0.8.1
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.7.0",
23
- "@fabrika/db": "0.7.0",
24
- "@fabrika/provider-zerops": "0.7.0",
25
- "@fabrika/proxy-core": "0.7.0",
26
- "@fabrika/ui": "0.7.0",
22
+ "@fabrika/app": "0.8.1",
23
+ "@fabrika/db": "0.8.1",
24
+ "@fabrika/provider-zerops": "0.8.1",
25
+ "@fabrika/proxy-core": "0.8.1",
26
+ "@fabrika/ui": "0.8.1",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.7.0",
34
- "@fabrika/cli": "0.7.0",
35
- "@fabrika/platform": "0.7.0",
33
+ "@fabrika/check": "0.8.1",
34
+ "@fabrika/cli": "0.8.1",
35
+ "@fabrika/platform": "0.8.1",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -160,16 +160,20 @@ export default serve(contract, { database: scoped(tables) })
160
160
  stylesheet origin in `styleSrc` and its font origin in `fontSrc`; font files under the SPA's `dir` need neither. No path under `/__fabrika`.
161
161
  - A public procedure's abuse control is its `admission`: `rate: { perClient, perEndpoint }` (per minute, shared by
162
162
  every instance; 10 and 100 by default for a public mutation; 429 past either) and
163
- `challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`. `honeypot(value)`
164
- of `@fabrika/app` is true for a filled field the page hides: answer the same and write nothing. A challenge needs the
165
- input field `challengeToken: z.string()`, which the page fills from the Turnstile widget rendered with `action` set
166
- to the endpoint id with `_` for `.` (`api_visitors_register`), or to the challenge's own `action: 'register'` (at most
167
- 32 letters, digits, `_` and `-`, one per procedure); the framework verifies it on the server (a failed or replayed
168
- token 400 `challenge_failed`, an unreachable provider 503, no row either way) and adds Turnstile's origin to the CSP. `secret` names an
169
- application variable the human sets, never the value; without it the application does not boot. `siteKey` names
170
- the variable with the widget's public site key, which every SPA mount serves its pages (below). On Zerops every
171
- visitor still shares one client address (the balancer), so `perClient` acts as a second per-endpoint limit: raise
172
- it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
163
+ `challenge: { provider: 'altcha', secret: 'ALTCHA_HMAC_KEY' }`. `honeypot(value)` of `@fabrika/app` is true for a
164
+ filled field the page hides: answer the same and write nothing. A challenge needs the input field
165
+ `challengeToken: z.string()`. An ALTCHA challenge is a proof of work the application issues at
166
+ `GET /__fabrika/challenge/<action>` (`challengeUrl(api.<procedure>)` of `@fabrika/app/client`; the action is the
167
+ endpoint id with `_` for `.`, `api_visitors_register`, or the challenge's own `action: 'register'`, at most 32
168
+ letters, digits, `_` and `-`, one per procedure) and verifies itself, asking no one: the page renders `Challenge` of
169
+ `@fabrika/ui/challenge`, which fills the field. A failed, expired or replayed one is 400 `challenge_failed`, no row.
170
+ `secret` names the application variable holding the HMAC key, which the human sets to a long random value
171
+ (32 characters at least), never the value itself; without it, or with a shorter one, the application does not boot. `cost` and `maxAgeMs` set the work and the lifetime
172
+ (`references/public-intake.md`). `provider: 'turnstile'` (with `siteKey`, verified at Cloudflare, an unreachable
173
+ provider 503) is the alternative when the human asks for it; it adds Cloudflare's origin to the CSP and serves the
174
+ site key to every SPA mount's pages (below). On Zerops every visitor still shares one client address (the balancer),
175
+ so `perClient` acts as a second per-endpoint limit: raise it, and use the challenge.
176
+ `references/public-intake.md` builds a whole public intake on it.
173
177
  - `defineApp(vocabulary, tables, { imports?, tokens? })`: `imports` names other applications' exports
174
178
  (`references/webhooks-links-connectors.md`), `tokens` the purposes of signed tokens (`references/endpoint.md`).
175
179
  - `c.rpc('/api/v1', groups, { compatibility: 'backward' })` promises a mount's callers a stable API
@@ -241,9 +245,9 @@ zerops:
241
245
  - bun run build:web
242
246
  ```
243
247
 
244
- The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its start script
245
- runs `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server: the
246
- application is not ready until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
248
+ The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its init commands
249
+ run `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server starts: a
250
+ version does not go live until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
247
251
 
248
252
  ## The SPA (added when the application has one)
249
253
 
@@ -59,10 +59,13 @@ export default seed
59
59
  - `dev/dev.env` holds the application's variables for `app dev`, one `NAME=value` per line (`#` comments, quotes
60
60
  optional). They win over the shell's; a `FABRIKA_*` name is refused. Commit it: it holds stand-in values, never a
61
61
  real secret.
62
- - A public procedure's challenge passes in `app dev`: the stand-in answers Turnstile's siteverify for any token. Set the
63
- challenge's `secret` variable to any value in `dev/dev.env`, and send a fresh `challengeToken` with each submission, as a test
64
- does: a token is single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test
65
- key `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
62
+ - A public procedure's ALTCHA challenge works in `app dev` as deployed, only cheaper: set the challenge's `secret`
63
+ variable in `dev/dev.env` to any value of at least 32 characters, as deployed, and the page's widget solves the challenge `app dev` issues in moments,
64
+ whatever the contract's `cost`. A solved challenge passes once, as deployed; the widget fetches a new one.
65
+ - A Turnstile challenge passes in `app dev`: the stand-in answers Turnstile's siteverify for any token. Set its
66
+ `secret` variable to any value, and send a fresh `challengeToken` with each submission, as a test does: a token is
67
+ single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test key
68
+ `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
66
69
  submission; a deployed application records it like any other, so there it passes once.
67
70
  - A job's e-mail goes to the kit's mail stand-in (`dev.mail`, as `kit.mail` in a test): nothing is delivered, and no
68
71
  mail variable is needed. The mail view lists each message, newest first, beside IAM's invitations: the recipient,
@@ -80,8 +83,7 @@ export default seed
80
83
 
81
84
  ```text dev/dev.env
82
85
  # Stand-in values for fabrika app dev.
83
- TURNSTILE_SECRET=dev
84
- TURNSTILE_SITE_KEY=1x00000000000000000000AA
86
+ ALTCHA_HMAC_KEY=dev-altcha-hmac-key-of-32-characters
85
87
  ARCHIVE_URL=https://outbox.invalid/archive
86
88
  ARCHIVE_KEY=dev
87
89
  ```
@@ -558,13 +558,13 @@ should hold. The generated tests skip the other-scope leak case for such an endp
558
558
 
559
559
  Every procedure takes `admission` (who may send it, how often) and `limits` (how large, how long):
560
560
 
561
- | Option | Credentialed and link procedures | Public procedures (`references/public-intake.md`) |
562
- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
563
- | `admission.rate` (calls per minute) | `perCaller`, `perEndpoint`; none by default | `perClient`, `perEndpoint`, `bytesPerHour`; a mutation 10 and 100 |
564
- | `admission.origins` | `'same-origin'` (default) or `'listed'` | `'same-origin'`, `'listed'` or `'any'` |
565
- | `admission.challenge` | — | `{ provider: 'turnstile', secret, siteKey, action? }` |
566
- | `limits.maxBytes` | 64 KiB, at most 1 MiB; with file fields 64 KiB plus each file at its kind's `maxBytes`, at most 48 MiB | the same |
567
- | `limits.timeoutMs` | 10 s, at most 60 s | the same |
561
+ | Option | Credentialed and link procedures | Public procedures (`references/public-intake.md`) |
562
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
563
+ | `admission.rate` (calls per minute) | `perCaller`, `perEndpoint`; none by default | `perClient`, `perEndpoint`, `bytesPerHour`; a mutation 10 and 100 |
564
+ | `admission.origins` | `'same-origin'` (default) or `'listed'` | `'same-origin'`, `'listed'` or `'any'` |
565
+ | `admission.challenge` | none | `{ provider: 'altcha', secret, action?, cost?, maxAgeMs? }`, or `{ provider: 'turnstile', secret, siteKey, action? }` |
566
+ | `limits.maxBytes` | 64 KiB, at most 1 MiB; with file fields 64 KiB plus each file at its kind's `maxBytes`, at most 48 MiB | the same |
567
+ | `limits.timeoutMs` | 10 s, at most 60 s | the same |
568
568
 
569
569
  - A declared `rate` merges into the default; `null` turns one bucket off. Past a bucket the answer is 429.
570
570
  - Every bucket is charged before the body is read and stays charged whatever the handler does. A bucket is a database
@@ -572,6 +572,8 @@ Every procedure takes `admission` (who may send it, how often) and `limits` (how
572
572
  - The body must arrive within `max(30 s, maxBytes at 16 KiB/s)`: a stalled body is 408. The handler's time starts
573
573
  after the last byte, so a slow upload still commits.
574
574
  - The approver sees every effective value. A higher bucket, a dropped challenge or a longer time limit widens.
575
+ - An `altcha` challenge adds the framework's own `GET /__fabrika/challenge/<action>`, which issues it from the
576
+ procedure's origins under buckets of its own (30 per client and 600 in all, at least twice the procedure's). The approver sees that endpoint too.
575
577
 
576
578
  ## Another origin: `admission.origins: 'listed'`
577
579
 
@@ -421,11 +421,7 @@ export const visitors = app.group({}, (r) => ({
421
421
  access: { public: true },
422
422
  actions: ['candidates.register'],
423
423
  admission: {
424
- challenge: {
425
- provider: 'turnstile',
426
- secret: 'TURNSTILE_SECRET',
427
- siteKey: 'TURNSTILE_SITE_KEY',
428
- },
424
+ challenge: { provider: 'altcha', secret: 'ALTCHA_HMAC_KEY' },
429
425
  rate: { perClient: 100, perEndpoint: 100, bytesPerHour: 536870912 },
430
426
  },
431
427
  kicks: ['intake'],
@@ -479,14 +475,26 @@ export const visitors = app.group({}, (r) => ({
479
475
  - **`ctx.client`** is `{ ip, userAgent }` as the proxy saw them, for consent evidence in a column of the row; `ip` is
480
476
  `null` on Zerops. Never decide on either.
481
477
  - **Its abuse control.** `honeypot(input.website)` is true when the field the page hides is filled: answer the same
482
- and write no row. `admission.challenge` makes the framework verify the Turnstile token in the input field
483
- `challengeToken` before the handler runs (a failed or replayed token 400 `challenge_failed`, an unreachable provider 503, no row either way) and adds
484
- Turnstile's origin to the CSP. `TURNSTILE_SECRET` is an application variable the human sets; without it the
485
- application does not boot. `siteKey` names the variable with the widget's public site key, which every SPA mount
486
- serves its pages; without it the application does not boot either. Another public value a page needs per
487
- environment (a link target) goes in the SPA mount's `browserConfig` list: `c.spa('/', { …, browserConfig: ['SIGNUP_URL'] })`. Both
488
- reach the page in `<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees each
489
- name. Never name a secret there: the contract refuses a variable that is both.
478
+ and write no row. `admission.challenge: { provider: 'altcha', secret: 'ALTCHA_HMAC_KEY' }` is a proof of work the
479
+ application issues and verifies itself: the framework serves `GET /__fabrika/challenge/<action>` (the endpoint id
480
+ with `_` for `.`, `api_visitors_register`, or the challenge's own `action`), from the procedure's origins with a
481
+ buckets of its own (30 per client and 600 in all per minute, and at least twice the procedure's), and verifies the solved challenge in the input field
482
+ `challengeToken` before the handler runs: a failed, expired or replayed one, or one issued for another host or
483
+ procedure, is 400 `challenge_failed`, no row. No request leaves the installation, no third party sees the visitor,
484
+ and there is no site key. `ALTCHA_HMAC_KEY` is an application variable the human sets to a long random value
485
+ (`openssl rand -base64 32`); without it, or with one under 32 characters, the application does not boot: every
486
+ challenge publishes a signature a short key could be guessed from, and the key forges every challenge. `cost`
487
+ (PBKDF2 iterations per attempt, 5000 by default, 1000 to 18000) and `maxAgeMs` (how long a challenge may be solved
488
+ and sent, ten minutes by default, a minute to an hour) are the contract's, and the approver sees both. A browser
489
+ makes up to 10000 attempts, about 5000 on average, a few seconds on a phone at the default; the build refuses a
490
+ `cost` a slow phone would not solve within the widget's 90 s or half of `maxAgeMs`. The CSP gains `worker-src 'self' blob:`, where the widget solves.
491
+ - **Turnstile instead.** `challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`
492
+ verifies Cloudflare's widget at Cloudflare (an unreachable provider is 503), adds Cloudflare's origin to the CSP, and
493
+ serves the site key to every SPA mount's pages. Use it only when the human asks for it: it needs a Cloudflare
494
+ account and a widget per hostname, and sends every visitor to a third party. Another public value a page needs per
495
+ environment (a link target) goes in the SPA mount's `browserConfig` list: `c.spa('/', { …, browserConfig: ['SIGNUP_URL'] })`.
496
+ It reaches the page in `<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees
497
+ each name. Never name a secret there: the contract refuses a variable that is both.
490
498
  `admission.rate: { perClient, perEndpoint }` (per minute, 10 and 100 by default for a public mutation) answers 429
491
499
  past either limit; `null` turns one off. A public query counts nothing unless it declares a bucket, since each
492
500
  bucket is a database write per call: a page of twenty images costs no write. All public procedures of an instance
@@ -496,14 +504,16 @@ export const visitors = app.group({}, (r) => ({
496
504
  within it.
497
505
  - **The order of a public request**, each refusal with its answer: the origin rule (403), the in-flight share (503),
498
506
  every bucket (429), the bounded body and the strict input with its signed tokens (413, 400), the workload token (503
499
- while the platform's identity service is down), the challenge (400, 503 when the provider is unreachable), the staged file parts, then the handler, the
507
+ while the platform's identity service is down), the challenge (400; Turnstile's 503 when Cloudflare is unreachable), the staged file parts, then the handler, the
500
508
  commit and the kicks.
501
509
  - **A form on someone else's page.** A page served from this application's own origin needs nothing. A partner's page
502
510
  or a separate marketing site needs `admission.origins`: `'listed'` for the origins an operator lists for the
503
511
  environment in Control, `'any'` for any site (an embeddable widget). Keep the honeypot, the challenge and the rate
504
512
  limit: `'any'` invites every site. Such a page is not yours to change in step with the application, so give it a URL
505
513
  and a widget action that do not follow your group names: `http: { path: '/register' }` and
506
- `challenge: { …, action: 'register' }`. The path lies outside every mount's prefix (the root SPA's excepted).
514
+ `challenge: { …, action: 'register' }`, so its challenge is at `/__fabrika/challenge/register`. The path lies outside
515
+ every mount's prefix (the root SPA's excepted). That page bundles the `altcha` widget itself, points its `challenge`
516
+ attribute at the application's origin and that path, and needs `worker-src blob:` in its own CSP.
507
517
  - **Zerops shares one client address.** Until the platform reads the visitor's address behind the Zerops balancer,
508
518
  every visitor counts as one client there, so `perClient` acts as a second per-endpoint limit: set it to the expected
509
519
  peak, never lower it to stop a flood. The challenge is the defense that does not depend on the address.
@@ -1064,23 +1074,6 @@ export const attach = async (file: File): Promise<string> => {
1064
1074
  }
1065
1075
  ```
1066
1076
 
1067
- The public page reads its site key from the config the framework serves it, so one build serves every environment.
1068
-
1069
- ```ts web/site/config.ts
1070
- import { z } from 'zod/v4'
1071
-
1072
- const SiteConfig = z.object({
1073
- public: z.object({ TURNSTILE_SITE_KEY: z.string() }),
1074
- })
1075
-
1076
- /** The Turnstile widget's site key, or `null` when the config is unreadable. */
1077
- export const siteKey = async (): Promise<string | null> => {
1078
- const response = await fetch('/__fabrika-config.json')
1079
- const config = SiteConfig.safeParse(response.ok ? await response.json() : null)
1080
- return config.success ? config.data.public.TURNSTILE_SITE_KEY : null
1081
- }
1082
- ```
1083
-
1084
1077
  The public page calls the public procedure through the typed client, like any other. An input with a file is sent
1085
1078
  as a multipart body, and one without a file as JSON.
1086
1079
 
@@ -1127,8 +1120,57 @@ export const register = async (
1127
1120
  }
1128
1121
  ```
1129
1122
 
1130
- - The page renders the Turnstile widget with the site key `siteKey()` reads and `action` set to the procedure's
1131
- endpoint id with `_` for `.` (`api_visitors_register`), and passes the widget's token. Say "check your inbox" on success whatever the address was.
1123
+ The page renders the challenge with `@fabrika/ui/challenge`, which bundles the `altcha` widget with the page (add
1124
+ `altcha` to the dependencies: it is the kit's optional peer). `challengeUrl` gives the address the widget fetches the
1125
+ procedure's challenge from; nothing else is configured per environment.
1126
+
1127
+ ```tsx web/site/register-form.tsx
1128
+ import { challengeUrl, createClient } from '@fabrika/app/client'
1129
+ import { Challenge } from '@fabrika/ui/challenge'
1130
+ import { useState } from 'react'
1131
+ import { contract } from '../../fabrika.contract'
1132
+ import { register } from './register'
1133
+
1134
+ const api = createClient(contract, 'api')
1135
+
1136
+ export function RegisterForm() {
1137
+ const [token, setToken] = useState<string | null>(null)
1138
+ // A challenge passes once: a new key renders a fresh one after a refusal.
1139
+ const [attempt, setAttempt] = useState(0)
1140
+ const [sent, setSent] = useState(false)
1141
+ if (sent) return <p>Check your inbox.</p>
1142
+ return (
1143
+ <form
1144
+ onSubmit={async (event) => {
1145
+ event.preventDefault()
1146
+ if (token === null) return
1147
+ if (await register(event.currentTarget, token)) setSent(true)
1148
+ else setAttempt((n) => n + 1)
1149
+ }}
1150
+ >
1151
+ <input name="email" type="email" required aria-label="E-mail" />
1152
+ <input name="full_name" required aria-label="Full name" />
1153
+ <input name="website" tabIndex={-1} autoComplete="off" hidden />
1154
+ <input
1155
+ name="photo"
1156
+ type="file"
1157
+ accept="image/*"
1158
+ capture="environment"
1159
+ aria-label="Photo"
1160
+ />
1161
+ <Challenge
1162
+ key={attempt}
1163
+ url={challengeUrl(api.visitors.register)}
1164
+ onToken={setToken}
1165
+ />
1166
+ <button type="submit" disabled={token === null}>Register</button>
1167
+ </form>
1168
+ )
1169
+ }
1170
+ ```
1171
+
1172
+ - Say "check your inbox" on success whatever the address was. The widget reads its texts in English; translate them
1173
+ with `strings` (`<Challenge strings={{ label: 'Nejsem robot', verified: 'Ověřeno' }} … />`).
1132
1174
  - The resend form calls `api.visitors.resend({ email })`. It answers the same for an unknown, a verified and a
1133
1175
  rate-limited address; the job decides.
1134
1176
  - Build each SPA into its `dir` (`dist/site`, `dist/portal`, `dist/hr`): one `Bun.build` per `index.html` in
@@ -1752,9 +1794,11 @@ export const testMails = app.cron('* * * * *', {
1752
1794
  })` and calls `createClient(contract, 'portal', { fetch: kit.fetch, key: holder.key ?? '' })`.
1753
1795
  - The job asks IAM to issue and revoke links, and the kit's own IAM answers only the credentials of its callers, not a
1754
1796
  job's key, so `kit.runJob('intake')` needs `testKit(app, { membership })` with an `IamMembershipRpc` of your own that
1755
- answers `issueLink` and `revokeLink`. A test of a public procedure with a challenge passes `env: {
1756
- TURNSTILE_SECRET: '…', TURNSTILE_SITE_KEY: '…' }` and `challengeFetch`, a stand-in for Turnstile's answer, to `testKit`, and a fresh
1757
- `challengeToken` with each submission: a token is single use. A submission with a photo goes through the typed
1797
+ answers `issueLink` and `revokeLink`. A test of a public procedure with an ALTCHA challenge passes `env: {
1798
+ ALTCHA_HMAC_KEY: '<32 characters or more>' }` to `testKit` and a fresh `challengeToken: await kit.solveChallenge(api.visitors.register)`
1799
+ with each submission: a challenge passes once. The kit issues the cheapest challenge whatever the contract's
1800
+ `cost`, and verifies it as production does, so a solve takes milliseconds. A Turnstile challenge instead takes
1801
+ `challengeFetch`, a stand-in for Cloudflare's answer, and any fresh token. A submission with a photo goes through the typed
1758
1802
  client over `kit.fetch`, and needs `files: new MemoryFileObjects()` in `testKit`; `kit.runJob('intake')` then
1759
1803
  writes the photo's reference into the candidate, and the scan job (`FILE_SCAN_JOB`, with a `scanner`) makes it
1760
1804
  readable. Test the refusals beside the success: no or a failed token, a filled honeypot, a file too large or of
@@ -1825,7 +1869,7 @@ if (postgres !== null) {
1825
1869
  database: postgres.database,
1826
1870
  files: new MemoryFileObjects(),
1827
1871
  membership,
1828
- env: { TURNSTILE_SECRET: 'test', TURNSTILE_SITE_KEY: 'test-site-key' },
1872
+ env: { ALTCHA_HMAC_KEY: 'test-altcha-hmac-key-of-32-characters' },
1829
1873
  })
1830
1874
  afterEach(() => kit.mail.heal())
1831
1875
  const sentTo = (address: string) =>
@@ -1856,7 +1900,7 @@ if (postgres !== null) {
1856
1900
  database: postgres.database,
1857
1901
  files: new MemoryFileObjects(),
1858
1902
  membership,
1859
- env: { TURNSTILE_SECRET: 'test', TURNSTILE_SITE_KEY: 'test-site-key' },
1903
+ env: { ALTCHA_HMAC_KEY: 'test-altcha-hmac-key-of-32-characters' },
1860
1904
  mail: 'none',
1861
1905
  },
1862
1906
  )
@@ -12,6 +12,7 @@ dependencies, `tsconfig.json` (`"jsx": "react-jsx"`) and `build-web.ts`.
12
12
  | `@fabrika/app/react` | `FabrikaProvider`, `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction`, `useDownload`, `useInvalidate`, `skip`, `useCaller`, `Can`, `FabrikaError` |
13
13
  | `@fabrika/app/react/testing` | `installDom`, `type`, `controlByLabel`, `waitForText`: a screen test's DOM and typing |
14
14
  | `@fabrika/ui` | the components below, `createRouter`, and `@fabrika/ui/styles.css` |
15
+ | `@fabrika/ui/challenge` | `Challenge`: the ALTCHA widget of a public procedure's challenge, at `challengeUrl(api.<procedure>)`; needs the `altcha` package (`public-intake.md`) |
15
16
 
16
17
  ## The provider and the routes
17
18
 
@@ -300,10 +300,13 @@ if (postgres !== null) {
300
300
  without it the application does not boot. With `scan: true`, an upload stays `quarantined` until
301
301
  `kit.runJob(FILE_SCAN_JOB)` (`@fabrika/app/implement`) runs with a `scanner` passed to `testKit`: a `FileScanner`
302
302
  stand-in, or the real ClamAV one, `clamscanScanner()` of `@fabrika/app/bun`.
303
- - A challenge token is single use: a second submission with the same token answers 400 `challenge_failed`,
304
- also when the test's `challengeFetch` answers success. Send a fresh token with each submission
305
- (`crypto.randomUUID()`). `passingChallenge` (`@fabrika/app/testing`) is a `challengeFetch` that passes every token;
306
- a stand-in of your own receives the check (`hostname`, `action`) a passing answer must confirm as its second argument.
303
+ - A challenge passes once: a second submission with the same token answers 400 `challenge_failed`. For an ALTCHA
304
+ challenge, `await kit.solveChallenge(api.<procedure>)` fetches one through the kit's proxy and solves it, so send a
305
+ fresh one with each submission; the kit issues the cheapest challenge and verifies it as production does. For a
306
+ Turnstile challenge send a fresh token with each submission (`crypto.randomUUID()`), also when the test's
307
+ `challengeFetch` answers success. `passingChallenge` (`@fabrika/app/testing`) is a `challengeFetch` that passes every
308
+ token; a stand-in of your own receives the check (`hostname`, `action`) a passing answer must confirm as its second
309
+ argument.
307
310
  - `testKit` takes no `deps`, and a test may not start a server (`Bun.serve` is `forbidden-global`). Stand in for an
308
311
  outbound dependency through its variables: `env` points it at a test URL, and `spyOn(globalThis, 'fetch')` answers
309
312
  that URL only and passes every other request on to the real `fetch`.
@@ -9,7 +9,7 @@ import { z } from 'zod/v4'
9
9
  import { type AttachmentBrand, attachmentOf } from '../contract/attachment'
10
10
  import { isProcedure, type ProcedureTreeDeclaration, type ProcedureType, SURFACE_SECTIONS } from '../contract/declaration'
11
11
  import { FILE_INPUT_PART } from '../contract/defaults'
12
- import { joinPath, LINK_API_SEGMENT, procedurePath } from '../contract/defaults'
12
+ import { challengePath, declaredChallengeAction, joinPath, LINK_API_SEGMENT, procedurePath } from '../contract/defaults'
13
13
  import type { AnyContract, InputSchema, Procedure, Schema, Surface } from '../contract/define'
14
14
  import { type ClientInput, type FileBrand, fileOf } from '../contract/file'
15
15
  import { type BounceConfig, type BounceOption, exchange, exchangeFile, resolveBounce, RpcError, type RpcFetch } from './transport'
@@ -201,6 +201,30 @@ interface TreeAt {
201
201
  readonly path: string
202
202
  }
203
203
 
204
+ /** Where each procedure of a client with an ALTCHA challenge fetches it, by the procedure's value. */
205
+ const challengeUrls = new WeakMap<object, string>()
206
+
207
+ /**
208
+ * The URL a page's ALTCHA widget fetches the challenge of `procedure` from (`GET /__fabrika/challenge/<action>`), on the
209
+ * client's `baseUrl`. Throws for a procedure whose `admission.challenge` is not `provider: 'altcha'`.
210
+ */
211
+ export function challengeUrl(procedure: object): string {
212
+ const url = challengeUrls.get(procedure)
213
+ if (url === undefined) {
214
+ throw new Error("challengeUrl takes a procedure of a client createClient made whose admission.challenge is provider: 'altcha'")
215
+ }
216
+ return url
217
+ }
218
+
219
+ /** The action of a procedure's declared ALTCHA challenge, or `null`; `buildArtifact` refuses every other shape. */
220
+ function altchaActionOf(admission: unknown, id: string): string | null {
221
+ const challenge: unknown = typeof admission === 'object' && admission !== null ? Reflect.get(admission, 'challenge') : undefined
222
+ if (typeof challenge !== 'object' || challenge === null || Reflect.get(challenge, 'provider') !== 'altcha') {
223
+ return null
224
+ }
225
+ return declaredChallengeAction(id, Reflect.get(challenge, 'action'))
226
+ }
227
+
204
228
  function clientTree(
205
229
  tree: ProcedureTreeDeclaration<Schema, unknown>,
206
230
  at: TreeAt,
@@ -209,19 +233,27 @@ function clientTree(
209
233
  const client: Record<string, unknown> = {}
210
234
  for (const [name, node] of Object.entries(tree)) {
211
235
  const path = at.path === '' ? name : `${at.path}.${name}`
212
- client[name] = isProcedure(node)
213
- ? make({
214
- id: `${at.mount}.${path}`,
215
- url: `${at.origin}${procedurePath(at.base, path, node.http)}`,
216
- method: httpMethodOf(node.http, node.type, node.output),
217
- type: node.type,
218
- input: node.input,
219
- output: node.output,
220
- }, {
221
- ...at,
222
- path,
223
- })
224
- : clientTree(node, { ...at, path }, make)
236
+ if (!isProcedure(node)) {
237
+ client[name] = clientTree(node, { ...at, path }, make)
238
+ continue
239
+ }
240
+ const id = `${at.mount}.${path}`
241
+ const made = make({
242
+ id,
243
+ url: `${at.origin}${procedurePath(at.base, path, node.http)}`,
244
+ method: httpMethodOf(node.http, node.type, node.output),
245
+ type: node.type,
246
+ input: node.input,
247
+ output: node.output,
248
+ }, {
249
+ ...at,
250
+ path,
251
+ })
252
+ const action = altchaActionOf(node.admission, id)
253
+ if (action !== null && (typeof made === 'function' || (typeof made === 'object' && made !== null))) {
254
+ challengeUrls.set(made, `${at.origin}${challengePath(action)}`)
255
+ }
256
+ client[name] = made
225
257
  }
226
258
  const group = Object.freeze(client)
227
259
  clientNodes.set(group, { mount: at.mount, path: at.path, procedure: null })
@@ -2,6 +2,7 @@
2
2
  export { type PageCaller, parsePageCaller } from './caller'
3
3
  export {
4
4
  type AttachmentProcedure,
5
+ challengeUrl,
5
6
  type ClientMount,
6
7
  type ClientOptions,
7
8
  type ClientTree,
@@ -26,9 +26,18 @@ import {
26
26
  type TablePolicyArtifact,
27
27
  type TupleShape,
28
28
  } from '@fabrika/auth-core'
29
- import { isPolicyOrigin } from '@fabrika/proxy-core/headers'
29
+ import { isPolicyOrigin, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
30
30
  import { z } from 'zod/v4'
31
- import { CHALLENGE_ACTION, JOB_LIMITS, PROCEDURE_LIMITS } from './defaults'
31
+ import {
32
+ ALTCHA_LIMITS,
33
+ altchaSolvable,
34
+ CHALLENGE_ACTION,
35
+ CHALLENGE_PREFIX,
36
+ challengePath,
37
+ challengeRowId,
38
+ JOB_LIMITS,
39
+ PROCEDURE_LIMITS,
40
+ } from './defaults'
32
41
  import { TOKEN_MAX_DAYS } from './token'
33
42
  import { webhookSchemeSchema } from './webhook-scheme'
34
43
 
@@ -186,6 +195,19 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
186
195
  crossOrigin: z.enum(['environment', 'any']).optional(),
187
196
  cache: endpointCacheSchema.optional(),
188
197
  }).refine((row) => row.cache === undefined || row.method === 'GET', 'only a GET answer is cached'),
198
+ // The framework's own GET that issues the ALTCHA challenge of the public procedure its source names: no body, no schemas.
199
+ z.strictObject({
200
+ ...rowBase,
201
+ kind: z.literal('public'),
202
+ source: z.strictObject({ kind: z.literal('challenge'), name }),
203
+ method: z.literal('GET'),
204
+ admission: z.null(),
205
+ input: z.null(),
206
+ output: z.null(),
207
+ callers: z.tuple([]),
208
+ requirement: z.null(),
209
+ crossOrigin: z.enum(['environment', 'any']).optional(),
210
+ }),
189
211
  // A public SPA is a fixed static row: GET only, no body, no schemas.
190
212
  z.strictObject({
191
213
  ...rowBase,
@@ -219,7 +241,17 @@ const formRate = z.int().positive().max(10_000)
219
241
  const applicationVariable = z.string().regex(/^[A-Z][A-Z0-9_]*$/)
220
242
  const bucket = z.int().positive().max(PROCEDURE_LIMITS.maxPerMinute).nullable()
221
243
  const challenge = z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() })
222
- const procedureChallenge = challenge.extend({ action: z.string().regex(CHALLENGE_ACTION) })
244
+ const challengeActionSchema = z.string().regex(CHALLENGE_ACTION)
245
+ const procedureChallenge = z.discriminatedUnion('provider', [
246
+ challenge.extend({ action: challengeActionSchema }),
247
+ z.strictObject({
248
+ provider: z.literal('altcha'),
249
+ secret: applicationVariable,
250
+ action: challengeActionSchema,
251
+ cost: z.int().min(ALTCHA_LIMITS.minCost).max(ALTCHA_LIMITS.maxCost),
252
+ maxAgeMs: z.int().min(ALTCHA_LIMITS.minMaxAgeMs).max(ALTCHA_LIMITS.maxMaxAgeMs),
253
+ }).refine((challenge) => altchaSolvable(challenge.cost, challenge.maxAgeMs), 'a slow phone does not solve this challenge in time'),
254
+ ])
223
255
 
224
256
  const mount = z.discriminatedUnion('kind', [
225
257
  z.strictObject({ kind: z.literal('rpc'), prefix: name, callers: z.array(rpcCaller), compatibility: z.literal('backward').optional() }),
@@ -350,6 +382,7 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
350
382
  frameSrc: z.array(cspSource).nonempty().optional(),
351
383
  styleSrc: z.array(cspSource).nonempty().optional(),
352
384
  fontSrc: z.array(cspSource).nonempty().optional(),
385
+ workerSrc: z.tuple([z.strictObject({ origin: z.literal(WORKER_BLOB_SOURCE), purpose: z.string().min(1) })]).optional(),
353
386
  }),
354
387
  tables: record(tablePolicy),
355
388
  endpoints: z.array(endpointRow).superRefine((rows, context) => {
@@ -407,10 +440,36 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
407
440
  tokens: record(z.strictObject({ claims: schemaShapeSchema, maxDays: z.int().positive().max(TOKEN_MAX_DAYS) })).optional(),
408
441
  }).superRefine((artifact, context) => {
409
442
  for (const row of artifact.endpoints) {
410
- if (row.kind === 'public' && artifact.declared?.[row.id]?.actions === undefined) {
443
+ if (row.kind === 'public' && row.source.kind !== 'challenge' && artifact.declared?.[row.id]?.actions === undefined) {
411
444
  context.addIssue({ code: 'custom', path: ['declared', row.id], message: 'a public procedure declares its workload actions' })
412
445
  }
413
446
  }
447
+ // Every ALTCHA challenge has exactly its own issuer, under its action, from the procedure's origins, with its own buckets.
448
+ const rows = new Map(artifact.endpoints.map((row) => [row.id, row]))
449
+ const issuers = artifact.endpoints.filter((row) => row.source.kind === 'challenge')
450
+ for (const [id, rules] of Object.entries(artifact.procedures ?? {})) {
451
+ if (rules.challenge?.provider !== 'altcha') continue
452
+ const issuer = rows.get(challengeRowId(id))
453
+ const procedure = rows.get(id)
454
+ if (
455
+ issuer === undefined || procedure === undefined || issuer.source.kind !== 'challenge' || issuer.source.name !== id
456
+ || issuer.path !== challengePath(rules.challenge.action) || issuer.crossOrigin !== procedure.crossOrigin
457
+ || artifact.procedures?.[issuer.id]?.challenge !== null
458
+ ) {
459
+ context.addIssue({ code: 'custom', path: ['procedures', id, 'challenge'], message: 'an ALTCHA challenge has its own issuer row' })
460
+ }
461
+ }
462
+ for (const issuer of issuers) {
463
+ if (issuer.id !== challengeRowId(issuer.source.name) || artifact.procedures?.[issuer.source.name]?.challenge?.provider !== 'altcha') {
464
+ context.addIssue({ code: 'custom', path: ['endpoints'], message: `'${issuer.id}' issues the challenge of no ALTCHA procedure` })
465
+ }
466
+ }
467
+ if (issuers.length > 0 && !artifact.reservedPrefixes.includes(CHALLENGE_PREFIX)) {
468
+ context.addIssue({ code: 'custom', path: ['reservedPrefixes'], message: `an issuer row reserves ${CHALLENGE_PREFIX}` })
469
+ }
470
+ if ((artifact.csp.workerSrc !== undefined) !== issuers.length > 0) {
471
+ context.addIssue({ code: 'custom', path: ['csp', 'workerSrc'], message: 'workerSrc admits the workers of an ALTCHA challenge, and only those' })
472
+ }
414
473
  })
415
474
 
416
475
  function mailLocales(kind: MessageKind) {