@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.
@@ -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
+ }