better-ship 0.25.0 → 0.27.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/auth-secondary-storage-b9ptwPSB.d.ts +37 -0
- package/dist/auth-secondary-storage-b9ptwPSB.d.ts.map +1 -0
- package/dist/better-auth/secondary-storage.d.ts +1 -1
- package/dist/bin/notify-ci.d.ts +1 -0
- package/dist/bin/notify-ci.js +24 -0
- package/dist/bin/notify-ci.js.map +1 -0
- package/dist/client-Dv1Ch7yY.js +20 -0
- package/dist/client-Dv1Ch7yY.js.map +1 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts +2 -36
- package/dist/cloudflare/durable-object-client.js +1 -1
- package/dist/cloudflare/durable-object-errors.js +2 -47
- package/dist/cloudflare/kv-client.js +1 -1
- package/dist/cloudflare/kv-errors.js +2 -143
- package/dist/cloudflare/r2-client.js +1 -1
- package/dist/cloudflare/r2-errors.js +2 -89
- 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/dist/durable-object-errors-DkMFXQnK.js +48 -0
- package/dist/durable-object-errors-DkMFXQnK.js.map +1 -0
- package/dist/kv-errors-BUTpFy0t.js +144 -0
- package/dist/kv-errors-BUTpFy0t.js.map +1 -0
- package/dist/metrics-CWv37yf9.js +30 -0
- package/dist/metrics-CWv37yf9.js.map +1 -0
- package/dist/metrics-cv9P5nvk.d.ts +19 -0
- package/dist/metrics-cv9P5nvk.d.ts.map +1 -0
- package/dist/payment-service-DUYd3Itt.d.ts +52 -0
- package/dist/payment-service-DUYd3Itt.d.ts.map +1 -0
- package/dist/payment-service.error-CqF7GkNp.js +30 -0
- package/dist/payment-service.error-CqF7GkNp.js.map +1 -0
- package/dist/posthog/metrics.d.ts +2 -18
- package/dist/posthog/metrics.js +2 -29
- package/dist/posthog/server.d.ts +1 -1
- package/dist/posthog/server.js +1 -1
- package/dist/r2-errors-rk7n7rvk.js +90 -0
- package/dist/r2-errors-rk7n7rvk.js.map +1 -0
- package/dist/request-4vJEWxyZ.js +21 -0
- package/dist/request-4vJEWxyZ.js.map +1 -0
- package/dist/slack-webhook-j1FCPeu8.js +270 -0
- package/dist/slack-webhook-j1FCPeu8.js.map +1 -0
- package/dist/slack.d.ts +102 -0
- package/dist/slack.d.ts.map +1 -0
- package/dist/slack.js +3 -0
- package/dist/stripe/client.js +2 -19
- package/dist/stripe/payment-service.d.ts +2 -51
- package/dist/stripe/payment-service.error.js +2 -29
- package/dist/stripe/payment-service.js +1 -1
- package/dist/stripe/request.js +2 -20
- package/dist/stripe-fixture-yFUknTJO.js +33 -0
- package/dist/stripe-fixture-yFUknTJO.js.map +1 -0
- package/dist/testing/payment-service.d.ts +1 -1
- package/dist/testing/payment-service.js +2 -2
- package/dist/testing/stripe.js +2 -32
- package/package.json +7 -1
- 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
- package/src/infrastructure/slack/ci-message.ts +276 -0
- package/src/infrastructure/slack/index.ts +10 -0
- package/src/infrastructure/slack/notify-ci.ts +22 -0
- package/src/infrastructure/slack/slack-webhook.ts +36 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts.map +0 -1
- package/dist/cloudflare/durable-object-errors.js.map +0 -1
- package/dist/cloudflare/kv-errors.js.map +0 -1
- package/dist/cloudflare/r2-errors.js.map +0 -1
- package/dist/posthog/metrics.d.ts.map +0 -1
- package/dist/posthog/metrics.js.map +0 -1
- package/dist/stripe/client.js.map +0 -1
- package/dist/stripe/payment-service.d.ts.map +0 -1
- package/dist/stripe/payment-service.error.js.map +0 -1
- package/dist/stripe/request.js.map +0 -1
- package/dist/testing/stripe.js.map +0 -1
|
@@ -1,51 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
/** Supply the same key and parameters when repeating one durable payment attempt. */
|
|
4
|
-
export type PaymentRequestOptions = Stripe.RequestOptions & Required<Pick<Stripe.RequestOptions, 'idempotencyKey'>>;
|
|
5
|
-
/** Native Checkout parameters; the service selects payment or setup mode. */
|
|
6
|
-
export type CheckoutInput = Omit<Stripe.Checkout.SessionCreateParams, 'mode'>;
|
|
7
|
-
/** A saved customer method; the service confirms the payment off-session. */
|
|
8
|
-
export type SavedMethodCharge = Omit<Stripe.PaymentIntentCreateParams, 'confirm' | 'off_session'> & Required<Pick<Stripe.PaymentIntentCreateParams, 'customer' | 'payment_method'>>;
|
|
9
|
-
/** Native customer response, including Stripe request metadata. */
|
|
10
|
-
export type CustomerResult = Awaited<ReturnType<Stripe['customers']['create']>>;
|
|
11
|
-
/** Native Checkout response. Its URL can be null; this does not confirm payment. */
|
|
12
|
-
export type CheckoutResult = Awaited<ReturnType<Stripe['checkout']['sessions']['create']>>;
|
|
13
|
-
/** Native PaymentIntent response. Applications decide what each status permits. */
|
|
14
|
-
export type PaymentResult = Awaited<ReturnType<Stripe['paymentIntents']['create']>>;
|
|
15
|
-
/**
|
|
16
|
-
* Stripe payment operations, with native types and stable keys for mutations.
|
|
17
|
-
* Rejects with PaymentServiceError for Stripe failures; non-Stripe failures propagate unchanged.
|
|
18
|
-
*/
|
|
19
|
-
export interface IPaymentService {
|
|
20
|
-
/** Create a customer; the application owns persistence and reuse of the customer link. */
|
|
21
|
-
createCustomer(input: Stripe.CustomerCreateParams, options: PaymentRequestOptions): Promise<CustomerResult>;
|
|
22
|
-
/** Create one-time payment checkout; application fulfillment requires verified payment confirmation. */
|
|
23
|
-
createCheckout(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
|
|
24
|
-
/** Create checkout in setup mode. The application records consent and verifies the saved method's owner. */
|
|
25
|
-
setupPaymentMethod(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
|
|
26
|
-
/** Confirm a saved method off-session. PaymentServiceError retains card errors in cause. */
|
|
27
|
-
chargeSavedMethod(input: SavedMethodCharge, options: PaymentRequestOptions): Promise<PaymentResult>;
|
|
28
|
-
/** Retrieve an existing payment without starting another payment attempt. */
|
|
29
|
-
getPayment(paymentId: string, params?: Stripe.PaymentIntentRetrieveParams, options?: Stripe.RequestOptions): Promise<PaymentResult>;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Ported from Typist's StripeClient request boundary; the native SDK now owns retries.
|
|
33
|
-
* The injected client owns retry and timeout defaults. Native request options can override them.
|
|
34
|
-
*/
|
|
35
|
-
export declare class StripePaymentService implements IPaymentService {
|
|
36
|
-
private readonly stripe;
|
|
37
|
-
/** Inject a native SDK instance; its transport, credentials, and API version remain caller-owned. */
|
|
38
|
-
constructor(stripe: Stripe);
|
|
39
|
-
/** Create a customer with a stable application key and preserve the native response. */
|
|
40
|
-
createCustomer(input: Stripe.CustomerCreateParams, options: PaymentRequestOptions): Promise<CustomerResult>;
|
|
41
|
-
/** Force payment mode after caller parameters; return the native Checkout Session. */
|
|
42
|
-
createCheckout(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
|
|
43
|
-
/** Force setup mode after caller parameters; this operation does not charge the customer. */
|
|
44
|
-
setupPaymentMethod(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
|
|
45
|
-
/** Force confirmation and off-session use; retain native card failures in PaymentServiceError.cause. */
|
|
46
|
-
chargeSavedMethod(input: SavedMethodCharge, options: PaymentRequestOptions): Promise<PaymentResult>;
|
|
47
|
-
/** Preserve every native status and response field when reconciling an existing payment. */
|
|
48
|
-
getPayment(paymentId: string, params?: Stripe.PaymentIntentRetrieveParams, options?: Stripe.RequestOptions): Promise<PaymentResult>;
|
|
49
|
-
}
|
|
50
|
-
//#endregion
|
|
51
|
-
//# sourceMappingURL=payment-service.d.ts.map
|
|
1
|
+
import { a as PaymentRequestOptions, c as StripePaymentService, i as IPaymentService, n as CheckoutResult, o as PaymentResult, r as CustomerResult, s as SavedMethodCharge, t as CheckoutInput } from "../payment-service-DUYd3Itt.js";
|
|
2
|
+
export { CheckoutInput, CheckoutResult, CustomerResult, IPaymentService, PaymentRequestOptions, PaymentResult, SavedMethodCharge, StripePaymentService };
|
|
@@ -1,30 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import Stripe from "stripe";
|
|
1
|
+
import { t as PaymentServiceError } from "../payment-service.error-CqF7GkNp.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
/** A final Stripe request failure. The caller logs once; native details remain in cause. */
|
|
6
|
-
var PaymentServiceError = class extends AppError {
|
|
7
|
-
operation;
|
|
8
|
-
_tag = "PaymentServiceError";
|
|
9
|
-
classification;
|
|
10
|
-
/** Classify the final SDK error without scheduling another payment attempt. */
|
|
11
|
-
constructor(operation, cause) {
|
|
12
|
-
super(`Stripe request failed: ${operation}`, { cause });
|
|
13
|
-
this.operation = operation;
|
|
14
|
-
this.classification = classifyStripeError(cause);
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
function classifyStripeError(error) {
|
|
18
|
-
if (error.headers?.["stripe-should-retry"] === "false") return "terminal";
|
|
19
|
-
const status = error.statusCode;
|
|
20
|
-
if (status !== void 0 && status >= 400 && status < 500 && status !== 409 && status !== 429) return "terminal";
|
|
21
|
-
if (error instanceof Stripe.errors.StripeAuthenticationError || error instanceof Stripe.errors.StripePermissionError || error instanceof Stripe.errors.StripeInvalidRequestError || error instanceof Stripe.errors.StripeIdempotencyError || error instanceof Stripe.errors.StripeCardError) return "terminal";
|
|
22
|
-
if (error.headers?.["stripe-should-retry"] === "true") return "transient";
|
|
23
|
-
if (status === 409 || status === 429 || status !== void 0 && status >= 500) return "transient";
|
|
24
|
-
if (error instanceof Stripe.errors.StripeConnectionError || error instanceof Stripe.errors.StripeAPIError || error instanceof Stripe.errors.StripeRateLimitError) return "transient";
|
|
25
|
-
return "unknown";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
//#endregion
|
|
29
|
-
export { PaymentServiceError };
|
|
30
|
-
//# sourceMappingURL=payment-service.error.js.map
|
|
3
|
+
export { PaymentServiceError };
|
package/dist/stripe/request.js
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import Stripe from "stripe";
|
|
1
|
+
import { t as stripeRequest } from "../request-4vJEWxyZ.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Invoke one SDK operation; the SDK owns retries configured on its client or request.
|
|
7
|
-
* Convert every Stripe error to PaymentServiceError without logging; preserve the original error in cause.
|
|
8
|
-
* Non-Stripe failures propagate unchanged.
|
|
9
|
-
*/
|
|
10
|
-
async function stripeRequest(operation, work) {
|
|
11
|
-
try {
|
|
12
|
-
return await work();
|
|
13
|
-
} catch (cause) {
|
|
14
|
-
if (!(cause instanceof Stripe.errors.StripeError)) throw cause;
|
|
15
|
-
throw new PaymentServiceError(operation, cause);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
//#endregion
|
|
20
|
-
export { stripeRequest };
|
|
21
|
-
//# sourceMappingURL=request.js.map
|
|
3
|
+
export { stripeRequest };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { n as createStripeClient } from "./client-Dv1Ch7yY.js";
|
|
2
|
+
import Stripe from "stripe";
|
|
3
|
+
|
|
4
|
+
//#region src/infrastructure/testing/stripe-fixture.ts
|
|
5
|
+
/**
|
|
6
|
+
* Construct the real SDK with a fake fetch transport and an ordered record of HTTP attempts.
|
|
7
|
+
* The responder receives an unread request. The shared client factory supplies retry defaults.
|
|
8
|
+
*/
|
|
9
|
+
function stripeFixture(respond) {
|
|
10
|
+
const requests = [];
|
|
11
|
+
return {
|
|
12
|
+
stripe: createStripeClient("sk_test_fixture", { httpClient: Stripe.createFetchHttpClient(async (input, init) => {
|
|
13
|
+
const request = new Request(input, init);
|
|
14
|
+
const url = new URL(request.url);
|
|
15
|
+
const call = {
|
|
16
|
+
method: request.method,
|
|
17
|
+
path: url.pathname,
|
|
18
|
+
query: url.searchParams,
|
|
19
|
+
body: new URLSearchParams(),
|
|
20
|
+
headers: new Headers(request.headers)
|
|
21
|
+
};
|
|
22
|
+
requests.push(call);
|
|
23
|
+
const body = new TextDecoder().decode(await request.clone().arrayBuffer());
|
|
24
|
+
for (const [key, value] of new URLSearchParams(body)) call.body.append(key, value);
|
|
25
|
+
return respond(request);
|
|
26
|
+
}) }),
|
|
27
|
+
requests
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { stripeFixture as t };
|
|
33
|
+
//# sourceMappingURL=stripe-fixture-yFUknTJO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stripe-fixture-yFUknTJO.js","names":[],"sources":["../src/infrastructure/testing/stripe-fixture.ts"],"sourcesContent":["import Stripe from 'stripe'\n\nimport { createStripeClient } from '../stripe/client.ts'\n\n/** One SDK HTTP attempt; query and body expose Stripe's URL-encoded parameters. */\nexport type StripeCall = {\n readonly method: string\n readonly path: string\n readonly query: URLSearchParams\n readonly body: URLSearchParams\n readonly headers: Headers\n}\n\ntype StripeFixture = { stripe: Stripe; requests: readonly StripeCall[] }\n\n/**\n * Construct the real SDK with a fake fetch transport and an ordered record of HTTP attempts.\n * The responder receives an unread request. The shared client factory supplies retry defaults.\n */\nexport function stripeFixture(\n respond: (request: Request) => Response | Promise<Response>,\n): StripeFixture {\n const requests: StripeCall[] = []\n const stripe = createStripeClient('sk_test_fixture', {\n httpClient: Stripe.createFetchHttpClient(async (input, init) => {\n const request = new Request(input, init)\n const url = new URL(request.url)\n const call: StripeCall = {\n method: request.method,\n path: url.pathname,\n query: url.searchParams,\n body: new URLSearchParams(),\n headers: new Headers(request.headers),\n }\n requests.push(call)\n const body = new TextDecoder().decode(await request.clone().arrayBuffer())\n for (const [key, value] of new URLSearchParams(body)) call.body.append(key, value)\n return respond(request)\n }),\n })\n return { stripe, requests }\n}\n"],"mappings":";;;;;;;;AAmBA,SAAgB,cACd,SACe;CACf,MAAM,WAAyB,CAAC;CAkBhC,OAAO;EAAE,QAjBM,mBAAmB,mBAAmB,EACnD,YAAY,OAAO,sBAAsB,OAAO,OAAO,SAAS;GAC9D,MAAM,UAAU,IAAI,QAAQ,OAAO,IAAI;GACvC,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;GAC/B,MAAM,OAAmB;IACvB,QAAQ,QAAQ;IAChB,MAAM,IAAI;IACV,OAAO,IAAI;IACX,MAAM,IAAI,gBAAgB;IAC1B,SAAS,IAAI,QAAQ,QAAQ,OAAO;GACtC;GACA,SAAS,KAAK,IAAI;GAClB,MAAM,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,QAAQ,MAAM,CAAC,CAAC,YAAY,CAAC;GACzE,KAAK,MAAM,CAAC,KAAK,UAAU,IAAI,gBAAgB,IAAI,GAAG,KAAK,KAAK,OAAO,KAAK,KAAK;GACjF,OAAO,QAAQ,OAAO;EACxB,CAAC,EACH,CACc;EAAG;CAAS;AAC5B"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PaymentServiceError } from "../
|
|
2
|
-
import { stripeFixture } from "
|
|
1
|
+
import { t as PaymentServiceError } from "../payment-service.error-CqF7GkNp.js";
|
|
2
|
+
import { t as stripeFixture } from "../stripe-fixture-yFUknTJO.js";
|
|
3
3
|
import Stripe from "stripe";
|
|
4
4
|
import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
|
|
5
5
|
|
package/dist/testing/stripe.js
CHANGED
|
@@ -1,33 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import Stripe from "stripe";
|
|
1
|
+
import { t as stripeFixture } from "../stripe-fixture-yFUknTJO.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Construct the real SDK with a fake fetch transport and an ordered record of HTTP attempts.
|
|
7
|
-
* The responder receives an unread request. The shared client factory supplies retry defaults.
|
|
8
|
-
*/
|
|
9
|
-
function stripeFixture(respond) {
|
|
10
|
-
const requests = [];
|
|
11
|
-
return {
|
|
12
|
-
stripe: createStripeClient("sk_test_fixture", { httpClient: Stripe.createFetchHttpClient(async (input, init) => {
|
|
13
|
-
const request = new Request(input, init);
|
|
14
|
-
const url = new URL(request.url);
|
|
15
|
-
const call = {
|
|
16
|
-
method: request.method,
|
|
17
|
-
path: url.pathname,
|
|
18
|
-
query: url.searchParams,
|
|
19
|
-
body: new URLSearchParams(),
|
|
20
|
-
headers: new Headers(request.headers)
|
|
21
|
-
};
|
|
22
|
-
requests.push(call);
|
|
23
|
-
const body = new TextDecoder().decode(await request.clone().arrayBuffer());
|
|
24
|
-
for (const [key, value] of new URLSearchParams(body)) call.body.append(key, value);
|
|
25
|
-
return respond(request);
|
|
26
|
-
}) }),
|
|
27
|
-
requests
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
//#endregion
|
|
32
|
-
export { stripeFixture };
|
|
33
|
-
//# sourceMappingURL=stripe.js.map
|
|
3
|
+
export { stripeFixture };
|
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.27.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": {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"url": "git+https://github.com/alexander-zuev/better-ship.git",
|
|
10
10
|
"directory": "packages/better-ship"
|
|
11
11
|
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"better-ship-notify-ci": "./dist/bin/notify-ci.js"
|
|
14
|
+
},
|
|
12
15
|
"files": [
|
|
13
16
|
"dist",
|
|
14
17
|
"src"
|
|
@@ -23,6 +26,7 @@
|
|
|
23
26
|
"./better-auth/secondary-storage": "./dist/better-auth/secondary-storage.js",
|
|
24
27
|
"./better-auth/stripe": "./dist/better-auth/stripe.js",
|
|
25
28
|
"./billing/client": "./dist/billing/client.js",
|
|
29
|
+
"./bin/notify-ci": "./dist/bin/notify-ci.js",
|
|
26
30
|
"./cloudflare": "./dist/cloudflare.js",
|
|
27
31
|
"./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
|
|
28
32
|
"./cloudflare/durable-object-client": "./dist/cloudflare/durable-object-client.js",
|
|
@@ -34,6 +38,7 @@
|
|
|
34
38
|
"./cloudflare/r2-errors": "./dist/cloudflare/r2-errors.js",
|
|
35
39
|
"./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
|
|
36
40
|
"./cloudflare/tracing": "./dist/cloudflare/tracing.js",
|
|
41
|
+
"./cloudflare/turnstile": "./dist/cloudflare/turnstile.js",
|
|
37
42
|
"./core": "./dist/core.js",
|
|
38
43
|
"./http": "./dist/http.js",
|
|
39
44
|
"./postgres": "./dist/postgres.js",
|
|
@@ -46,6 +51,7 @@
|
|
|
46
51
|
"./sentry/server": "./dist/sentry/server.js",
|
|
47
52
|
"./sentry/tanstack": "./dist/sentry/tanstack.js",
|
|
48
53
|
"./sentry/vite": "./dist/sentry/vite.js",
|
|
54
|
+
"./slack": "./dist/slack.js",
|
|
49
55
|
"./stripe/client": "./dist/stripe/client.js",
|
|
50
56
|
"./stripe/event-map": "./dist/stripe/event-map.js",
|
|
51
57
|
"./stripe/payment-service": "./dist/stripe/payment-service.js",
|
|
@@ -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
|
+
}
|