@fabrika/app 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -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 +450 -255
  10. package/skill/references/spa.md +6 -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 +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  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 +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -15,6 +15,8 @@ import {
15
15
  MAX_WITHIN_VALUES,
16
16
  type Ref as RefCapability,
17
17
  type RefGrant,
18
+ type RowGrant,
19
+ type RowRef as RowRefCapability,
18
20
  type Within as WithinCapability,
19
21
  type WithinGrant,
20
22
  } from '@fabrika/db/capability'
@@ -28,8 +30,13 @@ export const CAPABILITY_REFUSALS = {
28
30
  wrong_target: "an IAM request was not passed this request's ctx.target",
29
31
  undeclared_action: "the job's declaration or its token does not carry the action",
30
32
  undeclared_dimension: 'the job named a dimension the contract does not declare',
33
+ undeclared_kick: 'the site kicked a job it does not declare in kicks',
34
+ undeclared_binding: 'the site named a webhook it does not declare in binds',
35
+ undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
36
+ undeclared_egress: 'the site called an origin it does not declare in egress',
37
+ wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
31
38
  missing_id: 'the job named an object without an id',
32
- wrong_capability: 'a file collection was handed a capability of another dimension or action than the operation needs',
39
+ wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
33
40
  } as const
34
41
 
35
42
  export type CapabilityRefusal = keyof typeof CAPABILITY_REFUSALS
