@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.
@@ -58,7 +58,7 @@ import {
58
58
  type TokenPurposeArtifact,
59
59
  type WebhookScheme,
60
60
  } from '@fabrika/auth-core'
61
- import { isPolicyOrigin, MAX_CSP_ORIGINS } from '@fabrika/proxy-core/headers'
61
+ import { isPolicyOrigin, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
62
62
  import { z } from 'zod/v4'
63
63
  import { contractArtifactSchema } from './artifact-schema'
64
64
  import {
@@ -82,14 +82,23 @@ import {
82
82
  type SpaAccessDeclaration,
83
83
  } from './declaration'
84
84
  import {
85
+ ALTCHA_LIMITS,
86
+ altchaSolvable,
87
+ altchaWorstSolveMs,
85
88
  ATTACHMENT_LIMITS,
86
89
  CHALLENGE_ACTION,
87
90
  CHALLENGE_FIELD,
91
+ CHALLENGE_ISSUE_RATE,
88
92
  CHALLENGE_ORIGINS,
93
+ CHALLENGE_PREFIX,
89
94
  challengeAction,
95
+ challengeIssueBucket,
96
+ challengePath,
97
+ challengeRowId,
90
98
  CONNECT_LIMITS,
91
99
  CONNECT_PREFIX,
92
100
  CONTRACT_DEFAULTS,
101
+ declaredChallengeAction,
93
102
  exportBase,
94
103
  FILE_LIMITS,
95
104
  FORM_MULTIPART_TYPE,
@@ -127,6 +136,8 @@ const ATTACHMENT_FILENAMES: Readonly<Record<AttachmentFormat, RegExp>> = {
127
136
  const APP_SCOPE = 'app'
128
137
  const AUDIENCE = 'any consumer user'
129
138
  const IMPORT_FIELDS = ['app', 'export', 'version', 'digest', 'procedures', 'usedBy', 'timeoutMs']
139
+ const TURNSTILE_CHALLENGE_FIELDS = ['provider', 'secret', 'siteKey', 'action']
140
+ const ALTCHA_CHALLENGE_FIELDS = ['provider', 'secret', 'action', 'cost', 'maxAgeMs']
130
141
  /** Tables the framework keeps in the application's schema; no site reaches them unscoped. */
131
142
  const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
132
143
  /** The most origins one site calls: a site that reaches more is an integration hub, which a connector import serves. */
@@ -215,6 +226,8 @@ class ArtifactBuilder {
215
226
  const csp = this.#csp(contract.csp)
216
227
  const tables = this.#checkTables()
217
228
  this.#reserve(this.#endpoints.some((row) => row.source.kind === 'export') ? CONNECT_PREFIX : null)
229
+ // An unknown challenge path is a 404, never the root SPA's page.
230
+ this.#reserve(this.#endpoints.some((row) => row.source.kind === 'challenge') ? CHALLENGE_PREFIX : null)
218
231
  this.#checkKickCycles()
219
232
  const directories = this.#directories(dimensions)
220
233
  const locale = this.#locale()
@@ -541,21 +554,22 @@ class ArtifactBuilder {
541
554
  * declares its own `action`.
542
555
  */
543
556
  #challenge(id: string, where: string, challenge: object, fields: Readonly<Record<string, SchemaShape>>): ProcedureChallenge | undefined {
544
- this.#keys(`${where} challenge`, challenge, ['provider', 'secret', 'siteKey', 'action'])
545
557
  const provider: unknown = Reflect.get(challenge, 'provider')
558
+ if (provider !== 'turnstile' && provider !== 'altcha') {
559
+ this.#problem(where, `challenge provider ${JSON.stringify(provider)} is not one the framework verifies (allowed: 'turnstile', 'altcha')`)
560
+ return undefined
561
+ }
562
+ this.#keys(`${where} challenge`, challenge, provider === 'altcha' ? ALTCHA_CHALLENGE_FIELDS : TURNSTILE_CHALLENGE_FIELDS)
546
563
  const secret: unknown = Reflect.get(challenge, 'secret')
547
564
  const siteKey: unknown = Reflect.get(challenge, 'siteKey')
548
565
  const declaredAction: unknown = Reflect.get(challenge, 'action')
549
- if (provider !== 'turnstile') {
550
- this.#problem(where, `challenge provider ${JSON.stringify(provider)} is not one the framework verifies (allowed: 'turnstile')`)
551
- return undefined
552
- }
553
566
  if (typeof secret !== 'string' || !APPLICATION_VARIABLE.test(secret) || secret.startsWith('FABRIKA_')) {
567
+ const example = provider === 'altcha' ? 'ALTCHA_HMAC_KEY' : 'TURNSTILE_SECRET'
554
568
  this.#problem(
555
569
  where,
556
570
  `challenge secret ${
557
571
  JSON.stringify(secret)
558
- } is not the name of an application variable (allowed: 'TURNSTILE_SECRET'; never the secret itself, never FABRIKA_*)`,
572
+ } is not the name of an application variable (allowed: '${example}'; never the secret itself, never FABRIKA_*)`,
559
573
  )
560
574
  return undefined
561
575
  }
@@ -572,6 +586,35 @@ class ArtifactBuilder {
572
586
  )
573
587
  }
574
588
  this.#challenges.push({ site: id, provider, secret, action })
589
+ if (provider === 'altcha') {
590
+ const cost = this.#bounded(
591
+ where,
592
+ 'challenge.cost',
593
+ Reflect.get(challenge, 'cost'),
594
+ ALTCHA_LIMITS.cost,
595
+ ALTCHA_LIMITS.maxCost,
596
+ 'iterations',
597
+ ALTCHA_LIMITS.minCost,
598
+ )
599
+ const maxAgeMs = this.#bounded(
600
+ where,
601
+ 'challenge.maxAgeMs',
602
+ Reflect.get(challenge, 'maxAgeMs'),
603
+ ALTCHA_LIMITS.maxAgeMs,
604
+ ALTCHA_LIMITS.maxMaxAgeMs,
605
+ 'ms',
606
+ ALTCHA_LIMITS.minMaxAgeMs,
607
+ )
608
+ if (!altchaSolvable(cost, maxAgeMs)) {
609
+ this.#problem(
610
+ where,
611
+ `its challenge of cost ${cost} takes a slow phone up to ${
612
+ altchaWorstSolveMs(cost)
613
+ } ms, more than the widget's ${ALTCHA_LIMITS.solveTimeoutMs} ms or half of maxAgeMs ${maxAgeMs} (allowed: a lower cost, or a longer maxAgeMs)`,
614
+ )
615
+ }
616
+ return { provider, secret, action, cost, maxAgeMs }
617
+ }
575
618
  if (siteKey === undefined || !this.#applicationVariable(where, 'challenge siteKey', siteKey, 'TURNSTILE_SITE_KEY')) {
576
619
  return { provider, secret, action }
577
620
  }
@@ -588,7 +631,7 @@ class ArtifactBuilder {
588
631
  `challenge.action ${JSON.stringify(declared)} is not a widget action (allowed: at most 32 letters, digits, '_' or '-', such as 'register')`,
589
632
  )
590
633
  }
591
- return typeof declared === 'string' ? declared : challengeAction(id)
634
+ return declaredChallengeAction(id, declared)
592
635
  }
593
636
  const derived = challengeAction(id)
594
637
  if (!CHALLENGE_ACTION.test(derived)) {
@@ -807,9 +850,44 @@ class ArtifactBuilder {
807
850
  output: this.#output(where, procedure),
808
851
  ...origins,
809
852
  })
853
+ const procedureRules = this.#procedureRules[id]
854
+ if (procedureRules?.challenge?.provider === 'altcha') {
855
+ this.#challengeIssuer(id, procedureRules.challenge.action, procedureRules.rate, origins)
856
+ }
810
857
  return actions
811
858
  }
