@rdlabo/workers-hono-kit 0.6.4 → 0.6.8
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/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/payment-failed.d.ts +25 -0
- package/dist/db/payment-failed.js +33 -0
- package/dist/http/nest-error.d.ts +149 -0
- package/dist/http/nest-error.js +120 -0
- package/dist/iap/apple.d.ts +77 -0
- package/dist/iap/apple.js +84 -0
- package/dist/iap/google.d.ts +74 -0
- package/dist/iap/google.js +75 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/dist/payment/failure.d.ts +62 -0
- package/dist/payment/failure.js +56 -0
- package/dist/payment/persistence.d.ts +24 -0
- package/dist/payment/persistence.js +32 -0
- package/dist/stripe/failure.d.ts +108 -0
- package/dist/stripe/failure.js +208 -0
- package/dist/stripe/reconcile.d.ts +34 -0
- package/dist/stripe/reconcile.js +80 -0
- package/package.json +1 -1
package/dist/db/index.d.ts
CHANGED
|
@@ -24,3 +24,4 @@ export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-c
|
|
|
24
24
|
export type { HonoDrizzleConfigOptions, ResolvedDbSecret } from './orm-config.js';
|
|
25
25
|
export { baselineMigrations, readBaselineEntry } from './migrate.js';
|
|
26
26
|
export type { BaselineMigrationsOptions, BaselineResult, BaselineEntry } from './migrate.js';
|
|
27
|
+
export { reopenGuardedPaymentFailedSet } from './payment-failed.js';
|
package/dist/db/index.js
CHANGED
|
@@ -18,3 +18,4 @@ export { coerceDecimalNumber, decimalNumberParams } from './decimal.js';
|
|
|
18
18
|
export { jstTimestamp, jstDatetime, jstDate, decimalNumber, jstOnUpdateNow } from './columns.js';
|
|
19
19
|
export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-config.js';
|
|
20
20
|
export { baselineMigrations, readBaselineEntry } from './migrate.js';
|
|
21
|
+
export { reopenGuardedPaymentFailedSet } from './payment-failed.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SQL } from 'drizzle-orm';
|
|
2
|
+
/**
|
|
3
|
+
* `payment_failed` persistence helpers (MySQL / Drizzle).
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Lives in the `./db` subpath because it imports `drizzle-orm` (a peer); the web-standard root export
|
|
7
|
+
* must stay free of ORM deps. The helper only builds SQL fragments — the consumer's repository
|
|
8
|
+
* executes them, so it works regardless of the consumer's DB access layer (`db.write` / `helper.query`).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
|
|
12
|
+
* row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
|
|
13
|
+
* paid user with a permanent failure banner.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* `receipt` is evaluated **before** `status` (Drizzle emits the SET clause in the object's insertion
|
|
17
|
+
* order and MySQL evaluates it left→right, so later columns would otherwise see the already-updated
|
|
18
|
+
* `status`). Both branch on the *pre-update* `payment_failed.status`; `type`/`user_id` are always
|
|
19
|
+
* refreshed. Spread into the call:
|
|
20
|
+
* `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
|
|
21
|
+
*
|
|
22
|
+
* Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the Drizzle schema
|
|
23
|
+
* property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
|
|
24
|
+
*/
|
|
25
|
+
export declare function reopenGuardedPaymentFailedSet(): Record<'type' | 'userId' | 'status' | 'receipt', SQL>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
/**
|
|
3
|
+
* `payment_failed` persistence helpers (MySQL / Drizzle).
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Lives in the `./db` subpath because it imports `drizzle-orm` (a peer); the web-standard root export
|
|
7
|
+
* must stay free of ORM deps. The helper only builds SQL fragments — the consumer's repository
|
|
8
|
+
* executes them, so it works regardless of the consumer's DB access layer (`db.write` / `helper.query`).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
|
|
12
|
+
* row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
|
|
13
|
+
* paid user with a permanent failure banner.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* `receipt` is evaluated **before** `status` (Drizzle emits the SET clause in the object's insertion
|
|
17
|
+
* order and MySQL evaluates it left→right, so later columns would otherwise see the already-updated
|
|
18
|
+
* `status`). Both branch on the *pre-update* `payment_failed.status`; `type`/`user_id` are always
|
|
19
|
+
* refreshed. Spread into the call:
|
|
20
|
+
* `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
|
|
21
|
+
*
|
|
22
|
+
* Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the Drizzle schema
|
|
23
|
+
* property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
|
|
24
|
+
*/
|
|
25
|
+
export function reopenGuardedPaymentFailedSet() {
|
|
26
|
+
return {
|
|
27
|
+
type: sql `values(\`type\`)`,
|
|
28
|
+
userId: sql `values(\`user_id\`)`,
|
|
29
|
+
// resolved 行は理由も status も据え置き(再オープンしない)。status 代入より前に評価する。
|
|
30
|
+
receipt: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`receipt\`, values(\`receipt\`))`,
|
|
31
|
+
status: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`status\`, values(\`status\`))`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Context, Env } from 'hono';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
/**
|
|
4
|
+
* Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
|
|
8
|
+
* return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
|
|
9
|
+
*/
|
|
10
|
+
export declare const NEST_REASON_PHRASES: Record<number, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Contextual metadata passed to an {@link ErrorReporter} when reporting an unexpected error.
|
|
13
|
+
*/
|
|
14
|
+
export interface ErrorReportContext {
|
|
15
|
+
/** Correlation id for the failing request, if one is tracked. */
|
|
16
|
+
requestId?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Signature of a function that reports an unexpected (non-HTTP) error to an external sink such as Sentry.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Wire it into {@link createNestErrorHandler} via `onUnhandledError`, e.g.
|
|
23
|
+
* `(err, c) => reporter(err, { requestId: c.get('requestId') })`. The reporting client itself is
|
|
24
|
+
* intentionally kept out of this kit; the consumer supplies the implementation.
|
|
25
|
+
*
|
|
26
|
+
* @param error - The thrown value being reported.
|
|
27
|
+
* @param context - Optional correlation context for the failing request.
|
|
28
|
+
*/
|
|
29
|
+
export type ErrorReporter = (error: unknown, context?: ErrorReportContext) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Minimal Sentry-like client for {@link createSentryErrorReporter} and {@link createQueueErrorHandler}.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Declared structurally to avoid a hard dependency on `@sentry/cloudflare`.
|
|
35
|
+
*/
|
|
36
|
+
export interface SentryExceptionReporterLike {
|
|
37
|
+
captureException(exception: unknown, captureContext?: {
|
|
38
|
+
tags?: Record<string, string>;
|
|
39
|
+
extra?: Record<string, unknown>;
|
|
40
|
+
}): void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createSentryErrorReporter(sentry: SentryExceptionReporterLike): ErrorReporter;
|
|
46
|
+
/**
|
|
47
|
+
* Minimal shape read from a value treated as an HTTP error: its status, message, and optional body.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
interface HttpErrorLike {
|
|
52
|
+
/** HTTP status code to respond with. */
|
|
53
|
+
status: ContentfulStatusCode;
|
|
54
|
+
/** Human-readable error message placed in the response body. */
|
|
55
|
+
message: string;
|
|
56
|
+
/**
|
|
57
|
+
* Escape hatch for a fully custom response body. When present, it is rendered verbatim instead of
|
|
58
|
+
* the NestJS-shaped body.
|
|
59
|
+
*/
|
|
60
|
+
body?: unknown;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Options controlling how {@link createNestErrorHandler} shapes error responses.
|
|
64
|
+
*
|
|
65
|
+
* @typeParam E - The Hono environment type, so `onUnhandledError` receives a correctly typed context.
|
|
66
|
+
*/
|
|
67
|
+
export interface NestErrorHandlerOptions<E extends Env = Env> {
|
|
68
|
+
/** Status-to-reason-phrase map for the `error` field. Defaults to {@link NEST_REASON_PHRASES}. */
|
|
69
|
+
reasonPhrases?: Record<number, string>;
|
|
70
|
+
/**
|
|
71
|
+
* Statuses that return only `{ statusCode, message }`, omitting the `error` field. Defaults to `[401]`,
|
|
72
|
+
* matching NestJS where a generic `HttpException(msg, 401)` carries no `error`.
|
|
73
|
+
*/
|
|
74
|
+
bareStatuses?: readonly number[];
|
|
75
|
+
/**
|
|
76
|
+
* Field order of the non-bare error body. Defaults to `'statusCode-first'` (the NestJS canonical order).
|
|
77
|
+
* Use `'message-first'` to emit `{ message, error, statusCode }` when byte parity requires it.
|
|
78
|
+
*/
|
|
79
|
+
fieldOrder?: 'statusCode-first' | 'message-first';
|
|
80
|
+
/**
|
|
81
|
+
* Fallback `error` value for statuses that are neither bare nor present in `reasonPhrases`. Defaults to
|
|
82
|
+
* `undefined`, meaning the `error` field is omitted when no reason phrase is known. Set to a string such
|
|
83
|
+
* as `'Error'` to always include an `error` field, faithfully reproducing the NestJS default exception
|
|
84
|
+
* filter behavior where `error` is always present.
|
|
85
|
+
*/
|
|
86
|
+
fallbackReason?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Predicate identifying which thrown values are HTTP errors. Defaults to detecting Hono's `HTTPException`.
|
|
89
|
+
* Override it (e.g. `(e) => e instanceof MyHttpError`) when the app throws a custom HTTP error type.
|
|
90
|
+
*/
|
|
91
|
+
isHttpError?: (err: unknown) => err is HttpErrorLike;
|
|
92
|
+
/**
|
|
93
|
+
* Hook invoked before an unexpected (non-HTTP) error is returned as a 500, typically used to report the
|
|
94
|
+
* error (e.g. to Sentry). Any exception thrown by this hook is swallowed so reporting cannot alter the
|
|
95
|
+
* error response.
|
|
96
|
+
*/
|
|
97
|
+
onUnhandledError?: (err: unknown, c: Context<E>) => void;
|
|
98
|
+
/**
|
|
99
|
+
* Response body for unexpected errors returned as 500. Defaults to
|
|
100
|
+
* `{ statusCode: 500, message: 'Internal server error' }`.
|
|
101
|
+
*/
|
|
102
|
+
internalServerErrorBody?: unknown;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
|
|
109
|
+
* - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
|
|
110
|
+
* custom `body`, that body is returned verbatim.
|
|
111
|
+
* - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
|
|
112
|
+
* - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
|
|
113
|
+
*
|
|
114
|
+
* Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
|
|
115
|
+
* {@link NestErrorHandlerOptions}, while the branching logic stays shared.
|
|
116
|
+
*
|
|
117
|
+
* @typeParam E - The Hono environment type propagated to `onUnhandledError`.
|
|
118
|
+
* @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
|
|
119
|
+
* @returns A handler suitable for `app.onError(...)`.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* app.onError(
|
|
124
|
+
* createNestErrorHandler({
|
|
125
|
+
* fieldOrder: 'message-first',
|
|
126
|
+
* fallbackReason: 'Error',
|
|
127
|
+
* onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
|
|
128
|
+
* }),
|
|
129
|
+
* );
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export declare function createNestErrorHandler<E extends Env = Env>(options?: NestErrorHandlerOptions<E>): (err: Error, c: Context<E>) => Response;
|
|
133
|
+
/**
|
|
134
|
+
* Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
|
|
135
|
+
*
|
|
136
|
+
* @remarks
|
|
137
|
+
* Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
|
|
138
|
+
* NestJS default 404 response so unmatched routes stay byte-identical.
|
|
139
|
+
*
|
|
140
|
+
* @param c - The Hono request context for the unmatched route.
|
|
141
|
+
* @returns A 404 JSON response.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* app.notFound(nestNotFoundHandler);
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export declare function nestNotFoundHandler(c: Context): Response;
|
|
149
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { findMysqlDriverError, logMysqlDriverError } from './mysql-driver-error.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
|
|
7
|
+
* return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
|
|
8
|
+
*/
|
|
9
|
+
export const NEST_REASON_PHRASES = {
|
|
10
|
+
400: 'Bad Request',
|
|
11
|
+
401: 'Unauthorized',
|
|
12
|
+
403: 'Forbidden',
|
|
13
|
+
404: 'Not Found',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
|
|
17
|
+
*/
|
|
18
|
+
export function createSentryErrorReporter(sentry) {
|
|
19
|
+
return (error, context) => {
|
|
20
|
+
sentry.captureException(error, context?.requestId ? { tags: { request_id: context.requestId } } : undefined);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Structurally detect Hono's `HTTPException` without relying on `instanceof`.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* When this kit is symlinked into a consumer, the `hono` instance it resolves can differ from the
|
|
28
|
+
* consumer's `hono`, so an `HTTPException` from one copy fails an `instanceof` check against the other.
|
|
29
|
+
* Detecting the presence of a `getResponse()` method and a numeric `status` is stable across module
|
|
30
|
+
* boundaries and production bundles.
|
|
31
|
+
*
|
|
32
|
+
* @param err - The thrown value to test.
|
|
33
|
+
* @returns `true` when `err` looks like a Hono `HTTPException`.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
const isHTTPException = (err) => err instanceof Error &&
|
|
38
|
+
typeof err.getResponse === 'function' &&
|
|
39
|
+
typeof err.status === 'number';
|
|
40
|
+
/**
|
|
41
|
+
* Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
|
|
45
|
+
* - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
|
|
46
|
+
* custom `body`, that body is returned verbatim.
|
|
47
|
+
* - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
|
|
48
|
+
* - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
|
|
49
|
+
*
|
|
50
|
+
* Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
|
|
51
|
+
* {@link NestErrorHandlerOptions}, while the branching logic stays shared.
|
|
52
|
+
*
|
|
53
|
+
* @typeParam E - The Hono environment type propagated to `onUnhandledError`.
|
|
54
|
+
* @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
|
|
55
|
+
* @returns A handler suitable for `app.onError(...)`.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* app.onError(
|
|
60
|
+
* createNestErrorHandler({
|
|
61
|
+
* fieldOrder: 'message-first',
|
|
62
|
+
* fallbackReason: 'Error',
|
|
63
|
+
* onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
|
|
64
|
+
* }),
|
|
65
|
+
* );
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function createNestErrorHandler(options = {}) {
|
|
69
|
+
const { reasonPhrases = NEST_REASON_PHRASES, bareStatuses = [401], fieldOrder = 'statusCode-first', isHttpError = isHTTPException, onUnhandledError, internalServerErrorBody = { statusCode: 500, message: 'Internal server error' }, fallbackReason, } = options;
|
|
70
|
+
return (err, c) => {
|
|
71
|
+
if (isHttpError(err)) {
|
|
72
|
+
// Escape hatch for a custom error body: render it verbatim.
|
|
73
|
+
if (err.body !== undefined) {
|
|
74
|
+
return c.json(err.body, err.status);
|
|
75
|
+
}
|
|
76
|
+
// reasonPhrases[status] is typed as string, but with noUncheckedIndexedAccess disabled it can be
|
|
77
|
+
// undefined at runtime. The fallbackReason fallback for unregistered statuses is intentional.
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
79
|
+
const reason = bareStatuses.includes(err.status) ? undefined : (reasonPhrases[err.status] ?? fallbackReason);
|
|
80
|
+
if (reason === undefined) {
|
|
81
|
+
return c.json({ statusCode: err.status, message: err.message }, err.status);
|
|
82
|
+
}
|
|
83
|
+
const body = fieldOrder === 'message-first'
|
|
84
|
+
? { message: err.message, error: reason, statusCode: err.status }
|
|
85
|
+
: { statusCode: err.status, message: err.message, error: reason };
|
|
86
|
+
return c.json(body, err.status);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
onUnhandledError?.(err, c);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Reporting must never change the behavior of the error response.
|
|
93
|
+
}
|
|
94
|
+
if (findMysqlDriverError(err)) {
|
|
95
|
+
logMysqlDriverError(err, 500);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.error(err);
|
|
99
|
+
}
|
|
100
|
+
return c.json(internalServerErrorBody, 500);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
|
|
108
|
+
* NestJS default 404 response so unmatched routes stay byte-identical.
|
|
109
|
+
*
|
|
110
|
+
* @param c - The Hono request context for the unmatched route.
|
|
111
|
+
* @returns A 404 JSON response.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* app.notFound(nestNotFoundHandler);
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function nestNotFoundHandler(c) {
|
|
119
|
+
return c.json({ message: `Cannot ${c.req.method} ${new URL(c.req.url).pathname}`, error: 'Not Found', statusCode: 404 }, 404);
|
|
120
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple App Store (StoreKit / verifyReceipt) helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provider-agnostic and SDK-free: inputs are duck-typed `unknown` JSON (Apple ships no SDK), matching
|
|
6
|
+
* the `stripe/failure.ts` approach. `classifyAppleRenewal` maps a verifyReceipt response to a renewal
|
|
7
|
+
* state; the consumer decides how to persist it (billing_retry → `failed`, lapsed → `canceled`,
|
|
8
|
+
* active → resolve). `verifyAppleReceipt` performs the production→sandbox verification call.
|
|
9
|
+
*/
|
|
10
|
+
/** Apple `pending_renewal_info[]` entry (fields consumed here). */
|
|
11
|
+
export interface ApplePendingRenewalInfo {
|
|
12
|
+
/** Links this renewal info to a subscription group / receipt (used to pick the matching entry). */
|
|
13
|
+
original_transaction_id?: string;
|
|
14
|
+
/** `'1'` while Apple is retrying billing because the payment method failed (= card invalid). */
|
|
15
|
+
is_in_billing_retry_period?: string;
|
|
16
|
+
/** `'0'` = auto-renew turned off (will lapse at period end). */
|
|
17
|
+
auto_renew_status?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
/** Apple `latest_receipt_info[]` entry (fields consumed here). */
|
|
21
|
+
export interface AppleLatestReceiptInfo {
|
|
22
|
+
original_transaction_id?: string;
|
|
23
|
+
/** Subscription expiry as epoch-ms string (stable regardless of the `expires_date` string format). */
|
|
24
|
+
expires_date_ms?: string;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** Apple `verifyReceipt` response (fields consumed here). */
|
|
28
|
+
export interface AppleVerifyReceiptResponse {
|
|
29
|
+
status?: number;
|
|
30
|
+
environment?: string;
|
|
31
|
+
latest_receipt_info?: AppleLatestReceiptInfo[];
|
|
32
|
+
pending_renewal_info?: ApplePendingRenewalInfo[];
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Renewal state derived from an Apple verifyReceipt response.
|
|
37
|
+
* - `billing_retry` — Apple is retrying a failed payment (card invalid) → persist as `failed`.
|
|
38
|
+
* - `lapsed` — auto-renew off and expired → persist as `canceled`.
|
|
39
|
+
* - `active` — a valid (non-expired) subscription → resolve any open failure.
|
|
40
|
+
* - `unknown` — indeterminate (no receipt, or expired with auto-renew on and no retry) → no-op.
|
|
41
|
+
*/
|
|
42
|
+
export type AppleRenewalState = 'billing_retry' | 'lapsed' | 'active' | 'unknown';
|
|
43
|
+
/**
|
|
44
|
+
* Classify an Apple verifyReceipt response into an {@link AppleRenewalState}.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* The most recent renewal (max `expires_date_ms`) is used regardless of array order. Sandbox is NOT
|
|
48
|
+
* filtered here — the consumer should skip `environment === 'Sandbox'` before persisting to a
|
|
49
|
+
* production table (see {@link AppleVerifyReceiptResponse.environment}).
|
|
50
|
+
*
|
|
51
|
+
* @param verify - The verifyReceipt response (duck-typed).
|
|
52
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
53
|
+
* @returns The state plus the latest `original_transaction_id` / `expires_date_ms` (for the row key).
|
|
54
|
+
*/
|
|
55
|
+
export declare function classifyAppleRenewal(verify: unknown, now: number): {
|
|
56
|
+
state: AppleRenewalState;
|
|
57
|
+
originalTransactionId?: string;
|
|
58
|
+
expiresDateMs?: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Verify an App Store receipt against Apple, falling back from production to sandbox.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Mirrors the fleet's long-standing verify flow: POST to the production endpoint, and if
|
|
65
|
+
* `status !== 0` retry against sandbox. Returns `null` when both reject. The `password` (shared
|
|
66
|
+
* secret) is per-app and must be injected. `fetchImpl` is injectable for tests.
|
|
67
|
+
*
|
|
68
|
+
* @remarks Caveat: any non-zero `status` (including Apple's *retryable* 21005/21009) collapses to
|
|
69
|
+
* `null`, so a transient Apple outage is indistinguishable from a genuinely invalid receipt. Do not
|
|
70
|
+
* treat `null` as "subscription gone" without other signals. Also throws if a response is not JSON.
|
|
71
|
+
*
|
|
72
|
+
* @returns The parsed response, or `null` for an invalid (or unverifiable) receipt.
|
|
73
|
+
*/
|
|
74
|
+
export declare function verifyAppleReceipt(receipt: string, opts: {
|
|
75
|
+
password: string;
|
|
76
|
+
fetchImpl?: typeof fetch;
|
|
77
|
+
}): Promise<AppleVerifyReceiptResponse | null>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple App Store (StoreKit / verifyReceipt) helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provider-agnostic and SDK-free: inputs are duck-typed `unknown` JSON (Apple ships no SDK), matching
|
|
6
|
+
* the `stripe/failure.ts` approach. `classifyAppleRenewal` maps a verifyReceipt response to a renewal
|
|
7
|
+
* state; the consumer decides how to persist it (billing_retry → `failed`, lapsed → `canceled`,
|
|
8
|
+
* active → resolve). `verifyAppleReceipt` performs the production→sandbox verification call.
|
|
9
|
+
*/
|
|
10
|
+
const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
|
|
11
|
+
const toArray = (v) => (Array.isArray(v) ? v : []);
|
|
12
|
+
/**
|
|
13
|
+
* Classify an Apple verifyReceipt response into an {@link AppleRenewalState}.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* The most recent renewal (max `expires_date_ms`) is used regardless of array order. Sandbox is NOT
|
|
17
|
+
* filtered here — the consumer should skip `environment === 'Sandbox'` before persisting to a
|
|
18
|
+
* production table (see {@link AppleVerifyReceiptResponse.environment}).
|
|
19
|
+
*
|
|
20
|
+
* @param verify - The verifyReceipt response (duck-typed).
|
|
21
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
22
|
+
* @returns The state plus the latest `original_transaction_id` / `expires_date_ms` (for the row key).
|
|
23
|
+
*/
|
|
24
|
+
export function classifyAppleRenewal(verify, now) {
|
|
25
|
+
const v = asRecord(verify);
|
|
26
|
+
// 消耗型など expires_date_ms 欠損エントリを除外してから最新(max expires)を選ぶ。
|
|
27
|
+
// 欠損値を Number() すると NaN で比較が常に false になり、先頭の欠損行が最後まで残って
|
|
28
|
+
// 誤って 'active'(isExpired=false)を返すため、有効な expires を持つ行だけを対象にする。
|
|
29
|
+
const infos = toArray(v?.latest_receipt_info).filter((e) => Number.isFinite(Number(e.expires_date_ms)));
|
|
30
|
+
const latest = infos.reduce((acc, cur) => {
|
|
31
|
+
if (!acc) {
|
|
32
|
+
return cur;
|
|
33
|
+
}
|
|
34
|
+
return Number(cur.expires_date_ms) > Number(acc.expires_date_ms) ? cur : acc;
|
|
35
|
+
}, undefined);
|
|
36
|
+
const originalTransactionId = latest?.original_transaction_id;
|
|
37
|
+
if (!latest || !originalTransactionId) {
|
|
38
|
+
return { state: 'unknown' };
|
|
39
|
+
}
|
|
40
|
+
// 複数サブスク商品時に別商品の renewal info を読まないよう、latest と同じ original_transaction_id の
|
|
41
|
+
// pending エントリを優先(無ければ先頭にフォールバック)。
|
|
42
|
+
const pendingArr = toArray(v?.pending_renewal_info);
|
|
43
|
+
const pending = pendingArr.find((p) => p.original_transaction_id === originalTransactionId) ?? pendingArr.at(0);
|
|
44
|
+
const expiresMs = Number(latest.expires_date_ms);
|
|
45
|
+
const isExpired = Number.isFinite(expiresMs) && expiresMs < now;
|
|
46
|
+
const base = { originalTransactionId, expiresDateMs: latest.expires_date_ms };
|
|
47
|
+
if (pending?.is_in_billing_retry_period === '1') {
|
|
48
|
+
return { state: 'billing_retry', ...base };
|
|
49
|
+
}
|
|
50
|
+
if (pending?.auto_renew_status === '0' && isExpired) {
|
|
51
|
+
return { state: 'lapsed', ...base };
|
|
52
|
+
}
|
|
53
|
+
if (!isExpired) {
|
|
54
|
+
return { state: 'active', ...base };
|
|
55
|
+
}
|
|
56
|
+
return { state: 'unknown', ...base };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify an App Store receipt against Apple, falling back from production to sandbox.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Mirrors the fleet's long-standing verify flow: POST to the production endpoint, and if
|
|
63
|
+
* `status !== 0` retry against sandbox. Returns `null` when both reject. The `password` (shared
|
|
64
|
+
* secret) is per-app and must be injected. `fetchImpl` is injectable for tests.
|
|
65
|
+
*
|
|
66
|
+
* @remarks Caveat: any non-zero `status` (including Apple's *retryable* 21005/21009) collapses to
|
|
67
|
+
* `null`, so a transient Apple outage is indistinguishable from a genuinely invalid receipt. Do not
|
|
68
|
+
* treat `null` as "subscription gone" without other signals. Also throws if a response is not JSON.
|
|
69
|
+
*
|
|
70
|
+
* @returns The parsed response, or `null` for an invalid (or unverifiable) receipt.
|
|
71
|
+
*/
|
|
72
|
+
export async function verifyAppleReceipt(receipt, opts) {
|
|
73
|
+
const body = JSON.stringify({ 'receipt-data': receipt, password: opts.password });
|
|
74
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
75
|
+
const post = (url) => doFetch(url, { method: 'POST', body }).then((r) => r.json());
|
|
76
|
+
let verify = await post('https://buy.itunes.apple.com/verifyReceipt');
|
|
77
|
+
if (verify.status !== 0) {
|
|
78
|
+
verify = await post('https://sandbox.itunes.apple.com/verifyReceipt');
|
|
79
|
+
if (verify.status !== 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return verify;
|
|
84
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Play (Android Publisher) subscription helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* SDK-free and duck-typed like {@link ./apple.ts}. `classifyGoogleSubscription` maps a
|
|
6
|
+
* `purchases.subscriptions.get` response (or its 410 error) to a state; the consumer persists it
|
|
7
|
+
* (canceled/gone → `canceled`, active → resolve). `getGoogleSubscription` / `googleAccessToken`
|
|
8
|
+
* perform the OAuth + Android Publisher calls.
|
|
9
|
+
*/
|
|
10
|
+
/** Android Publisher `purchases.subscriptions.get` response (fields consumed here). */
|
|
11
|
+
export interface GoogleSubscriptionPurchase {
|
|
12
|
+
startTimeMillis?: string;
|
|
13
|
+
expiryTimeMillis?: string;
|
|
14
|
+
/** `false` once the user turns off auto-renew (will cancel at period end). */
|
|
15
|
+
autoRenewing?: boolean;
|
|
16
|
+
/** Present once canceled (0=user / 1=system / 2=replaced / 3=developer). */
|
|
17
|
+
cancelReason?: number;
|
|
18
|
+
/** Set when Google returns an error body (e.g. `{ code: 410 }` for a long-expired token). */
|
|
19
|
+
error?: {
|
|
20
|
+
code?: number;
|
|
21
|
+
message?: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Subscription state derived from a Google subscriptions.get response.
|
|
28
|
+
* - `canceled` — expired and will not renew (autoRenewing=false or a cancelReason) → persist `canceled`.
|
|
29
|
+
* - `gone` — Google 410 "no longer available" (long-churned) → persist `canceled` (best-effort).
|
|
30
|
+
* - `active` — a valid (non-expired) subscription → resolve any open failure.
|
|
31
|
+
* - `unknown` — indeterminate / transient error → no-op.
|
|
32
|
+
*
|
|
33
|
+
* @remarks Google's account-hold / grace-period (payment failed but auto-renew still on — the Android
|
|
34
|
+
* analogue of Apple's `billing_retry`) is currently reported as `active`/`unknown`, not a distinct
|
|
35
|
+
* "failed" state. If that distinction is needed later, add it to this union in a minor release (adding
|
|
36
|
+
* a member is semi-breaking for exhaustive `switch` consumers).
|
|
37
|
+
*/
|
|
38
|
+
export type GoogleSubscriptionState = 'canceled' | 'gone' | 'active' | 'unknown';
|
|
39
|
+
/**
|
|
40
|
+
* Classify a Google subscriptions.get response into a {@link GoogleSubscriptionState}.
|
|
41
|
+
*
|
|
42
|
+
* @param purchase - The response body (duck-typed); may be `{ error: { code } }`.
|
|
43
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function classifyGoogleSubscription(purchase: unknown, now: number): {
|
|
46
|
+
state: GoogleSubscriptionState;
|
|
47
|
+
};
|
|
48
|
+
/** OAuth service-account credentials for the Android Publisher API (per-app; inject, never hardcode in kit). */
|
|
49
|
+
export interface GoogleOAuthCredentials {
|
|
50
|
+
client_id: string;
|
|
51
|
+
client_secret: string;
|
|
52
|
+
refresh_token: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Exchange a refresh token for an Android Publisher access token.
|
|
56
|
+
*
|
|
57
|
+
* @param creds - The per-app OAuth credentials.
|
|
58
|
+
* @param fetchImpl - Injectable fetch (tests).
|
|
59
|
+
*/
|
|
60
|
+
export declare function googleAccessToken(creds: GoogleOAuthCredentials, fetchImpl?: typeof fetch): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Fetch a subscription purchase from the Android Publisher API.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* Returns the raw JSON (including `{ error: { code } }` bodies for 4xx such as 410) so the caller can
|
|
66
|
+
* pass it to {@link classifyGoogleSubscription}. `packageName` / `subscriptionId` are per-app.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getGoogleSubscription(opts: {
|
|
69
|
+
packageName: string;
|
|
70
|
+
subscriptionId: string;
|
|
71
|
+
purchaseToken: string;
|
|
72
|
+
accessToken: string;
|
|
73
|
+
fetchImpl?: typeof fetch;
|
|
74
|
+
}): Promise<GoogleSubscriptionPurchase>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Play (Android Publisher) subscription helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* SDK-free and duck-typed like {@link ./apple.ts}. `classifyGoogleSubscription` maps a
|
|
6
|
+
* `purchases.subscriptions.get` response (or its 410 error) to a state; the consumer persists it
|
|
7
|
+
* (canceled/gone → `canceled`, active → resolve). `getGoogleSubscription` / `googleAccessToken`
|
|
8
|
+
* perform the OAuth + Android Publisher calls.
|
|
9
|
+
*/
|
|
10
|
+
const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
|
|
11
|
+
/**
|
|
12
|
+
* Classify a Google subscriptions.get response into a {@link GoogleSubscriptionState}.
|
|
13
|
+
*
|
|
14
|
+
* @param purchase - The response body (duck-typed); may be `{ error: { code } }`.
|
|
15
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
16
|
+
*/
|
|
17
|
+
export function classifyGoogleSubscription(purchase, now) {
|
|
18
|
+
const p = asRecord(purchase);
|
|
19
|
+
if (!p) {
|
|
20
|
+
return { state: 'unknown' };
|
|
21
|
+
}
|
|
22
|
+
const err = asRecord(p.error);
|
|
23
|
+
if (err) {
|
|
24
|
+
// 410 = "The subscription purchase is no longer available for query" (expired too long) = churned.
|
|
25
|
+
return { state: err.code === 410 ? 'gone' : 'unknown' };
|
|
26
|
+
}
|
|
27
|
+
const expiryMs = Number(p.expiryTimeMillis);
|
|
28
|
+
const isExpired = Number.isFinite(expiryMs) && expiryMs < now;
|
|
29
|
+
const willNotRenew = p.autoRenewing === false || p.cancelReason !== undefined;
|
|
30
|
+
if (isExpired && willNotRenew) {
|
|
31
|
+
return { state: 'canceled' };
|
|
32
|
+
}
|
|
33
|
+
if (!isExpired) {
|
|
34
|
+
return { state: 'active' };
|
|
35
|
+
}
|
|
36
|
+
return { state: 'unknown' };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Exchange a refresh token for an Android Publisher access token.
|
|
40
|
+
*
|
|
41
|
+
* @param creds - The per-app OAuth credentials.
|
|
42
|
+
* @param fetchImpl - Injectable fetch (tests).
|
|
43
|
+
*/
|
|
44
|
+
export async function googleAccessToken(creds, fetchImpl) {
|
|
45
|
+
const body = new URLSearchParams({
|
|
46
|
+
client_id: creds.client_id,
|
|
47
|
+
client_secret: creds.client_secret,
|
|
48
|
+
grant_type: 'refresh_token',
|
|
49
|
+
refresh_token: creds.refresh_token,
|
|
50
|
+
}).toString();
|
|
51
|
+
const res = (await (fetchImpl ?? fetch)('https://accounts.google.com/o/oauth2/token', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
54
|
+
body,
|
|
55
|
+
}).then((r) => r.json()));
|
|
56
|
+
if (!res.access_token) {
|
|
57
|
+
// 静かに '' を返すと後続が error body → 'unknown' に劣化し、refresh token 失効(invalid_grant)を
|
|
58
|
+
// 見逃す。呼び出し側に可視化するため throw する。
|
|
59
|
+
throw new Error('googleAccessToken: no access_token in token response (refresh_token expired?)');
|
|
60
|
+
}
|
|
61
|
+
return res.access_token;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fetch a subscription purchase from the Android Publisher API.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Returns the raw JSON (including `{ error: { code } }` bodies for 4xx such as 410) so the caller can
|
|
68
|
+
* pass it to {@link classifyGoogleSubscription}. `packageName` / `subscriptionId` are per-app.
|
|
69
|
+
*/
|
|
70
|
+
export async function getGoogleSubscription(opts) {
|
|
71
|
+
const url = `https://www.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(opts.packageName)}` +
|
|
72
|
+
`/purchases/subscriptions/${encodeURIComponent(opts.subscriptionId)}/tokens/${encodeURIComponent(opts.purchaseToken)}`;
|
|
73
|
+
// access_token はクエリに載せず Authorization ヘッダで送る(URL ログ/プロキシへの秘匿情報漏洩を防ぐ)。
|
|
74
|
+
return (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${opts.accessToken}` } }).then((r) => r.json());
|
|
75
|
+
}
|