@@ -173,6 +180,7 @@ interface Bound {
173
180
  let readRef: (value: unknown) => Bound | undefined = () => undefined
174
181
  let readWithin: (value: unknown) => Bound | undefined = () => undefined
175
182
  let readApp: (value: unknown) => Bound | undefined = () => undefined
183
+ let readRow: (value: unknown) => Bound | undefined = () => undefined
176
184
 
177
185
  /** One authorized object for one action, in one request. `chain` holds the containers the object lies in. */
178
186
  export class Ref<D extends string, A extends string> implements RefCapability<D, A> {
@@ -344,10 +352,70 @@ export class AppCapability<A extends string> implements AppCapabilityType<A> {
344
352
  }
345
353
  }
346
354
 
355
+ interface RowFacts {
356
+ readonly table: string
357
+ readonly action: string
358
+ readonly principal: string
359
+ readonly id: string
360
+ readonly chain: Chain
361
+ }
362
+
363
+ /**
364
+ * One row a claim leased, for one action, in one job attempt: `row.ref`, which `tx.db.on(row.ref)` finishes the row
365
+ * through. `chain` holds the row's containment columns as it was claimed.
366
+ */
367
+ export class RowRef<T extends string, A extends string> implements RowRefCapability<T, A> {
368
+ declare readonly '~capability': { readonly kind: 'row'; readonly table: T; readonly action: A }
369
+ readonly #facts: RowFacts
370
+ readonly #frame: RequestFrame
371
+ readonly #issuer: object
372
+
373
+ constructor(token: typeof MINT, issuer: object, frame: RequestFrame, grant: Omit<RowGrant, 'kind'>) {
374
+ if (token !== MINT) {
375
+ throw new CapabilityRefusedError('not_minted', 'a row capability is minted by the framework only: claim the row')
376
+ }
377
+ this.#facts = freeze({ table: grant.table, action: grant.action, principal: grant.principal, id: grant.id, chain: chainOf(grant.chain) })
378
+ this.#frame = frame
379
+ this.#issuer = issuer
380
+ }
381
+
382
+ get table(): string {
383
+ return this.#facts.table
384
+ }
385
+
386
+ get action(): string {
387
+ return this.#facts.action
388
+ }
389
+
390
+ /** The row's key. */
391
+ get value(): string {
392
+ return this.#facts.id
393
+ }
394
+
395
+ static {
396
+ readRow = (value) => {
397
+ if (typeof value !== 'object' || value === null || !(#facts in value)) {
398
+ return undefined
399
+ }
400
+ const facts = value.#facts
401
+ const grant: RowGrant = freeze({
402
+ kind: 'row',
403
+ table: facts.table,
404
+ action: facts.action,
405
+ principal: facts.principal,
406
+ id: facts.id,
407
+ chain: mapOf(facts.chain),
408
+ })
409
+ return { grant, frame: value.#frame, issuer: value.#issuer }
410
+ }
411
+ }
412
+ }
413
+
347
414
  // Application code could otherwise redefine a getter that the framework or a handler reads.
348
415
  freeze(Ref.prototype)
349
416
  freeze(Within.prototype)
350
417
  freeze(AppCapability.prototype)
418
+ freeze(RowRef.prototype)
351
419
 
352
420
  /** Makes the capabilities one authority accepts; a runtime's is held by the `boot` call that made it alone. */
353
421
  export interface CapabilityMint {
@@ -410,7 +478,7 @@ export function createCapabilityIssuer(): CapabilityIssuer {
410
478
  })
411
479
  const authority: CapabilityAuthority = freeze({
412
480
  resolve(capability: unknown): CapabilityGrant {
413
- const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability)
481
+ const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability) ?? readRow(capability)
414
482
  if (bound === undefined || bound.issuer !== issuer) {
415
483
  throw new CapabilityRefusedError('not_minted', 'not a capability the framework minted: pass the ctx.target or ctx.within of this request')
416
484
  }
@@ -431,6 +499,14 @@ export function createCapabilityIssuer(): CapabilityIssuer {
431
499
  return grant
432
500
  },
433
501
 
502
+ row(grant: Omit<RowGrant, 'kind'>): RowRef<string, string> {
503
+ const frame = currentRequest.getStore()
504
+ if (frame === undefined || frame.ended) {
505
+ throw new CapabilityRefusedError('ended', 'a claimed row is minted only inside the job attempt that claims it')
506
+ }
507
+ return new RowRef(MINT, issuer, frame, grant)
508
+ },
509
+
434
510
  fence(): LeaseFence | null {
435
511
  return currentRequest.getStore()?.fence ?? null
436
512
  },
@@ -1,10 +1,15 @@
1
1
  // A form's challenge, verified on the server before its row is written. The provider list is closed: Turnstile's
2
2
  // siteverify is the only endpoint the framework asks, with the secret from the application variable the contract
3
- // names. A token verifies only for this host and this form's action, while it is fresh; the dispatcher then records
3
+ // names; an ALTCHA challenge the application issues and verifies itself, with the HMAC key that variable holds, and asks
4
+ // no one. A token verifies only for this host and this form's action, while it is fresh; the dispatcher then records
4
5
  // it, so its second use is a replay whatever the provider says.
5
6
 
6
- import type { ChallengeProvider } from '@fabrika/auth-core'
7
+ import type { AltchaChallenge, ChallengeProvider } from '@fabrika/auth-core'
8
+ import { createChallenge, randomInt, verifySolution } from 'altcha-lib'
9
+ import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2'
10
+ import { createHmac } from 'node:crypto'
7
11
  import { z } from 'zod/v4'
12
+ import { ALTCHA_LIMITS } from '../contract/defaults'
8
13
  import { readBoundedBytes } from './bounded'
9
14
 
10
15
  export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
@@ -37,11 +42,23 @@ export interface ChallengeCheck {
37
42
  readonly action: string
38
43
  }
39
44
 
45
+ /**
46
+ * A verification's answer. A passed one names what the dispatcher records it by: the token itself, or, for ALTCHA, the
47
+ * signature of the challenge it solved, since one solution can be encoded as many tokens. Its record lasts at least
48
+ * as long as this token could pass again, even if the issuer's configured lifetime has since changed.
49
+ */
50
+ export type ChallengeVerdict =
51
+ | { readonly outcome: 'passed'; readonly replayKey: string; readonly recordMs: number }
52
+ | { readonly outcome: Exclude<ChallengeOutcome, 'passed'> }
53
+
40
54
  export interface ChallengeVerifier {
41
55
  readonly provider: ChallengeProvider
42
- verify(check: ChallengeCheck): Promise<ChallengeOutcome>
56
+ verify(check: ChallengeCheck): Promise<ChallengeVerdict>
43
57
  }
44
58
 
59
+ const verdictOf = (outcome: ChallengeOutcome, replayKey: string, recordMs: number): ChallengeVerdict =>
60
+ outcome === 'passed' ? { outcome, replayKey, recordMs } : { outcome }
61
+
45
62
  /** A token as the widget issues it: printable ASCII without spaces, within the provider's length. */
46
63
  const TOKEN = new RegExp(`^[\\x21-\\x7e]{1,${CHALLENGE_LIMITS.maxTokenLength}}$`)
47
64
 
@@ -61,55 +78,60 @@ const SECRET_ERRORS = ['missing-input-secret', 'invalid-input-secret']
61
78
  export function turnstileVerifier(secret: string, fetch: ChallengeFetch, now: () => number = Date.now): ChallengeVerifier {
62
79
  const verifier: ChallengeVerifier = {
63
80
  provider: 'turnstile',
64
- async verify(check: ChallengeCheck): Promise<ChallengeOutcome> {
65
- if (!TOKEN.test(check.token)) {
66
- return 'failed'
67
- }
68
- const deadline = new AbortController()
69
- const timer = setTimeout(() => deadline.abort(), CHALLENGE_LIMITS.timeoutMs)
70
- let raw: Uint8Array<ArrayBuffer> | null
71
- try {
72
- const response = await fetch(
73
- new Request(TURNSTILE_VERIFY_URL, {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
- body: new URLSearchParams({ secret, response: check.token }),
77
- redirect: 'manual',
78
- signal: deadline.signal,
79
- }),
80
- check,
81
- )
82
- const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
83
- if (response.status !== 200 || type !== 'application/json') {
84
- await response.body?.cancel()
85
- return 'unavailable'
86
- }
87
- raw = await readBoundedBytes(response.body, { maxBytes: CHALLENGE_LIMITS.maxAnswerBytes, signal: deadline.signal })
88
- } catch {
89
- return 'unavailable'
90
- } finally {
91
- clearTimeout(timer)
92
- }
93
- const answer = raw === null ? null : parseAnswer(raw)
94
- if (answer === null) {
95
- return 'unavailable'
96
- }
97
- if (!answer.success) {
98
- const codes = answer['error-codes'] ?? []
99
- if (codes.some((code) => SECRET_ERRORS.includes(code))) {
100
- return 'misconfigured'
101
- }
102
- return codes.includes('internal-error') ? 'unavailable' : 'failed'
103
- }
104
- const issued = Date.parse(answer.challenge_ts ?? '')
105
- const age = now() - issued
106
- const fresh = Number.isFinite(issued) && age <= CHALLENGE_LIMITS.maxAgeMs && age >= -CHALLENGE_LIMITS.maxSkewMs
107
- return fresh && answer.hostname === check.hostname && answer.action === check.action ? 'passed' : 'failed'
81
+ async verify(check: ChallengeCheck): Promise<ChallengeVerdict> {
82
+ return verdictOf(await siteverify(secret, fetch, now, check), check.token, CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)
108
83
  },
109
84
  }
110
85
  return Object.freeze(verifier)
111
86
  }
112
87
 
88
+ /** Turnstile's answer for `check`, under one deadline and a byte budget. */
89
+ async function siteverify(secret: string, fetch: ChallengeFetch, now: () => number, check: ChallengeCheck): Promise<ChallengeOutcome> {
90
+ if (!TOKEN.test(check.token)) {
91
+ return 'failed'
92
+ }
93
+ const deadline = new AbortController()
94
+ const timer = setTimeout(() => deadline.abort(), CHALLENGE_LIMITS.timeoutMs)
95
+ let raw: Uint8Array<ArrayBuffer> | null
96
+ try {
97
+ const response = await fetch(
98
+ new Request(TURNSTILE_VERIFY_URL, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
101
+ body: new URLSearchParams({ secret, response: check.token }),
102
+ redirect: 'manual',
103
+ signal: deadline.signal,
104
+ }),
105
+ check,
106
+ )
107
+ const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
108
+ if (response.status !== 200 || type !== 'application/json') {
109
+ await response.body?.cancel()
110
+ return 'unavailable'
111
+ }
112
+ raw = await readBoundedBytes(response.body, { maxBytes: CHALLENGE_LIMITS.maxAnswerBytes, signal: deadline.signal })
113
+ } catch {
114
+ return 'unavailable'
115
+ } finally {
116
+ clearTimeout(timer)
117
+ }
118
+ const answer = raw === null ? null : parseAnswer(raw)
119
+ if (answer === null) {
120
+ return 'unavailable'
121
+ }
122
+ if (!answer.success) {
123
+ const codes = answer['error-codes'] ?? []
124
+ if (codes.some((code) => SECRET_ERRORS.includes(code))) {
125
+ return 'misconfigured'
126
+ }
127
+ return codes.includes('internal-error') ? 'unavailable' : 'failed'
128
+ }
129
+ const issued = Date.parse(answer.challenge_ts ?? '')
130
+ const age = now() - issued
131
+ const fresh = Number.isFinite(issued) && age <= CHALLENGE_LIMITS.maxAgeMs && age >= -CHALLENGE_LIMITS.maxSkewMs
132
+ return fresh && answer.hostname === check.hostname && answer.action === check.action ? 'passed' : 'failed'
133
+ }
134
+
113
135
  function parseAnswer(raw: Uint8Array<ArrayBuffer>): z.infer<typeof siteverifyAnswer> | null {
114
136
  try {
115
137
  const parsed = siteverifyAnswer.safeParse(JSON.parse(new TextDecoder().decode(raw)))
@@ -118,3 +140,137 @@ function parseAnswer(raw: Uint8Array<ArrayBuffer>): z.infer<typeof siteverifyAns
118
140
  return null
119
141
  }
120
142
  }
143
+
144
+ /**
145
+ * How hard an ALTCHA challenge is to solve: PBKDF2 attempts of `cost` iterations, up to a count drawn between
146
+ * `minCounter` and `maxCounter`. The contract's by default; a test kit and `fabrika app dev` make it cheap.
147
+ */
148
+ export interface ChallengeWork {
149
+ readonly cost: number
150
+ readonly minCounter: number
151
+ readonly maxCounter: number
152
+ }
153
+
154
+ /** The cheapest work: the test kit's and `fabrika app dev`'s, so a test solves a challenge in milliseconds. */
155
+ export const MINIMAL_CHALLENGE_WORK: ChallengeWork = Object.freeze({ cost: 1, minCounter: 1, maxCounter: 10 })
156
+
157
+ /** What the issuing endpoint answers: the signed parameters, which the widget solves and sends back with its solution. */
158
+ export interface IssuedChallenge {
159
+ readonly parameters: Readonly<Record<string, unknown>>
160
+ readonly signature?: string
161
+ }
162
+
163
+ export interface ChallengeIssuer {
164
+ /** A fresh challenge for this host and the procedure's action. */
165
+ issue(hostname: string): Promise<IssuedChallenge>
166
+ }
167
+
168
+ const HEX = /^[0-9a-f]+$/
169
+ const hex = (length: number) => z.string().length(length).regex(HEX)
170
+
171
+ // Strict: exactly what `issue` signs, so a payload of another shape (the widget's `test` mode, a server-signed one) never
172
+ // reaches the verification.
173
+ const altchaPayload = z.strictObject({
174
+ challenge: z.strictObject({
175
+ parameters: z.strictObject({
176
+ algorithm: z.literal(ALTCHA_LIMITS.algorithm),
177
+ nonce: hex(32),
178
+ salt: hex(32),
179
+ cost: z.int().positive(),
180
+ keyLength: z.literal(32),
181
+ keyPrefix: hex(32),
182
+ keySignature: hex(64),
183
+ expiresAt: z.int().positive(),
184
+ data: z.strictObject({ action: z.string(), host: z.string() }),
185
+ }),
186
+ signature: hex(64),
187
+ }),
188
+ solution: z.strictObject({ counter: z.int().nonnegative(), derivedKey: hex(64), time: z.number().nonnegative().optional() }),
189
+ })
190
+
191
+ type AltchaPayload = z.infer<typeof altchaPayload>
192
+
193
+ /** A payload as the widget sends it: base64 of its JSON, within the bound. */
194
+ const PAYLOAD = new RegExp(`^[A-Za-z0-9+/]{1,${ALTCHA_LIMITS.maxTokenLength}}={0,2}$`)
195
+
196
+ /**
197
+ * The two HMAC keys of an ALTCHA challenge, each derived from the application's key for one use: one signs the
198
+ * parameters, the other the solution the server knows, so neither signature can stand in for the other.
199
+ */
200
+ function altchaKeys(secret: string): { readonly signature: string; readonly key: string } {
201
+ const derive = (use: string) => createHmac('sha256', secret).update(`fabrika-altcha-${use}`).digest('hex')
202
+ return { signature: derive('challenge'), key: derive('solution') }
203
+ }
204
+
205
+ function parsePayload(token: string): AltchaPayload | null {
206
+ if (token.length > ALTCHA_LIMITS.maxTokenLength || !PAYLOAD.test(token)) {
207
+ return null
208
+ }
209
+ try {
210
+ const parsed = altchaPayload.safeParse(JSON.parse(atob(token)))
211
+ return parsed.success ? parsed.data : null
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
217
+ /**
218
+ * The issuer and the verifier of one public procedure's ALTCHA challenge, with the HMAC key its application variable
219
+ * holds. A challenge carries the action and the host it was issued for and expires `maxAgeMs` after it was issued; the
220
+ * server picks the attempt that solves it and signs that attempt's key, so verifying is two HMACs and no proof of work.
221
+ * Nothing leaves the process. `now` is the clock a challenge's expiry is set with; altcha-lib reads it on the real clock,
222
+ * and so does the verifier, so the two never disagree.
223
+ */
224
+ export function altchaChallenge(
225
+ secret: string,
226
+ rules: Pick<AltchaChallenge, 'action' | 'cost' | 'maxAgeMs'>,
227
+ work: ChallengeWork | null = null,
228
+ now: () => number = Date.now,
229
+ ): { readonly issuer: ChallengeIssuer; readonly verifier: ChallengeVerifier } {
230
+ const keys = altchaKeys(secret)
231
+ const effort = work ?? { cost: rules.cost, minCounter: ALTCHA_LIMITS.minCounter, maxCounter: ALTCHA_LIMITS.maxCounter }
232
+ const issuer: ChallengeIssuer = {
233
+ async issue(hostname: string): Promise<IssuedChallenge> {
234
+ const challenge = await createChallenge({
235
+ algorithm: ALTCHA_LIMITS.algorithm,
236
+ cost: effort.cost,
237
+ counter: randomInt(effort.maxCounter, effort.minCounter),
238
+ expiresAt: Math.floor((now() + rules.maxAgeMs) / 1000),
239
+ data: { action: rules.action, host: hostname },
240
+ deriveKey,
241
+ hmacSignatureSecret: keys.signature,
242
+ hmacKeySignatureSecret: keys.key,
243
+ })
244
+ return { parameters: { ...challenge.parameters }, ...(challenge.signature === undefined ? {} : { signature: challenge.signature }) }
245
+ },
246
+ }
247
+ const verifier: ChallengeVerifier = {
248
+ provider: 'altcha',
249
+ async verify(check: ChallengeCheck): Promise<ChallengeVerdict> {
250
+ const payload = parsePayload(check.token)
251
+ if (payload === null) {
252
+ return { outcome: 'failed' }
253
+ }
254
+ const { parameters } = payload.challenge
255
+ // The signature covers these; they are read before it only to answer without the HMAC.
256
+ if (parameters.data.action !== check.action || parameters.data.host !== check.hostname || parameters.expiresAt * 1000 < Date.now()) {
257
+ return { outcome: 'failed' }
258
+ }
259
+ try {
260
+ const result = await verifySolution({
261
+ challenge: payload.challenge,
262
+ solution: payload.solution,
263
+ deriveKey,
264
+ hmacSignatureSecret: keys.signature,
265
+ hmacKeySignatureSecret: keys.key,
266
+ })
267
+ // A previous deployment may have issued longer-lived challenges with this same key.
268
+ const recordMs = Math.max(0, parameters.expiresAt * 1000 - Date.now()) + CHALLENGE_LIMITS.maxSkewMs
269
+ return verdictOf(result.verified ? 'passed' : 'failed', payload.challenge.signature, recordMs)
270
+ } catch {
271
+ return { outcome: 'failed' }
272
+ }
273
+ },
274
+ }
275
+ return Object.freeze({ issuer: Object.freeze(issuer), verifier: Object.freeze(verifier) })
276
+ }
@@ -1,13 +1,9 @@
1
- // A form's admission: which bucket a request takes from, and what a submission carries beside its row. The buckets
2
- // live in the application's database (`@fabrika/db`'s `FormAdmissions`), so every instance shares them. Admission is
3
- // not authorization: a form is anonymous, and this only bounds how much one client and all clients together submit.
1
+ // The client a per-client bucket counts. The buckets live in the application's database (`@fabrika/db`'s
2
+ // `Admissions`), so every instance shares them. Admission is not authorization: it only bounds how much one client
3
+ // calls a public procedure.
4
4
 
5
5
  import { CLIENT_ADDRESS_HEADER } from '@fabrika/auth-core'
6
6
  import { createHash } from 'node:crypto'
7
- import { FORM_CHALLENGE_FIELD } from '../contract/defaults'
8
-
9
- /** The bucket of a form's accepted submissions; a client's bucket is `client:<digest>`, so the two never meet. */
10
- export const ACCEPTED_BUCKET = 'accepted'
11
7
 
12
8
  /** Longest header value read; an IPv6 address with a zone id fits with room to spare. */
13
9
  const MAX_ADDRESS_LENGTH = 64
@@ -87,34 +83,3 @@ export function clientKey(header: string | null): string {
87
83
  export function clientBucket(request: Request): string {
88
84
  return `client:${createHash('sha256').update(clientKey(request.headers.get(CLIENT_ADDRESS_HEADER))).digest('hex')}`
89
85
  }
90
-
91
- /** What the form declared beside its row: its honeypot field and whether a challenge token travels in the input. */
92
- export interface SubmissionFields {
93
- readonly honeypot: string | null
94
- readonly challenge: boolean
95
- }
96
-
97
- export interface Submission {
98
- /** The honeypot was filled: the answer is the constant one, and nothing is written. */
99
- readonly trapped: boolean
100
- /** The challenge token, when the form declares a challenge. */
101
- readonly token: string | null
102
- /** The columns the inbox row gets: the input without the honeypot and the token. */
103
- readonly row: Readonly<Record<string, unknown>>
104
- }
105
-
106
- export function submissionOf(input: Readonly<Record<string, unknown>>, fields: SubmissionFields): Submission {
107
- const row: Record<string, unknown> = {}
108
- for (const [name, value] of Object.entries(input)) {
109
- if (name !== fields.honeypot && !(fields.challenge && name === FORM_CHALLENGE_FIELD)) {
110
- row[name] = value
111
- }
112
- }
113
- const trap = fields.honeypot === null ? undefined : input[fields.honeypot]
114
- const token = fields.challenge ? input[FORM_CHALLENGE_FIELD] : undefined
115
- return {
116
- trapped: typeof trap === 'string' && trap !== '',
117
- token: typeof token === 'string' ? token : null,
118
- row,
119
- }
120
- }