812
859
 
860
+ /**
861
+ * The framework's own `GET` that issues the ALTCHA challenge of public procedure `id`: anonymous like the procedure,
862
+ * from the same origins, with buckets of its own that admit at least as many as the procedure's, and no body.
863
+ */
864
+ #challengeIssuer(id: string, action: string, rate: AdmissionRate, origins: { readonly crossOrigin?: 'environment' | 'any' }): void {
865
+ const issuer = challengeRowId(id)
866
+ this.#row({
867
+ id: issuer,
868
+ source: { kind: 'challenge', name: id },
869
+ kind: 'public',
870
+ method: 'GET',
871
+ path: challengePath(action),
872
+ admission: null,
873
+ callers: [],
874
+ requirement: null,
875
+ input: null,
876
+ output: null,
877
+ ...origins,
878
+ })
879
+ this.#procedureRules[issuer] = {
880
+ rate: {
881
+ perClient: challengeIssueBucket(rate.perClient, CHALLENGE_ISSUE_RATE.perClient),
882
+ perCaller: null,
883
+ perEndpoint: challengeIssueBucket(rate.perEndpoint, CHALLENGE_ISSUE_RATE.perEndpoint),
884
+ bytesPerHour: null,
885
+ },
886
+ challenge: null,
887
+ timeoutMs: PROCEDURE_LIMITS.timeoutMs,
888
+ }
889
+ }
890
+
813
891
  /** A public procedure's `actions`: at least one action of the contract, each once. */
