@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/src/contract/build.ts
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
-
|
|
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 <
|
|
1132
|
-
this.#problem(where, `${field} ${JSON.stringify(value)} is not a whole number of ${unit} from
|
|
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. */
|
package/src/contract/connect.ts
CHANGED
|
@@ -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
|
|
154
|
-
* answer or access (its kind, callers, requirement and cross-origin form), or a
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
}
|
package/src/contract/defaults.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
package/src/contract/define.ts
CHANGED
|
@@ -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
|
|
307
|
-
export
|
|
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>,
|
package/src/implement/boot.ts
CHANGED
|
@@ -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
|
-
|
|
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
|