@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 +10 -9
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +25 -21
- package/skill/references/dev.md +8 -6
- package/skill/references/endpoint.md +9 -7
- package/skill/references/public-intake.md +83 -39
- package/skill/references/spa.md +1 -0
- package/skill/references/tests.md +7 -4
- package/src/client/contract.ts +46 -14
- package/src/client/index.ts +1 -0
- package/src/contract/artifact-schema.ts +63 -4
- package/src/contract/build.ts +103 -11
- package/src/contract/connect.ts +13 -5
- package/src/contract/defaults.ts +82 -6
- package/src/contract/define.ts +23 -4
- package/src/implement/boot.ts +38 -3
- package/src/implement/browser-config.ts +2 -1
- package/src/implement/challenge.ts +203 -47
- package/src/implement/dispatch.ts +27 -6
- package/src/implement/public.ts +32 -7
- package/src/testing/challenge.ts +35 -1
- package/src/testing/endpoint-access.ts +5 -4
- package/src/testing/kit.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "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.
|
|
97
|
-
"@fabrika/auth-core": "0.
|
|
98
|
-
"@fabrika/db": "0.
|
|
99
|
-
"@fabrika/email": "0.
|
|
100
|
-
"@fabrika/jwks": "0.
|
|
101
|
-
"@fabrika/platform": "0.
|
|
102
|
-
"@fabrika/platform-node": "0.
|
|
103
|
-
"@fabrika/proxy-core": "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.
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.
|
|
24
|
-
"@fabrika/provider-zerops": "0.
|
|
25
|
-
"@fabrika/proxy-core": "0.
|
|
26
|
-
"@fabrika/ui": "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.
|
|
34
|
-
"@fabrika/cli": "0.
|
|
35
|
-
"@fabrika/platform": "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: '
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
the variable
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
package/skill/references/dev.md
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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` |
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
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' }
|
|
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
|
-
|
|
1131
|
-
|
|
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
|
|
1756
|
-
|
|
1757
|
-
|
|
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: {
|
|
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: {
|
|
1903
|
+
env: { ALTCHA_HMAC_KEY: 'test-altcha-hmac-key-of-32-characters' },
|
|
1860
1904
|
mail: 'none',
|
|
1861
1905
|
},
|
|
1862
1906
|
)
|
package/skill/references/spa.md
CHANGED
|
@@ -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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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`.
|
package/src/client/contract.ts
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 })
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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) {
|