814
892
  #workloadActions(where: string, value: unknown): readonly string[] {
815
893
  if (!Array.isArray(value) || value.length === 0 || !value.every((action) => typeof action === 'string')) {
@@ -1124,12 +1202,13 @@ class ArtifactBuilder {
1124
1202
  }
1125
1203
 
1126
1204
  /** A declared whole number from 1 to `ceiling`, or `fallback` when it is absent or refused. */
1127
- #bounded(where: string, field: string, value: unknown, fallback: number, ceiling: number, unit: string): number {
1205
+ /** A declared whole number from `floor` (1 unless named) to `ceiling`, or `fallback` when it is not declared. */
1206
+ #bounded(where: string, field: string, value: unknown, fallback: number, ceiling: number, unit: string, floor = 1): number {
1128
1207
  if (value === undefined) {
1129
1208
  return fallback
1130
1209
  }
1131
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > ceiling) {
1132
- this.#problem(where, `${field} ${JSON.stringify(value)} is not a whole number of ${unit} from 1 to ${ceiling}`)
1210
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < floor || value > ceiling) {
1211
+ this.#problem(where, `${field} ${JSON.stringify(value)} is not a whole number of ${unit} from ${floor} to ${ceiling}`)
1133
1212
  return fallback
1134
1213
  }
1135
1214
  return value
