better-ship 0.26.0 → 0.28.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.
- package/dist/cloudflare/rate-limiter.d.ts +34 -0
- package/dist/cloudflare/rate-limiter.d.ts.map +1 -0
- package/dist/cloudflare/rate-limiter.js +35 -0
- package/dist/cloudflare/rate-limiter.js.map +1 -0
- package/dist/cloudflare/turnstile.d.ts +57 -0
- package/dist/cloudflare/turnstile.d.ts.map +1 -0
- package/dist/cloudflare/turnstile.js +191 -0
- package/dist/cloudflare/turnstile.js.map +1 -0
- package/package.json +3 -1
- package/src/infrastructure/cloudflare/rate-limiter.ts +57 -0
- package/src/infrastructure/cloudflare/turnstile/index.ts +7 -0
- package/src/infrastructure/cloudflare/turnstile/turnstile.error.ts +21 -0
- package/src/infrastructure/cloudflare/turnstile/turnstile.ts +261 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Xt as AppError } from "../index-DUFS0SJ_.js";
|
|
2
|
+
//#region src/infrastructure/cloudflare/rate-limiter.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Caller limits through a Workers Rate Limiting binding. Ported from the `consume` path of
|
|
5
|
+
* `better-auth/rate-limit.ts` and Porte `auth-rate-limit.ts`. This module does not log.
|
|
6
|
+
*/
|
|
7
|
+
/** The binding threw instead of answering. A refused call is `RateLimitError`, never this error. */
|
|
8
|
+
export declare class RateLimiterUnavailableError extends AppError {
|
|
9
|
+
readonly scope: string;
|
|
10
|
+
readonly _tag = "RateLimiterUnavailableError";
|
|
11
|
+
readonly classification = "unknown";
|
|
12
|
+
constructor(scope: string, cause: unknown);
|
|
13
|
+
}
|
|
14
|
+
/** One feature's limit on a binding. The binding's `simple.limit` sets how many calls a window allows. */
|
|
15
|
+
export type RateLimiterOptions = {
|
|
16
|
+
readonly binding: Pick<RateLimit, 'limit'>;
|
|
17
|
+
/** Names the feature. Two scopes on one binding keep separate counters. */
|
|
18
|
+
readonly scope: string;
|
|
19
|
+
/** The binding's `simple.period` in seconds. Cloudflare accepts only 10 or 60. */
|
|
20
|
+
readonly window: 10 | 60;
|
|
21
|
+
};
|
|
22
|
+
/** Counts calls for each caller key inside one scope. */
|
|
23
|
+
export type RateLimiter = {
|
|
24
|
+
/**
|
|
25
|
+
* Count one call for `key`, and resolve when the binding allows it.
|
|
26
|
+
* A refused call throws `RateLimitError` with `retryAfter` set to the window.
|
|
27
|
+
* A binding failure throws `RateLimiterUnavailableError`. The call does not retry, because a retry counts again.
|
|
28
|
+
*/
|
|
29
|
+
readonly limit: (key: string) => Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
/** Bind one scope and window to a Rate Limiting binding. Build it once in the composition root. */
|
|
32
|
+
export declare function createRateLimiter(options: RateLimiterOptions): RateLimiter;
|
|
33
|
+
//#endregion
|
|
34
|
+
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/rate-limiter.ts"],"mappings":";;;;;;;qBAQa,oCAAoC;WAKpC;WAJF;WACA;EAET,YACW,eACT;;;YAOQ;WACD,SAAS,KAAK;;WAEd;;WAEA;;;YAIC;;;;;;WAMD,QAAQ,gBAAgB;;;wBAInB,kBAAkB,SAAS,qBAAqB"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { U as RateLimitError, nt as AppError } from "../core-BiN6S_cm.js";
|
|
2
|
+
|
|
3
|
+
//#region src/infrastructure/cloudflare/rate-limiter.ts
|
|
4
|
+
/**
|
|
5
|
+
* Caller limits through a Workers Rate Limiting binding. Ported from the `consume` path of
|
|
6
|
+
* `better-auth/rate-limit.ts` and Porte `auth-rate-limit.ts`. This module does not log.
|
|
7
|
+
*/
|
|
8
|
+
/** The binding threw instead of answering. A refused call is `RateLimitError`, never this error. */
|
|
9
|
+
var RateLimiterUnavailableError = class extends AppError {
|
|
10
|
+
scope;
|
|
11
|
+
_tag = "RateLimiterUnavailableError";
|
|
12
|
+
classification = "unknown";
|
|
13
|
+
constructor(scope, cause) {
|
|
14
|
+
super("Rate limiter is unavailable", { cause });
|
|
15
|
+
this.scope = scope;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Bind one scope and window to a Rate Limiting binding. Build it once in the composition root. */
|
|
19
|
+
function createRateLimiter(options) {
|
|
20
|
+
return { async limit(key) {
|
|
21
|
+
const { success } = await consume(options, key);
|
|
22
|
+
if (!success) throw new RateLimitError(options.window);
|
|
23
|
+
} };
|
|
24
|
+
}
|
|
25
|
+
async function consume(options, key) {
|
|
26
|
+
try {
|
|
27
|
+
return await options.binding.limit({ key: JSON.stringify([options.scope, key]) });
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
throw new RateLimiterUnavailableError(options.scope, cause);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { RateLimiterUnavailableError, createRateLimiter };
|
|
35
|
+
//# sourceMappingURL=rate-limiter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.js","names":[],"sources":["../../src/infrastructure/cloudflare/rate-limiter.ts"],"sourcesContent":["import { AppError, RateLimitError } from '@/core'\n\n/**\n * Caller limits through a Workers Rate Limiting binding. Ported from the `consume` path of\n * `better-auth/rate-limit.ts` and Porte `auth-rate-limit.ts`. This module does not log.\n */\n\n/** The binding threw instead of answering. A refused call is `RateLimitError`, never this error. */\nexport class RateLimiterUnavailableError extends AppError {\n readonly _tag = 'RateLimiterUnavailableError'\n readonly classification = 'unknown'\n\n constructor(\n readonly scope: string,\n cause: unknown,\n ) {\n super('Rate limiter is unavailable', { cause })\n }\n}\n\n/** One feature's limit on a binding. The binding's `simple.limit` sets how many calls a window allows. */\nexport type RateLimiterOptions = {\n readonly binding: Pick<RateLimit, 'limit'>\n /** Names the feature. Two scopes on one binding keep separate counters. */\n readonly scope: string\n /** The binding's `simple.period` in seconds. Cloudflare accepts only 10 or 60. */\n readonly window: 10 | 60\n}\n\n/** Counts calls for each caller key inside one scope. */\nexport type RateLimiter = {\n /**\n * Count one call for `key`, and resolve when the binding allows it.\n * A refused call throws `RateLimitError` with `retryAfter` set to the window.\n * A binding failure throws `RateLimiterUnavailableError`. The call does not retry, because a retry counts again.\n */\n readonly limit: (key: string) => Promise<void>\n}\n\n/** Bind one scope and window to a Rate Limiting binding. Build it once in the composition root. */\nexport function createRateLimiter(options: RateLimiterOptions): RateLimiter {\n return {\n async limit(key) {\n const { success } = await consume(options, key)\n if (!success) throw new RateLimitError(options.window)\n },\n }\n}\n\nasync function consume(options: RateLimiterOptions, key: string): Promise<RateLimitOutcome> {\n try {\n // A JSON pair keeps `a:b` + `c` apart from `a` + `b:c`.\n return await options.binding.limit({ key: JSON.stringify([options.scope, key]) })\n } catch (cause) {\n throw new RateLimiterUnavailableError(options.scope, cause)\n }\n}\n"],"mappings":";;;;;;;;AAQA,IAAa,8BAAb,cAAiD,SAAS;CAK7C;CAJX,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YACE,AAAS,OACT,OACA;EACA,MAAM,+BAA+B,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF;;AAsBA,SAAgB,kBAAkB,SAA0C;CAC1E,OAAO,EACL,MAAM,MAAM,KAAK;EACf,MAAM,EAAE,YAAY,MAAM,QAAQ,SAAS,GAAG;EAC9C,IAAI,CAAC,SAAS,MAAM,IAAI,eAAe,QAAQ,MAAM;CACvD,EACF;AACF;AAEA,eAAe,QAAQ,SAA6B,KAAwC;CAC1F,IAAI;EAEF,OAAO,MAAM,QAAQ,QAAQ,MAAM,EAAE,KAAK,KAAK,UAAU,CAAC,QAAQ,OAAO,GAAG,CAAC,EAAE,CAAC;CAClF,SAAS,OAAO;EACd,MAAM,IAAI,4BAA4B,QAAQ,OAAO,KAAK;CAC5D;AACF"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Xt as AppError, Zt as FailureClassification } from "../index-DUFS0SJ_.js";
|
|
2
|
+
//#region src/infrastructure/cloudflare/turnstile/turnstile.error.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Siteverify did not produce a verdict.
|
|
5
|
+
* A rejected token is not this error. The caller logs it once.
|
|
6
|
+
*/
|
|
7
|
+
export declare class TurnstileVerificationError extends AppError {
|
|
8
|
+
readonly _tag = "TurnstileVerificationError";
|
|
9
|
+
readonly classification: FailureClassification;
|
|
10
|
+
readonly errorCodes: readonly string[];
|
|
11
|
+
constructor(args: {
|
|
12
|
+
readonly classification: FailureClassification;
|
|
13
|
+
readonly errorCodes: readonly string[];
|
|
14
|
+
readonly cause: unknown;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/infrastructure/cloudflare/turnstile/turnstile.d.ts
|
|
19
|
+
/** The values siteverify needs. The secret stays in the caller. */
|
|
20
|
+
type TurnstileCheck = {
|
|
21
|
+
readonly secretKey: string;
|
|
22
|
+
readonly token: string;
|
|
23
|
+
/** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */
|
|
24
|
+
readonly remoteIp?: string;
|
|
25
|
+
/** Widget action. A present, different action rejects. A missing action does not. */
|
|
26
|
+
readonly expectedAction?: string;
|
|
27
|
+
/** Allowed widget hostnames. A testing-key success skips this list. */
|
|
28
|
+
readonly expectedHostnames?: readonly string[];
|
|
29
|
+
/** UUID reused for this call's retries. Omit it and the call mints one. */
|
|
30
|
+
readonly idempotencyKey?: string;
|
|
31
|
+
};
|
|
32
|
+
/** A solved challenge, or a refusal siteverify already decided. */
|
|
33
|
+
type TurnstileVerdict = {
|
|
34
|
+
readonly _tag: 'Accepted';
|
|
35
|
+
readonly hostname: string;
|
|
36
|
+
readonly challengeTs: string;
|
|
37
|
+
readonly action?: string;
|
|
38
|
+
} | {
|
|
39
|
+
readonly _tag: 'Rejected';
|
|
40
|
+
readonly errorCodes: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
/** Deadline and fetch overrides. Production calls omit this argument. */
|
|
43
|
+
type TurnstileVerifyOptions = {
|
|
44
|
+
/** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */
|
|
45
|
+
readonly fetch?: typeof fetch;
|
|
46
|
+
/** Siteverify deadline in milliseconds. The default is 10 seconds. */
|
|
47
|
+
readonly timeoutMs?: number;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Ask siteverify whether this token is a solved challenge.
|
|
51
|
+
* A rejection is a verdict. No verdict throws `TurnstileVerificationError`.
|
|
52
|
+
* A transient failure retries twice with the same idempotency key.
|
|
53
|
+
*/
|
|
54
|
+
export declare function verifyTurnstile(check: TurnstileCheck, options?: TurnstileVerifyOptions): Promise<TurnstileVerdict>;
|
|
55
|
+
//#endregion
|
|
56
|
+
export type { TurnstileCheck, TurnstileVerdict, TurnstileVerifyOptions };
|
|
57
|
+
//# sourceMappingURL=turnstile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turnstile.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/turnstile/turnstile.error.ts","../../src/infrastructure/cloudflare/turnstile/turnstile.ts"],"mappings":";;;;;;qBAMa,mCAAmC;WACrC;WACA,gBAAgB;WAChB;EAET,YAAY;aACD,gBAAgB;aAChB;aACA;;;;;;KCoCD;WACD;WACA;;WAEA;;WAEA;;WAEA;;WAEA;;;KAIC;WAEG;WACA;WACA;WACA;;WAGA;WACA;;;KAIH;;WAED,eAAe;;WAEf;;;;;;;wBAmBW,gBACpB,OAAO,gBACP,UAAS,yBACR,QAAQ"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { b as shouldRetryFailure, c as exponentialEqualJitterMs, nt as AppError, u as classifyBoundaryError } from "../core-BiN6S_cm.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
|
|
5
|
+
//#region src/infrastructure/cloudflare/turnstile/turnstile.error.ts
|
|
6
|
+
/**
|
|
7
|
+
* Siteverify did not produce a verdict.
|
|
8
|
+
* A rejected token is not this error. The caller logs it once.
|
|
9
|
+
*/
|
|
10
|
+
var TurnstileVerificationError = class extends AppError {
|
|
11
|
+
_tag = "TurnstileVerificationError";
|
|
12
|
+
classification;
|
|
13
|
+
errorCodes;
|
|
14
|
+
constructor(args) {
|
|
15
|
+
super("Turnstile siteverify could not produce a verdict", { cause: args.cause });
|
|
16
|
+
this.classification = args.classification;
|
|
17
|
+
this.errorCodes = args.errorCodes;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/infrastructure/cloudflare/turnstile/turnstile.ts
|
|
23
|
+
/**
|
|
24
|
+
* Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.
|
|
25
|
+
* A rejection is a verdict. This module does not log, and one call retries with one idempotency key.
|
|
26
|
+
*/
|
|
27
|
+
const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
28
|
+
const MAX_TOKEN_LENGTH = 2048;
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
30
|
+
const RETRY_TIMES = 2;
|
|
31
|
+
const CONFIGURATION_CODES = /* @__PURE__ */ new Set([
|
|
32
|
+
"missing-input-secret",
|
|
33
|
+
"invalid-input-secret",
|
|
34
|
+
"bad-request"
|
|
35
|
+
]);
|
|
36
|
+
const SuccessBody = z.object({
|
|
37
|
+
success: z.literal(true),
|
|
38
|
+
hostname: z.string(),
|
|
39
|
+
challenge_ts: z.string(),
|
|
40
|
+
action: z.string().optional(),
|
|
41
|
+
metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional()
|
|
42
|
+
});
|
|
43
|
+
const FailureBody = z.object({
|
|
44
|
+
success: z.literal(false),
|
|
45
|
+
"error-codes": z.array(z.string()).default([])
|
|
46
|
+
});
|
|
47
|
+
var SiteverifyRetry = class extends Error {
|
|
48
|
+
errorCodes;
|
|
49
|
+
_tag = "SiteverifyRetry";
|
|
50
|
+
constructor(errorCodes, cause) {
|
|
51
|
+
super("Turnstile siteverify did not answer", { cause });
|
|
52
|
+
this.errorCodes = errorCodes;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Ask siteverify whether this token is a solved challenge.
|
|
57
|
+
* A rejection is a verdict. No verdict throws `TurnstileVerificationError`.
|
|
58
|
+
* A transient failure retries twice with the same idempotency key.
|
|
59
|
+
*/
|
|
60
|
+
async function verifyTurnstile(check, options = {}) {
|
|
61
|
+
if (check.token.length > MAX_TOKEN_LENGTH) return {
|
|
62
|
+
_tag: "Rejected",
|
|
63
|
+
errorCodes: ["invalid-input-response"]
|
|
64
|
+
};
|
|
65
|
+
const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey);
|
|
66
|
+
const result = await Result.tryPromise({
|
|
67
|
+
try: () => requestVerdict(check, idempotencyKey, options),
|
|
68
|
+
catch: (cause) => cause
|
|
69
|
+
}, { retry: {
|
|
70
|
+
times: RETRY_TIMES,
|
|
71
|
+
delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),
|
|
72
|
+
shouldRetry: (cause) => cause instanceof SiteverifyRetry && shouldRetryFailure({
|
|
73
|
+
classification: "transient",
|
|
74
|
+
repeatSafe: true,
|
|
75
|
+
owner: "immediate"
|
|
76
|
+
})
|
|
77
|
+
} });
|
|
78
|
+
if (result.isOk()) return result.value;
|
|
79
|
+
if (result.error instanceof TurnstileVerificationError) throw result.error;
|
|
80
|
+
if (result.error instanceof SiteverifyRetry) throw new TurnstileVerificationError({
|
|
81
|
+
classification: "transient",
|
|
82
|
+
errorCodes: result.error.errorCodes,
|
|
83
|
+
cause: result.error.cause
|
|
84
|
+
});
|
|
85
|
+
throw new TurnstileVerificationError({
|
|
86
|
+
classification: "unknown",
|
|
87
|
+
errorCodes: [],
|
|
88
|
+
cause: result.error
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function resolveIdempotencyKey(key) {
|
|
92
|
+
if (key === void 0) return crypto.randomUUID();
|
|
93
|
+
if (z.uuid().safeParse(key).success) return key;
|
|
94
|
+
throw new TurnstileVerificationError({
|
|
95
|
+
classification: "terminal",
|
|
96
|
+
errorCodes: [],
|
|
97
|
+
cause: { idempotencyKey: "invalid" }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async function requestVerdict(check, idempotencyKey, options) {
|
|
101
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
102
|
+
const fields = {
|
|
103
|
+
secret: check.secretKey,
|
|
104
|
+
response: check.token,
|
|
105
|
+
idempotency_key: idempotencyKey
|
|
106
|
+
};
|
|
107
|
+
const payload = check.remoteIp !== void 0 && check.remoteIp !== "" ? {
|
|
108
|
+
...fields,
|
|
109
|
+
remoteip: check.remoteIp
|
|
110
|
+
} : fields;
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
response = await fetchImpl(SITEVERIFY_URL, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify(payload),
|
|
117
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
118
|
+
});
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
throw transportFailure(cause);
|
|
121
|
+
}
|
|
122
|
+
const text = await response.text();
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(text);
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
throw unparsedFailure(response.status, cause);
|
|
128
|
+
}
|
|
129
|
+
const success = SuccessBody.safeParse(parsed);
|
|
130
|
+
if (success.success) return decideSuccess(success.data, check);
|
|
131
|
+
const failure = FailureBody.safeParse(parsed);
|
|
132
|
+
if (failure.success) return decideFailure(failure.data["error-codes"], response.status);
|
|
133
|
+
throw unparsedFailure(response.status, { status: response.status });
|
|
134
|
+
}
|
|
135
|
+
function decideSuccess(body, check) {
|
|
136
|
+
if (check.expectedAction !== void 0 && body.action !== void 0 && body.action !== check.expectedAction) return {
|
|
137
|
+
_tag: "Rejected",
|
|
138
|
+
errorCodes: ["action-mismatch"]
|
|
139
|
+
};
|
|
140
|
+
if (!(body.metadata?.result_with_testing_key === true) && check.expectedHostnames !== void 0 && !check.expectedHostnames.includes(body.hostname)) return {
|
|
141
|
+
_tag: "Rejected",
|
|
142
|
+
errorCodes: ["hostname-mismatch"]
|
|
143
|
+
};
|
|
144
|
+
if (body.action === void 0) return {
|
|
145
|
+
_tag: "Accepted",
|
|
146
|
+
hostname: body.hostname,
|
|
147
|
+
challengeTs: body.challenge_ts
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
_tag: "Accepted",
|
|
151
|
+
hostname: body.hostname,
|
|
152
|
+
challengeTs: body.challenge_ts,
|
|
153
|
+
action: body.action
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function decideFailure(errorCodes, status) {
|
|
157
|
+
if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) throw verificationFailure("terminal", errorCodes, status);
|
|
158
|
+
if (errorCodes.includes("internal-error") || status === 429 || status >= 500) throw new SiteverifyRetry(errorCodes, { status });
|
|
159
|
+
return {
|
|
160
|
+
_tag: "Rejected",
|
|
161
|
+
errorCodes
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function transportFailure(cause) {
|
|
165
|
+
const classification = classifyBoundaryError({
|
|
166
|
+
cause,
|
|
167
|
+
classifyBoundary: () => void 0,
|
|
168
|
+
includeCloudflare: false
|
|
169
|
+
});
|
|
170
|
+
if (classification === "transient") return new SiteverifyRetry([], cause);
|
|
171
|
+
return new TurnstileVerificationError({
|
|
172
|
+
classification,
|
|
173
|
+
errorCodes: [],
|
|
174
|
+
cause
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function unparsedFailure(status, cause) {
|
|
178
|
+
if (status === 429 || status >= 500) return new SiteverifyRetry([], cause);
|
|
179
|
+
return verificationFailure("terminal", [], status);
|
|
180
|
+
}
|
|
181
|
+
function verificationFailure(classification, errorCodes, status) {
|
|
182
|
+
return new TurnstileVerificationError({
|
|
183
|
+
classification,
|
|
184
|
+
errorCodes,
|
|
185
|
+
cause: { status }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
export { TurnstileVerificationError, verifyTurnstile };
|
|
191
|
+
//# sourceMappingURL=turnstile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turnstile.js","names":[],"sources":["../../src/infrastructure/cloudflare/turnstile/turnstile.error.ts","../../src/infrastructure/cloudflare/turnstile/turnstile.ts"],"sourcesContent":["import { AppError, type FailureClassification } from '@/core'\n\n/**\n * Siteverify did not produce a verdict.\n * A rejected token is not this error. The caller logs it once.\n */\nexport class TurnstileVerificationError extends AppError {\n readonly _tag = 'TurnstileVerificationError'\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n\n constructor(args: {\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n readonly cause: unknown\n }) {\n super('Turnstile siteverify could not produce a verdict', { cause: args.cause })\n this.classification = args.classification\n this.errorCodes = args.errorCodes\n }\n}\n","import { Result } from 'better-result'\nimport { z } from 'zod'\n\nimport {\n classifyBoundaryError,\n exponentialEqualJitterMs,\n shouldRetryFailure,\n type FailureClassification,\n} from '@/core'\n\nimport { TurnstileVerificationError } from './turnstile.error.ts'\n\n/**\n * Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.\n * A rejection is a verdict. This module does not log, and one call retries with one idempotency key.\n */\n\nconst SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\n// Cloudflare documents 2048 characters. The testing secret still accepts a longer token.\nconst MAX_TOKEN_LENGTH = 2048\n\n// Cloudflare's validation example stops at 10 seconds.\nconst DEFAULT_TIMEOUT_MS = 10_000\n\n// `times` counts retries after the first attempt, so a call stops at three attempts.\nconst RETRY_TIMES = 2\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'missing-input-secret',\n 'invalid-input-secret',\n 'bad-request',\n])\n\nconst SuccessBody = z.object({\n success: z.literal(true),\n hostname: z.string(),\n challenge_ts: z.string(),\n action: z.string().optional(),\n metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional(),\n})\n\nconst FailureBody = z.object({\n success: z.literal(false),\n 'error-codes': z.array(z.string()).default([]),\n})\n\ntype SuccessBody = z.infer<typeof SuccessBody>\n\n/** The values siteverify needs. The secret stays in the caller. */\nexport type TurnstileCheck = {\n readonly secretKey: string\n readonly token: string\n /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */\n readonly remoteIp?: string\n /** Widget action. A present, different action rejects. A missing action does not. */\n readonly expectedAction?: string\n /** Allowed widget hostnames. A testing-key success skips this list. */\n readonly expectedHostnames?: readonly string[]\n /** UUID reused for this call's retries. Omit it and the call mints one. */\n readonly idempotencyKey?: string\n}\n\n/** A solved challenge, or a refusal siteverify already decided. */\nexport type TurnstileVerdict =\n | {\n readonly _tag: 'Accepted'\n readonly hostname: string\n readonly challengeTs: string\n readonly action?: string\n }\n | {\n readonly _tag: 'Rejected'\n readonly errorCodes: readonly string[]\n }\n\n/** Deadline and fetch overrides. Production calls omit this argument. */\nexport type TurnstileVerifyOptions = {\n /** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */\n readonly fetch?: typeof fetch\n /** Siteverify deadline in milliseconds. The default is 10 seconds. */\n readonly timeoutMs?: number\n}\n\nclass SiteverifyRetry extends Error {\n readonly _tag = 'SiteverifyRetry'\n\n constructor(\n readonly errorCodes: readonly string[],\n cause: unknown,\n ) {\n super('Turnstile siteverify did not answer', { cause })\n }\n}\n\n/**\n * Ask siteverify whether this token is a solved challenge.\n * A rejection is a verdict. No verdict throws `TurnstileVerificationError`.\n * A transient failure retries twice with the same idempotency key.\n */\nexport async function verifyTurnstile(\n check: TurnstileCheck,\n options: TurnstileVerifyOptions = {},\n): Promise<TurnstileVerdict> {\n if (check.token.length > MAX_TOKEN_LENGTH) {\n return { _tag: 'Rejected', errorCodes: ['invalid-input-response'] }\n }\n const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey)\n const result = await Result.tryPromise(\n {\n try: () => requestVerdict(check, idempotencyKey, options),\n catch: (cause) => cause,\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),\n shouldRetry: (cause) =>\n cause instanceof SiteverifyRetry &&\n shouldRetryFailure({\n classification: 'transient',\n repeatSafe: true,\n owner: 'immediate',\n }),\n },\n },\n )\n if (result.isOk()) return result.value\n if (result.error instanceof TurnstileVerificationError) throw result.error\n if (result.error instanceof SiteverifyRetry) {\n throw new TurnstileVerificationError({\n classification: 'transient',\n errorCodes: result.error.errorCodes,\n cause: result.error.cause,\n })\n }\n throw new TurnstileVerificationError({\n classification: 'unknown',\n errorCodes: [],\n cause: result.error,\n })\n}\n\nfunction resolveIdempotencyKey(key: string | undefined): string {\n if (key === undefined) return crypto.randomUUID()\n if (z.uuid().safeParse(key).success) return key\n throw new TurnstileVerificationError({\n classification: 'terminal',\n errorCodes: [],\n cause: { idempotencyKey: 'invalid' },\n })\n}\n\nasync function requestVerdict(\n check: TurnstileCheck,\n idempotencyKey: string,\n options: TurnstileVerifyOptions,\n): Promise<TurnstileVerdict> {\n const fetchImpl = options.fetch ?? fetch\n const fields = {\n secret: check.secretKey,\n response: check.token,\n idempotency_key: idempotencyKey,\n }\n const payload =\n check.remoteIp !== undefined && check.remoteIp !== ''\n ? { ...fields, remoteip: check.remoteIp }\n : fields\n\n let response: Response\n try {\n response = await fetchImpl(SITEVERIFY_URL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (cause) {\n throw transportFailure(cause)\n }\n\n const text = await response.text()\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch (cause) {\n throw unparsedFailure(response.status, cause)\n }\n\n const success = SuccessBody.safeParse(parsed)\n if (success.success) return decideSuccess(success.data, check)\n const failure = FailureBody.safeParse(parsed)\n if (failure.success) return decideFailure(failure.data['error-codes'], response.status)\n throw unparsedFailure(response.status, { status: response.status })\n}\n\nfunction decideSuccess(body: SuccessBody, check: TurnstileCheck): TurnstileVerdict {\n // A testing secret omits action. Compare only an action the response names.\n if (\n check.expectedAction !== undefined &&\n body.action !== undefined &&\n body.action !== check.expectedAction\n ) {\n return { _tag: 'Rejected', errorCodes: ['action-mismatch'] }\n }\n // A testing secret reports example.com, so the allowlist does not apply to it.\n const testingKey = body.metadata?.result_with_testing_key === true\n if (\n !testingKey &&\n check.expectedHostnames !== undefined &&\n !check.expectedHostnames.includes(body.hostname)\n ) {\n return { _tag: 'Rejected', errorCodes: ['hostname-mismatch'] }\n }\n if (body.action === undefined) {\n return { _tag: 'Accepted', hostname: body.hostname, challengeTs: body.challenge_ts }\n }\n return {\n _tag: 'Accepted',\n hostname: body.hostname,\n challengeTs: body.challenge_ts,\n action: body.action,\n }\n}\n\nfunction decideFailure(errorCodes: readonly string[], status: number): TurnstileVerdict {\n if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) {\n throw verificationFailure('terminal', errorCodes, status)\n }\n if (errorCodes.includes('internal-error') || status === 429 || status >= 500) {\n throw new SiteverifyRetry(errorCodes, { status })\n }\n return { _tag: 'Rejected', errorCodes }\n}\n\nfunction transportFailure(cause: unknown): Error {\n const classification = classifyBoundaryError({\n cause,\n classifyBoundary: () => undefined,\n includeCloudflare: false,\n })\n if (classification === 'transient') return new SiteverifyRetry([], cause)\n return new TurnstileVerificationError({ classification, errorCodes: [], cause })\n}\n\nfunction unparsedFailure(status: number, cause: unknown): Error {\n if (status === 429 || status >= 500) return new SiteverifyRetry([], cause)\n return verificationFailure('terminal', [], status)\n}\n\nfunction verificationFailure(\n classification: FailureClassification,\n errorCodes: readonly string[],\n status: number,\n): TurnstileVerificationError {\n return new TurnstileVerificationError({\n classification,\n errorCodes,\n cause: { status },\n })\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package.json",
|
|
3
3
|
"name": "better-ship",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.28.0",
|
|
5
5
|
"description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
"./cloudflare/queue": "./dist/cloudflare/queue.js",
|
|
37
37
|
"./cloudflare/r2-client": "./dist/cloudflare/r2-client.js",
|
|
38
38
|
"./cloudflare/r2-errors": "./dist/cloudflare/r2-errors.js",
|
|
39
|
+
"./cloudflare/rate-limiter": "./dist/cloudflare/rate-limiter.js",
|
|
39
40
|
"./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
|
|
40
41
|
"./cloudflare/tracing": "./dist/cloudflare/tracing.js",
|
|
42
|
+
"./cloudflare/turnstile": "./dist/cloudflare/turnstile.js",
|
|
41
43
|
"./core": "./dist/core.js",
|
|
42
44
|
"./http": "./dist/http.js",
|
|
43
45
|
"./postgres": "./dist/postgres.js",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AppError, RateLimitError } from '@/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caller limits through a Workers Rate Limiting binding. Ported from the `consume` path of
|
|
5
|
+
* `better-auth/rate-limit.ts` and Porte `auth-rate-limit.ts`. This module does not log.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The binding threw instead of answering. A refused call is `RateLimitError`, never this error. */
|
|
9
|
+
export class RateLimiterUnavailableError extends AppError {
|
|
10
|
+
readonly _tag = 'RateLimiterUnavailableError'
|
|
11
|
+
readonly classification = 'unknown'
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
readonly scope: string,
|
|
15
|
+
cause: unknown,
|
|
16
|
+
) {
|
|
17
|
+
super('Rate limiter is unavailable', { cause })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One feature's limit on a binding. The binding's `simple.limit` sets how many calls a window allows. */
|
|
22
|
+
export type RateLimiterOptions = {
|
|
23
|
+
readonly binding: Pick<RateLimit, 'limit'>
|
|
24
|
+
/** Names the feature. Two scopes on one binding keep separate counters. */
|
|
25
|
+
readonly scope: string
|
|
26
|
+
/** The binding's `simple.period` in seconds. Cloudflare accepts only 10 or 60. */
|
|
27
|
+
readonly window: 10 | 60
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Counts calls for each caller key inside one scope. */
|
|
31
|
+
export type RateLimiter = {
|
|
32
|
+
/**
|
|
33
|
+
* Count one call for `key`, and resolve when the binding allows it.
|
|
34
|
+
* A refused call throws `RateLimitError` with `retryAfter` set to the window.
|
|
35
|
+
* A binding failure throws `RateLimiterUnavailableError`. The call does not retry, because a retry counts again.
|
|
36
|
+
*/
|
|
37
|
+
readonly limit: (key: string) => Promise<void>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Bind one scope and window to a Rate Limiting binding. Build it once in the composition root. */
|
|
41
|
+
export function createRateLimiter(options: RateLimiterOptions): RateLimiter {
|
|
42
|
+
return {
|
|
43
|
+
async limit(key) {
|
|
44
|
+
const { success } = await consume(options, key)
|
|
45
|
+
if (!success) throw new RateLimitError(options.window)
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function consume(options: RateLimiterOptions, key: string): Promise<RateLimitOutcome> {
|
|
51
|
+
try {
|
|
52
|
+
// A JSON pair keeps `a:b` + `c` apart from `a` + `b:c`.
|
|
53
|
+
return await options.binding.limit({ key: JSON.stringify([options.scope, key]) })
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
throw new RateLimiterUnavailableError(options.scope, cause)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { AppError, type FailureClassification } from '@/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Siteverify did not produce a verdict.
|
|
5
|
+
* A rejected token is not this error. The caller logs it once.
|
|
6
|
+
*/
|
|
7
|
+
export class TurnstileVerificationError extends AppError {
|
|
8
|
+
readonly _tag = 'TurnstileVerificationError'
|
|
9
|
+
readonly classification: FailureClassification
|
|
10
|
+
readonly errorCodes: readonly string[]
|
|
11
|
+
|
|
12
|
+
constructor(args: {
|
|
13
|
+
readonly classification: FailureClassification
|
|
14
|
+
readonly errorCodes: readonly string[]
|
|
15
|
+
readonly cause: unknown
|
|
16
|
+
}) {
|
|
17
|
+
super('Turnstile siteverify could not produce a verdict', { cause: args.cause })
|
|
18
|
+
this.classification = args.classification
|
|
19
|
+
this.errorCodes = args.errorCodes
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Result } from 'better-result'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
classifyBoundaryError,
|
|
6
|
+
exponentialEqualJitterMs,
|
|
7
|
+
shouldRetryFailure,
|
|
8
|
+
type FailureClassification,
|
|
9
|
+
} from '@/core'
|
|
10
|
+
|
|
11
|
+
import { TurnstileVerificationError } from './turnstile.error.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.
|
|
15
|
+
* A rejection is a verdict. This module does not log, and one call retries with one idempotency key.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
|
19
|
+
|
|
20
|
+
// Cloudflare documents 2048 characters. The testing secret still accepts a longer token.
|
|
21
|
+
const MAX_TOKEN_LENGTH = 2048
|
|
22
|
+
|
|
23
|
+
// Cloudflare's validation example stops at 10 seconds.
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
25
|
+
|
|
26
|
+
// `times` counts retries after the first attempt, so a call stops at three attempts.
|
|
27
|
+
const RETRY_TIMES = 2
|
|
28
|
+
|
|
29
|
+
const CONFIGURATION_CODES: ReadonlySet<string> = new Set([
|
|
30
|
+
'missing-input-secret',
|
|
31
|
+
'invalid-input-secret',
|
|
32
|
+
'bad-request',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const SuccessBody = z.object({
|
|
36
|
+
success: z.literal(true),
|
|
37
|
+
hostname: z.string(),
|
|
38
|
+
challenge_ts: z.string(),
|
|
39
|
+
action: z.string().optional(),
|
|
40
|
+
metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const FailureBody = z.object({
|
|
44
|
+
success: z.literal(false),
|
|
45
|
+
'error-codes': z.array(z.string()).default([]),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
type SuccessBody = z.infer<typeof SuccessBody>
|
|
49
|
+
|
|
50
|
+
/** The values siteverify needs. The secret stays in the caller. */
|
|
51
|
+
export type TurnstileCheck = {
|
|
52
|
+
readonly secretKey: string
|
|
53
|
+
readonly token: string
|
|
54
|
+
/** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */
|
|
55
|
+
readonly remoteIp?: string
|
|
56
|
+
/** Widget action. A present, different action rejects. A missing action does not. */
|
|
57
|
+
readonly expectedAction?: string
|
|
58
|
+
/** Allowed widget hostnames. A testing-key success skips this list. */
|
|
59
|
+
readonly expectedHostnames?: readonly string[]
|
|
60
|
+
/** UUID reused for this call's retries. Omit it and the call mints one. */
|
|
61
|
+
readonly idempotencyKey?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A solved challenge, or a refusal siteverify already decided. */
|
|
65
|
+
export type TurnstileVerdict =
|
|
66
|
+
| {
|
|
67
|
+
readonly _tag: 'Accepted'
|
|
68
|
+
readonly hostname: string
|
|
69
|
+
readonly challengeTs: string
|
|
70
|
+
readonly action?: string
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
readonly _tag: 'Rejected'
|
|
74
|
+
readonly errorCodes: readonly string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Deadline and fetch overrides. Production calls omit this argument. */
|
|
78
|
+
export type TurnstileVerifyOptions = {
|
|
79
|
+
/** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */
|
|
80
|
+
readonly fetch?: typeof fetch
|
|
81
|
+
/** Siteverify deadline in milliseconds. The default is 10 seconds. */
|
|
82
|
+
readonly timeoutMs?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class SiteverifyRetry extends Error {
|
|
86
|
+
readonly _tag = 'SiteverifyRetry'
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
readonly errorCodes: readonly string[],
|
|
90
|
+
cause: unknown,
|
|
91
|
+
) {
|
|
92
|
+
super('Turnstile siteverify did not answer', { cause })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Ask siteverify whether this token is a solved challenge.
|
|
98
|
+
* A rejection is a verdict. No verdict throws `TurnstileVerificationError`.
|
|
99
|
+
* A transient failure retries twice with the same idempotency key.
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyTurnstile(
|
|
102
|
+
check: TurnstileCheck,
|
|
103
|
+
options: TurnstileVerifyOptions = {},
|
|
104
|
+
): Promise<TurnstileVerdict> {
|
|
105
|
+
if (check.token.length > MAX_TOKEN_LENGTH) {
|
|
106
|
+
return { _tag: 'Rejected', errorCodes: ['invalid-input-response'] }
|
|
107
|
+
}
|
|
108
|
+
const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey)
|
|
109
|
+
const result = await Result.tryPromise(
|
|
110
|
+
{
|
|
111
|
+
try: () => requestVerdict(check, idempotencyKey, options),
|
|
112
|
+
catch: (cause) => cause,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
retry: {
|
|
116
|
+
times: RETRY_TIMES,
|
|
117
|
+
delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),
|
|
118
|
+
shouldRetry: (cause) =>
|
|
119
|
+
cause instanceof SiteverifyRetry &&
|
|
120
|
+
shouldRetryFailure({
|
|
121
|
+
classification: 'transient',
|
|
122
|
+
repeatSafe: true,
|
|
123
|
+
owner: 'immediate',
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
if (result.isOk()) return result.value
|
|
129
|
+
if (result.error instanceof TurnstileVerificationError) throw result.error
|
|
130
|
+
if (result.error instanceof SiteverifyRetry) {
|
|
131
|
+
throw new TurnstileVerificationError({
|
|
132
|
+
classification: 'transient',
|
|
133
|
+
errorCodes: result.error.errorCodes,
|
|
134
|
+
cause: result.error.cause,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
throw new TurnstileVerificationError({
|
|
138
|
+
classification: 'unknown',
|
|
139
|
+
errorCodes: [],
|
|
140
|
+
cause: result.error,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveIdempotencyKey(key: string | undefined): string {
|
|
145
|
+
if (key === undefined) return crypto.randomUUID()
|
|
146
|
+
if (z.uuid().safeParse(key).success) return key
|
|
147
|
+
throw new TurnstileVerificationError({
|
|
148
|
+
classification: 'terminal',
|
|
149
|
+
errorCodes: [],
|
|
150
|
+
cause: { idempotencyKey: 'invalid' },
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function requestVerdict(
|
|
155
|
+
check: TurnstileCheck,
|
|
156
|
+
idempotencyKey: string,
|
|
157
|
+
options: TurnstileVerifyOptions,
|
|
158
|
+
): Promise<TurnstileVerdict> {
|
|
159
|
+
const fetchImpl = options.fetch ?? fetch
|
|
160
|
+
const fields = {
|
|
161
|
+
secret: check.secretKey,
|
|
162
|
+
response: check.token,
|
|
163
|
+
idempotency_key: idempotencyKey,
|
|
164
|
+
}
|
|
165
|
+
const payload =
|
|
166
|
+
check.remoteIp !== undefined && check.remoteIp !== ''
|
|
167
|
+
? { ...fields, remoteip: check.remoteIp }
|
|
168
|
+
: fields
|
|
169
|
+
|
|
170
|
+
let response: Response
|
|
171
|
+
try {
|
|
172
|
+
response = await fetchImpl(SITEVERIFY_URL, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
175
|
+
body: JSON.stringify(payload),
|
|
176
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
177
|
+
})
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
throw transportFailure(cause)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const text = await response.text()
|
|
183
|
+
let parsed: unknown
|
|
184
|
+
try {
|
|
185
|
+
parsed = JSON.parse(text)
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
throw unparsedFailure(response.status, cause)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const success = SuccessBody.safeParse(parsed)
|
|
191
|
+
if (success.success) return decideSuccess(success.data, check)
|
|
192
|
+
const failure = FailureBody.safeParse(parsed)
|
|
193
|
+
if (failure.success) return decideFailure(failure.data['error-codes'], response.status)
|
|
194
|
+
throw unparsedFailure(response.status, { status: response.status })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function decideSuccess(body: SuccessBody, check: TurnstileCheck): TurnstileVerdict {
|
|
198
|
+
// A testing secret omits action. Compare only an action the response names.
|
|
199
|
+
if (
|
|
200
|
+
check.expectedAction !== undefined &&
|
|
201
|
+
body.action !== undefined &&
|
|
202
|
+
body.action !== check.expectedAction
|
|
203
|
+
) {
|
|
204
|
+
return { _tag: 'Rejected', errorCodes: ['action-mismatch'] }
|
|
205
|
+
}
|
|
206
|
+
// A testing secret reports example.com, so the allowlist does not apply to it.
|
|
207
|
+
const testingKey = body.metadata?.result_with_testing_key === true
|
|
208
|
+
if (
|
|
209
|
+
!testingKey &&
|
|
210
|
+
check.expectedHostnames !== undefined &&
|
|
211
|
+
!check.expectedHostnames.includes(body.hostname)
|
|
212
|
+
) {
|
|
213
|
+
return { _tag: 'Rejected', errorCodes: ['hostname-mismatch'] }
|
|
214
|
+
}
|
|
215
|
+
if (body.action === undefined) {
|
|
216
|
+
return { _tag: 'Accepted', hostname: body.hostname, challengeTs: body.challenge_ts }
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
_tag: 'Accepted',
|
|
220
|
+
hostname: body.hostname,
|
|
221
|
+
challengeTs: body.challenge_ts,
|
|
222
|
+
action: body.action,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function decideFailure(errorCodes: readonly string[], status: number): TurnstileVerdict {
|
|
227
|
+
if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) {
|
|
228
|
+
throw verificationFailure('terminal', errorCodes, status)
|
|
229
|
+
}
|
|
230
|
+
if (errorCodes.includes('internal-error') || status === 429 || status >= 500) {
|
|
231
|
+
throw new SiteverifyRetry(errorCodes, { status })
|
|
232
|
+
}
|
|
233
|
+
return { _tag: 'Rejected', errorCodes }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function transportFailure(cause: unknown): Error {
|
|
237
|
+
const classification = classifyBoundaryError({
|
|
238
|
+
cause,
|
|
239
|
+
classifyBoundary: () => undefined,
|
|
240
|
+
includeCloudflare: false,
|
|
241
|
+
})
|
|
242
|
+
if (classification === 'transient') return new SiteverifyRetry([], cause)
|
|
243
|
+
return new TurnstileVerificationError({ classification, errorCodes: [], cause })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function unparsedFailure(status: number, cause: unknown): Error {
|
|
247
|
+
if (status === 429 || status >= 500) return new SiteverifyRetry([], cause)
|
|
248
|
+
return verificationFailure('terminal', [], status)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function verificationFailure(
|
|
252
|
+
classification: FailureClassification,
|
|
253
|
+
errorCodes: readonly string[],
|
|
254
|
+
status: number,
|
|
255
|
+
): TurnstileVerificationError {
|
|
256
|
+
return new TurnstileVerificationError({
|
|
257
|
+
classification,
|
|
258
|
+
errorCodes,
|
|
259
|
+
cause: { status },
|
|
260
|
+
})
|
|
261
|
+
}
|