@next-buildr/payload 2.0.1 → 3.0.0-rc.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 (52) hide show
  1. package/README.md +2 -0
  2. package/UPGRADING.md +322 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/plugin/access.d.ts +7 -0
  5. package/dist/plugin/access.d.ts.map +1 -1
  6. package/dist/plugin/access.js +19 -0
  7. package/dist/plugin/collections/templates.d.ts.map +1 -1
  8. package/dist/plugin/collections/templates.js +9 -3
  9. package/dist/plugin/endpoints/context.d.ts +8 -0
  10. package/dist/plugin/endpoints/context.d.ts.map +1 -1
  11. package/dist/plugin/endpoints/forms-captcha.test.d.ts +2 -0
  12. package/dist/plugin/endpoints/forms-captcha.test.d.ts.map +1 -0
  13. package/dist/plugin/endpoints/forms-captcha.test.js +676 -0
  14. package/dist/plugin/endpoints/forms.d.ts.map +1 -1
  15. package/dist/plugin/endpoints/forms.js +110 -12
  16. package/dist/plugin/endpoints/publish.d.ts.map +1 -1
  17. package/dist/plugin/endpoints/publish.js +4 -0
  18. package/dist/plugin/endpoints/save.d.ts.map +1 -1
  19. package/dist/plugin/endpoints/save.js +3 -0
  20. package/dist/plugin/forms/captcha.d.ts +45 -0
  21. package/dist/plugin/forms/captcha.d.ts.map +1 -0
  22. package/dist/plugin/forms/captcha.js +195 -0
  23. package/dist/plugin/forms/captcha.test.d.ts +2 -0
  24. package/dist/plugin/forms/captcha.test.d.ts.map +1 -0
  25. package/dist/plugin/forms/captcha.test.js +262 -0
  26. package/dist/plugin/forms/client-ip.d.ts +8 -0
  27. package/dist/plugin/forms/client-ip.d.ts.map +1 -0
  28. package/dist/plugin/forms/client-ip.js +41 -0
  29. package/dist/plugin/forms/client-ip.test.d.ts +2 -0
  30. package/dist/plugin/forms/client-ip.test.d.ts.map +1 -0
  31. package/dist/plugin/forms/client-ip.test.js +45 -0
  32. package/dist/plugin/forms/rate-limit.d.ts +2 -0
  33. package/dist/plugin/forms/rate-limit.d.ts.map +1 -1
  34. package/dist/plugin/forms/rate-limit.js +10 -1
  35. package/dist/plugin/hooks/author-code.test.d.ts +2 -0
  36. package/dist/plugin/hooks/author-code.test.d.ts.map +1 -0
  37. package/dist/plugin/hooks/author-code.test.js +285 -0
  38. package/dist/plugin/hooks/layout-hook.d.ts +1 -1
  39. package/dist/plugin/hooks/layout-hook.d.ts.map +1 -1
  40. package/dist/plugin/hooks/layout-hook.js +12 -2
  41. package/dist/plugin/hooks/process-layout.d.ts +10 -0
  42. package/dist/plugin/hooks/process-layout.d.ts.map +1 -1
  43. package/dist/plugin/hooks/process-layout.js +94 -1
  44. package/dist/plugin/hooks/read-layout.d.ts +1 -1
  45. package/dist/plugin/hooks/read-layout.d.ts.map +1 -1
  46. package/dist/plugin/index.d.ts.map +1 -1
  47. package/dist/plugin/index.js +20 -1
  48. package/dist/plugin/options.d.ts +11 -0
  49. package/dist/plugin/options.d.ts.map +1 -1
  50. package/dist/plugin/options.js +49 -1
  51. package/dist/plugin/plugin.test.js +81 -2
  52. package/package.json +12 -11
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import { deriveFormSchema, MAX_FORM_FIELDS } from '@next-buildr/core';
3
3
  import { resolveLayout, TEMPLATES_COLLECTION } from '../../data/resolve-layout.js';