@@ -1892,7 +1971,20 @@ class ArtifactBuilder {
1892
1971
  ...(frameSrc.length === 0 ? {} : { frameSrc }),
1893
1972
  ...(styleSrc.length === 0 ? {} : { styleSrc }),
1894
1973
  ...(fontSrc.length === 0 ? {} : { fontSrc }),
1974
+ ...this.#challengeWorkers(),
1975
+ }
1976
+ }
1977
+
1978
+ /** `blob:` for workers, when a challenge provider's widget starts its own; the contract never declares it. */
1979
+ #challengeWorkers(): Pick<CspExtensions, 'workerSrc'> {
1980
+ const providers = [...new Set(this.#challenges.map((challenge) => challenge.provider))].sort()
1981
+ const needing = providers.filter((provider) => CHALLENGE_ORIGINS[provider].workerSrc.length > 0)
1982
+ if (needing.length === 0) {
1983
+ return {}
1895
1984
  }
1985
+ const sites = this.#challenges.filter((challenge) => needing.includes(challenge.provider)).map((challenge) => `'${challenge.site}'`).sort()
1986
+ const purpose = `the workers of the ${needing.join(' and ')} challenge of ${sites.join(', ')}`
1987
+ return { workerSrc: [{ origin: WORKER_BLOB_SOURCE, purpose }] }
1896
1988
  }
1897
1989
 
1898
1990
  /** The declared sources, plus each fixed origin a challenge provider loads that the contract does not name itself. */
@@ -150,8 +150,9 @@ export function incompatibleChanges(before: ExportDescription, after: ExportDesc
150
150
 
151
151
  /**
152
152
  * What `after` breaks for the callers of each mount `before` promises `compatibility: 'backward'`: a procedure it
153
- * removes, an input or a query that accepts less, another output, another HTTP shape (method, path, body codec), file
154
- * answer or access (its kind, callers, requirement and cross-origin form), or a mount moved. The promise binds the
153
+ * removes (including its generated challenge endpoint), an input or a query that accepts less, another output, another
154
+ * HTTP shape (method, path, body codec), file answer or access (its kind, callers, requirement and cross-origin form), or a
155
+ * mount moved. The promise binds the
155
156
  * publication that drops it too, so a breaking change is a new mount beside the old one. Empty when compatible.
156
157
  */
157
158
  export function incompatibleMountChanges(before: ContractArtifact, after: ContractArtifact): string[] {
@@ -165,9 +166,10 @@ export function incompatibleMountChanges(before: ContractArtifact, after: Contra
165
166
  problems.push(`mount '${name}' promises backward compatibility, and this deploy removes or moves it`)
166
167
  continue
167
168
  }
168
- for (const row of before.endpoints.filter((endpoint) => isMountProcedure(endpoint, name))) {
169
- const next = after.endpoints.find((candidate) => candidate.id === row.id)
170
- if (next === undefined || !isMountProcedure(next, name)) {
169
+ const current = mountEndpoints(after, name)
170
+ for (const row of mountEndpoints(before, name)) {
171
+ const next = current.find((candidate) => candidate.id === row.id)
172
+ if (next === undefined) {
171
173
  problems.push(`removes '${row.id}'`)
172
174
  continue
173
175
  }
@@ -177,6 +179,12 @@ export function incompatibleMountChanges(before: ContractArtifact, after: Contra
177
179
  return problems
178
180
  }
179
181
 
182
+ /** A challenge URL is embedded in the mount's clients too, even though its row is outside the mount's prefix. */
183
+ function mountEndpoints(artifact: ContractArtifact, mount: string): EndpointRow[] {
184
+ const procedures = new Set(artifact.endpoints.filter((row) => isMountProcedure(row, mount)).map((row) => row.id))
185
+ return artifact.endpoints.filter((row) => procedures.has(row.id) || (row.source.kind === 'challenge' && procedures.has(row.source.name)))
186
+ }
187
+
180
188
  function isMountProcedure(row: EndpointRow, mount: string): boolean {
181
189
  return row.source.kind === 'mount' && row.source.name === mount && (row.kind === 'query' || row.kind === 'mutation' || row.kind === 'public')
182
190
  }
@@ -2,6 +2,7 @@
2
2
  // source reaches them only through `buildArtifact`, so both producers emit the same artifact.
3
3
 
4
4
  import type { BodyAdmission, ChallengeProvider } from '@fabrika/auth-core'
5
+ import { WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
5
6
 
6
7
  export const CONTRACT_DEFAULTS = {
7
8
  /** A failed job run is not retried unless the job asks for it: a retry repeats work. */
@@ -54,7 +55,7 @@ export const JOB_LIMITS = {
54
55
  /** A public mutation's buckets per minute unless it declares its own: per client and for the endpoint. */
55
56
  export const PUBLIC_MUTATION_RATE = { perClient: 10, perEndpoint: 100 } as const
56
57
 
57
- /** What Turnstile admits as a widget's `action`: at most 32 letters, digits, `_` and `-`. */
58
+ /** What a widget's `action` may be: at most 32 letters, digits, `_` and `-`, what Turnstile admits, and a path segment. */
58
59
  export const CHALLENGE_ACTION = /^[A-Za-z0-9_-]{1,32}$/
59
60
 
60
61
  /**
@@ -71,11 +72,86 @@ export const CHALLENGE_FIELD = 'challengeToken'
71
72
  /** A procedure with file fields reads `multipart/form-data`; the proxy admits that type for its row. */
72
73
  export { FORM_MULTIPART_TYPE } from '@fabrika/auth-core'
73
74
 
74
- /** Each challenge provider's fixed browser origins: its widget's script and its frame. */
75
- export const CHALLENGE_ORIGINS: Readonly<Record<ChallengeProvider, { readonly scriptSrc: readonly string[]; readonly frameSrc: readonly string[] }>> =
76
- {
77
- turnstile: { scriptSrc: ['https://challenges.cloudflare.com'], frameSrc: ['https://challenges.cloudflare.com'] },
78
- }
75
+ /**
76
+ * Each challenge provider's fixed browser sources: the origins of its widget's script and frame, and `blob:` for workers
77
+ * when the widget starts its own. ALTCHA's widget is bundled with the page and solves in workers built from `Blob`s.
78
+ */
79
+ export const CHALLENGE_ORIGINS: Readonly<
80
+ Record<ChallengeProvider, { readonly scriptSrc: readonly string[]; readonly frameSrc: readonly string[]; readonly workerSrc: readonly string[] }>
81
+ > = {
82
+ turnstile: { scriptSrc: ['https://challenges.cloudflare.com'], frameSrc: ['https://challenges.cloudflare.com'], workerSrc: [] },
83
+ altcha: { scriptSrc: [], frameSrc: [], workerSrc: [WORKER_BLOB_SOURCE] },
84
+ }
85
+
86
+ /**
87
+ * An ALTCHA challenge: PBKDF2/SHA-256 attempts of `cost` iterations each, counted up to a number drawn per challenge
88
+ * between `minCounter` and `maxCounter`, so a browser makes that many attempts, about half of `maxCounter` on average.
89
+ * The draw starts at 1: a solver that knew a higher floor would skip the attempts below it, which the widget makes.
90
+ * `cost` and `maxAgeMs` are the contract's (`challenge.cost`, `challenge.maxAgeMs`), each with its default and its
91
+ * bounds, and together they must leave a slow phone time to solve (`altchaSolvable`). The HMAC key is at least
92
+ * `minKeyLength` characters: every issued challenge publishes a signature a short key could be guessed from. A solved
93
+ * payload is base64 JSON of the challenge and its solution, at most `maxTokenLength` characters.
94
+ */
95
+ export const ALTCHA_LIMITS = {
96
+ algorithm: 'PBKDF2/SHA-256',
97
+ cost: 5000,
98
+ minCost: 1000,
99
+ maxCost: 18_000,
100
+ minCounter: 1,
101
+ maxCounter: 10_000,
102
+ maxAgeMs: 10 * 60_000,
103
+ minMaxAgeMs: 60_000,
104
+ maxMaxAgeMs: 60 * 60_000,
105
+ maxTokenLength: 4096,
106
+ minKeyLength: 32,
107
+ /** What a slow phone's widget runs over its workers, PBKDF2 iterations a second: the rate the worst case is sized by. */
108
+ slowIterationsPerSecond: 2_000_000,
109
+ /** How long the widget tries before it gives up. */
110
+ solveTimeoutMs: 90_000,
111
+ } as const
112
+
113
+ /** How long a slow phone takes for the worst draw of a challenge of `cost`, in ms. */
114
+ export function altchaWorstSolveMs(cost: number): number {
115
+ return Math.ceil((cost * ALTCHA_LIMITS.maxCounter * 1000) / ALTCHA_LIMITS.slowIterationsPerSecond)
116
+ }
117
+
118
+ /** Whether a slow phone solves the worst draw before the widget gives up and within half the challenge's lifetime. */
119
+ export function altchaSolvable(cost: number, maxAgeMs: number): boolean {
120
+ return altchaWorstSolveMs(cost) <= Math.min(ALTCHA_LIMITS.solveTimeoutMs, maxAgeMs / 2)
121
+ }
122
+
123
+ /**
124
+ * The `action` a procedure's challenge expects back: the declared one, else the endpoint id with `_` for `.`. The build
125
+ * and the typed client both read it here, so the client's challenge path is the artifact's.
126
+ */
127
+ export function declaredChallengeAction(endpoint: string, declared: unknown): string {
128
+ return typeof declared === 'string' ? declared : challengeAction(endpoint)
129
+ }
130
+
131
+ /** Where an ALTCHA challenge is issued: one path per procedure, under its action. */
132
+ export const CHALLENGE_PREFIX = '/__fabrika/challenge'
133
+
134
+ export function challengePath(action: string): string {
135
+ return `${CHALLENGE_PREFIX}/${action}`
136
+ }
137
+
138
+ /** The endpoint id of the row that issues the challenge of public procedure `endpoint`. */
139
+ export function challengeRowId(endpoint: string): string {
140
+ return `${endpoint}.challenge`
141
+ }
142
+
143
+ /**
144
+ * The buckets per minute of the endpoint that issues a challenge: a visitor fetches one per form shown and one more per
145
+ * expiry, and every issue costs the server one attempt's work. Each is at least this, and at least twice the
146
+ * procedure's own bucket, so the issuer never admits fewer submissions than the procedure declares. A bucket the
147
+ * procedure turns off (`null`) is this one on the issuer: the procedure's handler may be cheap, the issue never is.
148
+ */
149
+ export const CHALLENGE_ISSUE_RATE = { perClient: 30, perEndpoint: 600 } as const
150
+
151
+ /** The issuer's bucket for the procedure's `own`, within the ceiling of every bucket. */
152
+ export function challengeIssueBucket(own: number | null, floor: number): number {
153
+ return Math.min(PROCEDURE_LIMITS.maxPerMinute, Math.max(floor, 2 * (own ?? 0)))
154
+ }
79
155
 
80
156
  /**
81
157
  * The bounds of a file kind: the largest file a `scan: true` kind admits (the scanner reads at most this much of one
@@ -303,12 +303,13 @@ export interface Surface extends SurfaceSections {
303
303
  readonly [name: string]: AnyMount | SurfaceSections[keyof SurfaceSections]
304
304
  }
305
305
 
306
- /** A public procedure's challenge: the provider and the application variables that hold its secret and its public site key. */
307
- export interface ChallengeOption {
306
+ /** A public procedure's challenge, by provider: Turnstile, verified at Cloudflare, or ALTCHA, which the application issues and verifies. */
307
+ export type ChallengeOption = TurnstileChallengeOption | AltchaChallengeOption
308
+
309
+ interface ChallengeOptionBase {
308
310
  readonly provider: ChallengeProvider
311
+ /** The application variable that holds the provider's secret (Turnstile's) or the HMAC key (ALTCHA's): a name, never the value. */
309
312
  readonly secret: string
310
- /** Served by every SPA mount at `<prefix>/__fabrika-config.json`, so the page renders the widget with it. */
311
- readonly siteKey?: string
312
313
  /**
313
314
  * The widget's `action`, which the verification expects back: at most 32 letters, digits, `_` and `-`. The endpoint id
314
315
  * with `_` for `.` by default (`api_visitors_register`).
@@ -316,6 +317,24 @@ export interface ChallengeOption {
316
317
  readonly action?: string
317
318
  }
318
319
 
320
+ export interface TurnstileChallengeOption extends ChallengeOptionBase {
321
+ readonly provider: 'turnstile'
322
+ /** Served by every SPA mount at `<prefix>/__fabrika-config.json`, so the page renders the widget with it. */
323
+ readonly siteKey?: string
324
+ }
325
+
326
+ /** The page fetches its challenge from `GET /__fabrika/challenge/<action>` (`challengeUrl` of `@fabrika/app/client`). */
327
+ export interface AltchaChallengeOption extends ChallengeOptionBase {
328
+ readonly provider: 'altcha'
329
+ /**
330
+ * The PBKDF2 iterations of one attempt, 1000 to 18000; 5000 by default. A browser makes up to 10000 attempts, about
331
+ * 5000 on average, and the build refuses a cost a slow phone would not solve within 90 s or half of `maxAgeMs`.
332
+ */
333
+ readonly cost?: number
334
+ /** How long a challenge may be solved and submitted, in ms, 60 s to an hour; ten minutes by default. */
335
+ readonly maxAgeMs?: number
336
+ }
337
+
319
338
  export interface LinkKit<A extends string> {
320
339
  query<const Acc extends LinkAccess<A>, O extends Schema, I extends InputSchema | null = null>(
321
340
  def: ProcedureDef<I, O, Acc>,
@@ -32,7 +32,7 @@ import type { FileObjectStore } from '@fabrika/platform'
32
32
  import { compileContract } from '../contract/compile'
33
33
  import { exportDescription } from '../contract/connect'
34
34
  import { isProcedure, type ProcedureDeclaration, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
35
- import { exportBase, spaPrefixRowId } from '../contract/defaults'
35
+ import { challengeRowId, exportBase, spaPrefixRowId } from '../contract/defaults'
36
36
  import type { AnyContract, Schema } from '../contract/define'
37
37
  import { kitOriginOf } from '../contract/handled'
38
38
  import { schemaFromShape } from '../contract/schema-from-shape'
@@ -45,7 +45,7 @@ import { bindJobs, type ContractJobs, type HandlerErrorSink } from '../jobs/runn
45
45
  import { environmentScrubber } from '../jobs/scrub'
46
46
  import { browserConfigs } from './browser-config'
47
47
  import { createCapabilityIssuer, requestIsReadOnly } from './capability'
48
- import type { ChallengeFetch } from './challenge'
48
+ import type { ChallengeFetch, ChallengeWork } from './challenge'
49
49
  import { type ConnectFetch, wireImports } from './connect'
50
50
  import {
51
51
  ContractRuntime,
@@ -120,6 +120,11 @@ export interface BootOptions {
120
120
  * Turnstile's always-passing test widget hands out for every call. Every other token stays single use.
121
121
  */
122
122
  readonly repeatableChallengeToken?: string
123
+ /**
124
+ * The work of every ALTCHA challenge in place of the contract's `cost`: the test kit's and `fabrika app dev`'s
125
+ * (`MINIMAL_CHALLENGE_WORK`), so a test solves one in milliseconds. A deployed application never sets it.
126
+ */
127
+ readonly challengeWork?: ChallengeWork
123
128
  /**
124
129
  * The developer's own machine only (`fabrika app dev`): what a job's handler threw, which every log of a deployed
125
130
  * application withholds because it may carry a credential.
@@ -545,7 +550,33 @@ function admissionOptions(options: BootOptions): AdmissionOptions {
545
550
  application: options.environment.application,
546
551
  ...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
547
552
  ...(options.repeatableChallengeToken === undefined ? {} : { repeatableChallengeToken: options.repeatableChallengeToken }),
553
+ ...(options.challengeWork === undefined ? {} : { challengeWork: options.challengeWork }),
554
+ }
555
+ }
556
+
557
+ /**
558
+ * The framework's own endpoint that issues the ALTCHA challenge of a served public procedure, with its own buckets;
559
+ * `null` for a procedure without one.
560
+ */
561
+ function challengeIssuerEndpoint(
562
+ procedure: Extract<ServedEndpoint, { kind: 'public' }>,
563
+ rows: ReadonlyMap<string, EndpointRow>,
564
+ artifact: ContractArtifact,
565
+ options: BootOptions,
566
+ problems: string[],
567
+ ): ServedEndpoint | null {
568
+ const issuer = procedure.admission.challenge?.issuer ?? null
569
+ if (issuer === null) {
570
+ return null
548
571
  }
572
+ const id = challengeRowId(procedure.row.id)
573
+ const row = rows.get(id)
574
+ if (row === undefined || row.source.kind !== 'challenge') {
575
+ problems.push(`public procedure '${procedure.row.id}' has no endpoint that issues its challenge`)
576
+ return null
577
+ }
578
+ const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
579
+ return admission === null ? null : { kind: 'challenge', row, issuer, admission }
549
580
  }
550
581
 
551
582
  /**
@@ -560,7 +591,7 @@ function publicEndpoint(
560
591
  workloads: ReadonlyMap<string, WorkloadCredential>,
561
592
  options: BootOptions,
562
593
  problems: string[],
563
- ): ServedEndpoint | null {
594
+ ): Extract<ServedEndpoint, { kind: 'public' }> | null {
564
595
  const where = `public procedure '${row.id}'`
565
596
  const site = bound === null ? undefined : servedSite(procedure.type, bound)
566
597
  if (bound === null || site === undefined) {
@@ -729,6 +760,10 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
729
760
  const served = publicEndpoint(row, procedure, bound, artifact, workloads, options, problems)
730
761
  if (served !== null) {
731
762
  endpoints.set(id, served)
763
+ const issuer = challengeIssuerEndpoint(served, rows, artifact, options, problems)
764
+ if (issuer !== null) {
765
+ endpoints.set(issuer.row.id, issuer)
766
+ }
732
767
  }
733
768
  continue
734
769
  }
@@ -35,7 +35,8 @@ export function browserConfigs(
35
35
  const siteKeys: Record<string, string> = {}
36
36
  for (const [id, procedure] of Object.entries(artifact.procedures ?? {})) {
37
37
  const challenge = procedure.challenge
38
- if (challenge?.siteKey === undefined) continue
38
+ // Only Turnstile's widget needs a key in the page; ALTCHA's fetches its challenge from the application.
39
+ if (challenge?.provider !== 'turnstile' || challenge.siteKey === undefined) continue
39
40
  const value = valueOf(challenge.siteKey)
40
41
  if (value === null) problems.push(`procedure '${id}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
41
42
  else siteKeys[challenge.siteKey] = value