4
4
  import { FORM_SUBMISSIONS_COLLECTION } from '../collections/form-submissions.js';
5
+ import { ancestorFormHasCaptcha, CAPTCHA_ACTION, CAPTCHA_FIELD, captchaCData, findFormCaptchas, formSubtreeNodes, isCaptchaToken, } from '../forms/captcha.js';
6
+ import { clientIpOf } from '../forms/client-ip.js';
5
7
  import { isAllowedRecipient } from '../forms/notify.js';
6
8
  import { validateSubmission } from '../forms/validate.js';
7
9
  import { readLayout } from '../hooks/read-layout.js';
@@ -53,9 +55,8 @@ async function readBody(req) {
53
55
  return { ok: false, response: fail(415, 'The content type must be a form or JSON.') };
54
56
  }
55
57
  /** The visitor's address, hashed with the server secret: enough to rate-limit, not to identify. */
56
- function ipHashOf(req) {
57
- const forwarded = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
58
- const ip = forwarded || req.headers.get('x-real-ip') || 'unknown';
58
+ function ipHashOf(req, source) {
59
+ const ip = clientIpOf(req, source);
59
60
  return createHash('sha256').update(`${req.payload.secret}:${ip}`).digest('hex').slice(0, 32);
60
61
  }
61
62
  /** Back to the page the form was on (same origin only, so the redirect can never leave the site). */
@@ -101,7 +102,7 @@ export const formsEndpoint = (env) => ({
101
102
  const locale = chooseLocale(req, req.searchParams?.get('locale'));
102
103
  if (!locale.ok)
103
104
  return locale.response;
104
- const ipHash = ipHashOf(req);
105
+ const ipHash = ipHashOf(req, env.options.forms.clientIp);
105
106
  const limited = await env.rateLimiter?.hit(`${ipHash}:${collection}:${id}:${nodeId}`);
106
107
  if (limited !== undefined && !limited.allowed) {
107
108
  const response = fail(429, 'Too many submissions. Try again later.');
@@ -119,7 +120,7 @@ export const formsEndpoint = (env) => ({
119
120
  if (trap !== undefined && !(typeof trap === 'string' && trap === '')) {
120
121
  return done('sent') ?? json({ ok: true });
121
122
  }
122
- if (Object.keys(values).length > MAX_FORM_FIELDS)
123
+ if (Object.keys(values).filter((name) => name !== CAPTCHA_FIELD).length > MAX_FORM_FIELDS)
123
124
  return fail(422, 'Too many fields.');
124
125
  // Only the published state is public: a draft, or a document never published, has no form.
125
126
  let stored;
@@ -137,17 +138,20 @@ export const formsEndpoint = (env) => ({
137
138
  }
138
139
  if (stored['_status'] !== 'published')
139
140
  return fail(404, 'The form was not found.');
140
- const layout = isTemplate
141
- ? stored['layout']
142
- : (await resolveLayout({
141
+ const resolved = isTemplate
142
+ ? {
143
+ layout: stored['layout'],
144
+ layoutRef: stored['id'] === undefined ? null : `${TEMPLATES_COLLECTION}:${String(stored['id'])}`,
145
+ }
146
+ : await resolveLayout({
143
147
  payload: req.payload,
144
148
  collection,
145
149
  doc: stored,
146
150
  contextName: own?.context ?? collection,
147
151
  draft: false,
148
152
  overrideAccess: true,
149
- })).layout;
150
- const read = readLayout(layout, env.options);
153
+ });
154
+ const read = readLayout(resolved.layout, env.options);
151
155
  const form = read.ok && Object.hasOwn(read.doc.nodes, nodeId) ? read.doc.nodes[nodeId] : undefined;
152
156
  if (!read.ok ||
153
157
  form === undefined ||
@@ -155,8 +159,102 @@ export const formsEndpoint = (env) => ({
155
159
  env.options.registry === undefined) {
156
160
  return fail(404, 'The form was not found.');
157
161
  }
158
- const { schema } = deriveFormSchema(read.doc, env.options.registry.meta, nodeId);
159
- const result = validateSubmission(schema, values);
162
+ const snapshot = read.doc;
163
+ const canonicalRef = resolved.layoutRef;
164
+ if (canonicalRef === null)
165
+ return fail(404, 'The form was not found.');
166
+ const captchaFailure = (unavailable) => done('invalid') ??
167
+ (unavailable
168
+ ? json({ error: 'Verification is unavailable. Try again later.', code: 'captcha-unavailable' }, 503)
169
+ : json({
170
+ errors: { [CAPTCHA_FIELD]: 'The verification failed. Try again.' },
171
+ codes: { [CAPTCHA_FIELD]: 'captcha' },
172
+ }, 422));
173
+ const configFailure = () => {
174
+ req.payload.logger.error({
175
+ msg: 'buildr: captcha configuration error',
176
+ reason: 'configuration',
177
+ });
178
+ return captchaFailure(true);
179
+ };
180
+ // Check raw author fields too: invalid metadata or the field limit must not mask this name.
181
+ const reservedName = formSubtreeNodes(snapshot, nodeId).some((node) => {
182
+ const field = env.options.registry?.meta.get(node.type)?.formField;
183
+ const name = field !== undefined && node.props !== undefined && Object.hasOwn(node.props, field.nameProp)
184
+ ? node.props[field.nameProp]
185
+ : undefined;
186
+ return name?.kind === 'static' && name.value === CAPTCHA_FIELD;
187
+ });
188
+ const captchas = findFormCaptchas(snapshot, nodeId);
189
+ if (reservedName || ancestorFormHasCaptcha(snapshot, nodeId) || captchas.length > 1)
190
+ return configFailure();
191
+ const { schema } = deriveFormSchema(snapshot, env.options.registry.meta, nodeId);
192
+ let submitted = values;
193
+ if (captchas.length === 1) {
194
+ const captcha = env.captcha;
195
+ if (captcha === undefined)
196
+ return configFailure();
197
+ const token = Object.hasOwn(values, CAPTCHA_FIELD) ? values[CAPTCHA_FIELD] : undefined;
198
+ if (!isCaptchaToken(token))
199
+ return captchaFailure(false);
200
+ const limited = await captcha.rateLimiter.hit(ipHash);
201
+ if (limited !== undefined && !limited.allowed) {
202
+ const response = fail(429, 'Too many submissions. Try again later.');
203
+ response.headers.set('retry-after', String(limited.retryAfterSeconds));
204
+ return response;
205
+ }
206
+ const release = captcha.gate.tryAcquire();
207
+ if (release === null)
208
+ return captchaFailure(true);
209
+ let verification;
210
+ let onAbort = () => { };
211
+ const signal = AbortSignal.timeout(5000);
212
+ try {
213
+ const deadline = new Promise((resolve) => {
214
+ onAbort = () => {
215
+ release();
216
+ resolve({ ok: false, reason: 'unavailable' });
217
+ };
218
+ signal.addEventListener('abort', onAbort, { once: true });
219
+ if (signal.aborted)
220
+ onAbort();
221
+ });
222
+ verification = await Promise.race([captcha.verifier.verify({ token, signal }), deadline]);
223
+ }
224
+ catch {
225
+ verification = { ok: false, reason: 'unavailable' };
226
+ }
227
+ finally {
228
+ signal.removeEventListener('abort', onAbort);
229
+ release();
230
+ }
231
+ if (verification?.ok !== true) {
232
+ const unavailable = !verification ||
233
+ (verification.reason !== 'invalid' && verification.reason !== 'expired-or-reused');
234
+ const entry = {
235
+ msg: 'buildr: captcha verification',
236
+ reason: unavailable ? 'unavailable' : verification.reason,
237
+ ...verification?.log,
238
+ };
239
+ if (unavailable)
240
+ req.payload.logger.error(entry);
241
+ else
242
+ req.payload.logger.warn(entry);
243
+ return captchaFailure(unavailable);
244
+ }
245
+ if (typeof verification.hostname !== 'string' ||
246
+ typeof verification.action !== 'string' ||
247
+ typeof verification.cdata !== 'string' ||
248
+ !captcha.hostnames.includes(verification.hostname.toLowerCase()) ||
249
+ verification.action !== CAPTCHA_ACTION ||
250
+ verification.cdata !== captchaCData(canonicalRef, nodeId)) {
251
+ req.payload.logger.warn({ msg: 'buildr: captcha verification', reason: 'mismatch' });
252
+ return captchaFailure(false);
253
+ }
254
+ const { [CAPTCHA_FIELD]: _token, ...clean } = values;
255
+ submitted = clean;
256
+ }
257
+ const result = validateSubmission(schema, submitted);
160
258
  if (!result.ok) {
161
259
  if (!wantsJson)
162
260
  return backTo(req, 'invalid');
@@ -1 +1 @@
1
- {"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/publish.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAIxC,OAAO,EAGL,KAAK,WAAW,EAOjB,MAAM,cAAc,CAAC;AAItB;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAS,WAAW,KAAG,QAiEjD,CAAC"}
1
+ {"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/publish.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC,OAAO,EAGL,KAAK,WAAW,EAOjB,MAAM,cAAc,CAAC;AAItB;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAS,WAAW,KAAG,QAoEjD,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { runA11y } from '@next-buildr/core';
2
2
  import { publishRequestSchema, publishResponseSchema } from '../../contract.js';
3
+ import { authorCodePermitted } from '../access.js';
3
4
  import { processLayout } from '../hooks/process-layout.js';
4
5
  import { configuredLocales } from '../locales.js';
5
6
  import { allowed, bodyOf, latestOf, revisionOf, targetOf, updatedBy, writeContext, writeLimit, } from './context.js';
@@ -38,9 +39,12 @@ export const publishEndpoint = (env) => ({
38
39
  const current = revisionOf(found.value);
39
40
  if (parsed.data.baseRevision !== current)
40
41
  return conflict(current);
42
+ // Publishing writes no layout: what is stored passes as it is (a save already vetted its author code).
41
43
  const layout = processLayout(found.value['layout'], {
42
44
  ...env.options,
43
45
  locales: configuredLocales(req.payload.config),
46
+ allowAuthorCode: await authorCodePermitted(env.options, req),
47
+ previous: found.value['layout'],
44
48
  });
45
49
  if (!layout.ok)
46
50
  return invalid(layout.diagnostics);
@@ -1 +1 @@
1
- {"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/save.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAIxC,OAAO,EAGL,KAAK,WAAW,EAOjB,MAAM,cAAc,CAAC;AAItB;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAAS,WAAW,KAAG,QA0C9C,CAAC"}
1
+ {"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/save.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC,OAAO,EAGL,KAAK,WAAW,EAOjB,MAAM,cAAc,CAAC;AAItB;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAAS,WAAW,KAAG,QA4C9C,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import { saveRequestSchema, saveResponseSchema } from '../../contract.js';
2
+ import { authorCodePermitted } from '../access.js';
2
3
  import { processLayout } from '../hooks/process-layout.js';
3
4
  import { configuredLocales } from '../locales.js';
4
5
  import { allowed, bodyOf, latestOf, revisionOf, targetOf, updatedBy, writeContext, writeLimit, } from './context.js';
@@ -39,6 +40,8 @@ export const saveEndpoint = (env) => ({
39
40
  const layout = processLayout(parsed.data.document, {
40
41
  ...env.options,
41
42
  locales: configuredLocales(req.payload.config),
43
+ allowAuthorCode: await authorCodePermitted(env.options, req),
44
+ previous: found.value['layout'],
42
45
  });
43
46
  if (!layout.ok)
44
47
  return invalid(layout.diagnostics);
@@ -0,0 +1,45 @@
1
+ import type { BuilderDocument, PageNode } from '@next-buildr/core';
2
+ export declare const CAPTCHA_FIELD = "buildr-captcha";
3
+ export declare const CAPTCHA_ACTION = "buildr_form";
4
+ export declare const CAPTCHA_TYPE = "buildr/captcha";
5
+ export type CaptchaVerification = {
6
+ ok: true;
7
+ hostname: string;
8
+ action: string;
9
+ cdata: string;
10
+ } | {
11
+ ok: false;
12
+ reason: 'invalid' | 'expired-or-reused' | 'unavailable';
13
+ log?: CaptchaLog;
14
+ };
15
+ export interface CaptchaVerifier {
16
+ verify(input: {
17
+ token: string;
18
+ signal: AbortSignal;
19
+ }): Promise<CaptchaVerification>;
20
+ }
21
+ export declare const isCaptchaToken: (token: unknown) => token is string;
22
+ /** Shared with the widget: hash the UTF-8 JSON tuple, never a request-derived layout reference. */
23
+ export declare function captchaCData(layoutRef: string, formNodeId: string): string;
24
+ export interface CaptchaGate {
25
+ tryAcquire(): (() => void) | null;
26
+ }
27
+ /** Each plugin instance owns its gate. Releases are idempotent (deadline and finally may race). */
28
+ export declare function createCaptchaGate(maxInFlight: number): CaptchaGate;
29
+ type CaptchaLog = {
30
+ reason: 'configuration' | 'invalid' | 'expired-or-reused' | 'unavailable';
31
+ codes: readonly string[];
32
+ };
33
+ /** A fixed origin and bounded, abortable response reader keep secret-bearing requests contained. */
34
+ export declare function createTurnstileVerifier(options: {
35
+ secretKey: string;
36
+ fetch?: typeof globalThis.fetch;
37
+ log?: (entry: CaptchaLog) => void;
38
+ }): CaptchaVerifier;
39
+ /** Literal graph traversal: registry metadata and render-time conditions cannot waive a challenge. */
40
+ export declare function formSubtreeNodes(snapshot: BuilderDocument, formNodeId: string): PageNode[];
41
+ export declare function findFormCaptchas(snapshot: BuilderDocument, formNodeId: string): PageNode[];
42
+ /** All reverse edges count, including corrupt multiple-parent graphs; cycles cannot hide an ancestor. */
43
+ export declare function ancestorFormHasCaptcha(snapshot: BuilderDocument, formNodeId: string): boolean;
44
+ export {};
45
+ //# sourceMappingURL=captcha.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"captcha.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/captcha.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAGnE,eAAO,MAAM,aAAa,mBAAmB,CAAC;AAC9C,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAC5C,eAAO,MAAM,YAAY,mBAAmB,CAAC;AAO7C,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC7D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,mBAAmB,GAAG,aAAa,CAAC;IAAC,GAAG,CAAC,EAAE,UAAU,CAAA;CAAE,CAAC;AAE7F,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACrF;AAED,eAAO,MAAM,cAAc,UAAW,OAAO,KAAG,KAAK,IAAI,MACM,CAAC;AAEhE,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAI1E;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;CACnC;AAED,mGAAmG;AACnG,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,CAclE;AASD,KAAK,UAAU,GAAG;IAChB,MAAM,EAAE,eAAe,GAAG,SAAS,GAAG,mBAAmB,GAAG,aAAa,CAAC;IAC1E,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B,CAAC;AAGF,oGAAoG;AACpG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACnC,GAAG,eAAe,CAgGlB;AAED,sGAAsG;AACtG,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAgB1F;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAE1F;AAED,yGAAyG;AACzG,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAsB7F"}
@@ -0,0 +1,195 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ export const CAPTCHA_FIELD = 'buildr-captcha';
4
+ export const CAPTCHA_ACTION = 'buildr_form';
5
+ export const CAPTCHA_TYPE = 'buildr/captcha';
6
+ const FORM_TYPE = 'buildr/form';
7
+ const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
8
+ const TOKEN = /^[A-Za-z0-9._-]{1,2048}$/;
9
+ const CODE = /^[a-z0-9-]{1,64}$/;
10
+ const MAX_RESPONSE_BYTES = 16 * 1024;
11
+ export const isCaptchaToken = (token) => typeof token === 'string' && TOKEN.exec(token)?.[0] === token;
12
+ /** Shared with the widget: hash the UTF-8 JSON tuple, never a request-derived layout reference. */
13
+ export function captchaCData(layoutRef, formNodeId) {
14
+ return createHash('sha256')
15
+ .update(JSON.stringify([CAPTCHA_TYPE, 1, layoutRef, formNodeId]), 'utf8')
16
+ .digest('hex');
17
+ }
18
+ /** Each plugin instance owns its gate. Releases are idempotent (deadline and finally may race). */
19
+ export function createCaptchaGate(maxInFlight) {
20
+ let active = 0;
21
+ return {
22
+ tryAcquire() {
23
+ if (active >= maxInFlight)
24
+ return null;
25
+ active += 1;
26
+ let released = false;
27
+ return () => {
28
+ if (released)
29
+ return;
30
+ released = true;
31
+ active -= 1;
32
+ };
33
+ },
34
+ };
35
+ }
36
+ const responseSchema = z.object({
37
+ success: z.boolean(),
38
+ 'error-codes': z.array(z.string()).optional(),
39
+ hostname: z.string().optional(),
40
+ action: z.string().optional(),
41
+ cdata: z.string().optional(),
42
+ });
43
+ const unavailable = () => ({ ok: false, reason: 'unavailable' });
44
+ /** A fixed origin and bounded, abortable response reader keep secret-bearing requests contained. */
45
+ export function createTurnstileVerifier(options) {
46
+ const fetcher = options.fetch ?? globalThis.fetch;
47
+ return {
48
+ async verify({ token, signal }) {
49
+ if (!isCaptchaToken(token))
50
+ return { ok: false, reason: 'invalid' };
51
+ if (signal.aborted)
52
+ return unavailable();
53
+ let reader;
54
+ let onAbort = () => { };
55
+ const cancelled = new Promise((resolve) => {
56
+ onAbort = () => {
57
+ void reader?.cancel().catch(() => { });
58
+ resolve(unavailable());
59
+ };
60
+ signal.addEventListener('abort', onAbort, { once: true });
61
+ });
62
+ const run = async () => {
63
+ try {
64
+ const response = await fetcher(SITEVERIFY, {
65
+ method: 'POST',
66
+ body: new URLSearchParams({ secret: options.secretKey, response: token }),
67
+ redirect: 'error',
68
+ cache: 'no-store',
69
+ signal,
70
+ });
71
+ if (!response.ok || response.body === null || signal.aborted) {
72
+ void response.body?.cancel().catch(() => { });
73
+ return unavailable();
74
+ }
75
+ reader = response.body.getReader();
76
+ const chunks = [];
77
+ let size = 0;
78
+ while (true) {
79
+ const chunk = await reader.read();
80
+ if (signal.aborted)
81
+ return unavailable();
82
+ if (chunk.done)
83
+ break;
84
+ size += chunk.value.byteLength;
85
+ if (size > MAX_RESPONSE_BYTES) {
86
+ void reader.cancel().catch(() => { });
87
+ return unavailable();
88
+ }
89
+ chunks.push(chunk.value);
90
+ }
91
+ const bytes = new Uint8Array(size);
92
+ let offset = 0;
93
+ for (const chunk of chunks) {
94
+ bytes.set(chunk, offset);
95
+ offset += chunk.byteLength;
96
+ }
97
+ const parsed = responseSchema.safeParse(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
98
+ if (!parsed.success)
99
+ return unavailable();
100
+ const body = parsed.data;
101
+ if (body.success) {
102
+ if (typeof body.hostname !== 'string' || typeof body.action !== 'string')
103
+ return unavailable();
104
+ return {
105
+ ok: true,
106
+ hostname: body.hostname,
107
+ action: body.action,
108
+ cdata: body.cdata ?? '',
109
+ };
110
+ }
111
+ const codes = body['error-codes'] ?? [];
112
+ const config = codes.includes('missing-input-secret') || codes.includes('invalid-input-secret');
113
+ const reason = config || codes.includes('internal-error')
114
+ ? 'unavailable'
115
+ : codes.includes('timeout-or-duplicate')
116
+ ? 'expired-or-reused'
117
+ : 'invalid';
118
+ const log = {
119
+ reason: config ? 'configuration' : reason,
120
+ codes: codes
121
+ .filter((code) => CODE.exec(code)?.[0] === code &&
122
+ !code.includes(token) &&
123
+ !code.includes(options.secretKey))
124
+ .slice(0, 10),
125
+ };
126
+ options.log?.(log);
127
+ return { ok: false, reason, ...(options.log === undefined ? { log } : {}) };
128
+ }
129
+ catch {
130
+ return unavailable();
131
+ }
132
+ };
133
+ try {
134
+ return await Promise.race([run(), cancelled]);
135
+ }
136
+ finally {
137
+ signal.removeEventListener('abort', onAbort);
138
+ }
139
+ },
140
+ };
141
+ }
142
+ /** Literal graph traversal: registry metadata and render-time conditions cannot waive a challenge. */
143
+ export function formSubtreeNodes(snapshot, formNodeId) {
144
+ const visited = new Set();
145
+ const stack = [formNodeId];
146
+ const nodes = [];
147
+ while (stack.length > 0) {
148
+ const id = stack.pop();
149
+ if (id === undefined || visited.has(id))
150
+ continue;
151
+ visited.add(id);
152
+ const node = Object.hasOwn(snapshot.nodes, id) ? snapshot.nodes[id] : undefined;
153
+ if (node === undefined || (id !== formNodeId && node.type === FORM_TYPE))
154
+ continue;
155
+ if (id !== formNodeId)
156
+ nodes.push(node);
157
+ for (const children of Object.values(node.slots ?? {})) {
158
+ for (const child of children)
159
+ stack.push(child);
160
+ }
161
+ }
162
+ return nodes;
163
+ }
164
+ export function findFormCaptchas(snapshot, formNodeId) {
165
+ return formSubtreeNodes(snapshot, formNodeId).filter((node) => node.type === CAPTCHA_TYPE);
166
+ }
167
+ /** All reverse edges count, including corrupt multiple-parent graphs; cycles cannot hide an ancestor. */
168
+ export function ancestorFormHasCaptcha(snapshot, formNodeId) {
169
+ const parents = new Map();
170
+ for (const [id, node] of Object.entries(snapshot.nodes)) {
171
+ for (const children of Object.values(node.slots ?? {})) {
172
+ for (const child of children) {
173
+ const list = parents.get(child);
174
+ if (list === undefined)
175
+ parents.set(child, [id]);
176
+ else
177
+ list.push(id);
178
+ }
179
+ }
180
+ }
181
+ const visited = new Set([formNodeId]);
182
+ const stack = [...(parents.get(formNodeId) ?? [])];
183
+ while (stack.length > 0) {
184
+ const id = stack.pop();
185
+ if (id === undefined || visited.has(id))
186
+ continue;
187
+ visited.add(id);
188
+ const node = Object.hasOwn(snapshot.nodes, id) ? snapshot.nodes[id] : undefined;
189
+ if (node?.type === FORM_TYPE && findFormCaptchas(snapshot, id).length > 0)
190
+ return true;
191
+ for (const parent of parents.get(id) ?? [])
192
+ stack.push(parent);
193
+ }
194
+ return false;
195
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=captcha.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"captcha.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/captcha.test.ts"],"names":[],"mappings":""}