@rdlabo/workers-hono-kit 0.6.3 → 0.6.6

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/README.md CHANGED
@@ -5,7 +5,7 @@ Infrastructure toolkit for building APIs on [Hono](https://hono.dev) + [Cloudfla
5
5
  It provides the building blocks a NestJS-style API needs but that don't run on `workerd` (no Node.js AWS SDK, no `firebase-admin`), plus middleware that matches Express / NestJS response semantics byte-for-byte:
6
6
 
7
7
  - **Firebase ID-token verification** on Workers via [`jose`](https://github.com/panva/jose) (RS256 against Google's securetoken JWKS), with optional Identity Toolkit REST for `getUser` / `deleteUser`.
8
- - **AWS Secrets Manager** via SigV4-signed `fetch` ([`aws4fetch`](https://github.com/mhart/aws4fetch)) — no AWS SDK.
8
+ - **AWS Secrets Manager / STS AssumeRole / CloudFront signed URLs** via SigV4-signed `fetch` ([`aws4fetch`](https://github.com/mhart/aws4fetch)) or Web Crypto — no AWS SDK.
9
9
  - **Middleware**: `finalizeResponse` (Express-compatible weak ETag + JSON charset), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
10
10
  - **Standard API errors**: `createHttpErrorHandler` / `notFoundHandler` / `HttpStatus`.
11
11
  - **Deadlock retry** (`ER_LOCK_DEADLOCK` exponential backoff) and an optional **MySQL data layer** (`@rdlabo/workers-hono-kit/db`) for Hyperdrive + Drizzle.
@@ -56,6 +56,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
56
56
  | `createSentryValidate(sentry)` | **Deprecated** — use `createValidate({ sentry })`. |
57
57
  | `zNum` / `zNumWithDefault` / `zNumOptional` / `zNumNullable` | Number-coercion zod schemas (mirror class-transformer `@Transform`). |
58
58
  | `getAuthenticationSecret<T>(options, secretId)` / `AwsSecretsOptions` | Fetch a secret from AWS Secrets Manager (SigV4 `fetch`, per-isolate cache). |
59
+ | `getTemporaryCredentials(options)` / `GetTemporaryCredentialsOptions` / `StsCredentials` | STS `AssumeRole` via SigV4 `fetch` (global `sts.amazonaws.com`); returns temporary credentials for browser S3 uploads. |
59
60
  | `getCloudFrontSignedUrl(url, privateKeyPem, keyPairId, dateLessThan)` | CloudFront signed URL (canned policy, RSA-SHA1, URL-safe base64) — Web Crypto reimpl of `@aws-sdk/cloudfront-signer`, byte-identical query order. |
60
61
  | `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `deleteUser`). |
61
62
  | `createRemoteFirebaseVerifier(projectId)` | Convenience factory: production verifier with a cached remote JWKS (verification only). |
@@ -271,6 +272,20 @@ const secret = await getAuthenticationSecret<MySecret>(
271
272
  );
272
273
  ```
273
274
 
275
+ ### STS AssumeRole (browser S3 uploads)
276
+
277
+ ```ts
278
+ import { getTemporaryCredentials } from '@rdlabo/workers-hono-kit';
279
+
280
+ const credentials = await getTemporaryCredentials({
281
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
282
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
283
+ roleArn: 'arn:aws:iam::123456789012:role/s3-put-app-only-role',
284
+ roleSessionName: `session-${userId}-${Date.now()}`,
285
+ });
286
+ // Return credentials to the browser; PutObject uses @aws-sdk/client-s3 with AccessKeyId / …
287
+ ```
288
+
274
289
  ### Deadlock retry & HTTP helpers
275
290
 
276
291
  ```ts
@@ -0,0 +1,67 @@
1
+ /**
2
+ * AWS credentials used to sign an STS `AssumeRole` request.
3
+ *
4
+ * @remarks
5
+ * Same shape as {@link AwsSecretsOptions} minus the required Secrets Manager region; STS defaults to
6
+ * the global endpoint (`us-east-1`) unless {@link GetTemporaryCredentialsOptions.region} is set.
7
+ */
8
+ export interface GetTemporaryCredentialsOptions {
9
+ /** AWS access key ID of the caller principal that may AssumeRole. */
10
+ accessKeyId: string;
11
+ /** AWS secret access key of the caller principal. */
12
+ secretAccessKey: string;
13
+ /** Optional STS session token when the caller already holds temporary credentials. */
14
+ sessionToken?: string;
15
+ /** ARN of the role to assume (e.g. `arn:aws:iam::123:role/s3-put-app-only-role`). */
16
+ roleArn: string;
17
+ /** Session name recorded in CloudTrail (often `session-${userId}-${Date.now()}`). */
18
+ roleSessionName: string;
19
+ /**
20
+ * Credential lifetime in seconds.
21
+ * @defaultValue 900
22
+ */
23
+ durationSeconds?: number;
24
+ /**
25
+ * STS SigV4 signing region.
26
+ * @defaultValue us-east-1
27
+ */
28
+ region?: string;
29
+ /**
30
+ * STS endpoint URL.
31
+ * @defaultValue https://sts.amazonaws.com/
32
+ */
33
+ endpoint?: string;
34
+ }
35
+ /**
36
+ * Temporary credentials returned by STS `AssumeRole`.
37
+ *
38
+ * @remarks
39
+ * Field names match the STS XML response / `@aws-sdk/client-sts` `Credentials` shape so browser
40
+ * apps can pass them straight into `@aws-sdk/client-s3` (`AccessKeyId` → `accessKeyId`, etc.).
41
+ */
42
+ export interface StsCredentials {
43
+ AccessKeyId?: string;
44
+ SecretAccessKey?: string;
45
+ SessionToken?: string;
46
+ Expiration?: Date;
47
+ }
48
+ /**
49
+ * Call STS `AssumeRole` via SigV4-signed `fetch` (aws4fetch) and return temporary credentials.
50
+ *
51
+ * Port of winecode / airlec browser-upload credential issuance — no AWS SDK. The consuming app
52
+ * supplies `roleArn` and `roleSessionName`; the kit only performs the signed STS request and XML parse.
53
+ *
54
+ * @param options - Caller AWS keys plus assume-role parameters.
55
+ * @returns Temporary credentials for browser or edge PutObject / GetObject.
56
+ * @throws Error When the STS response is not OK.
57
+ * @example
58
+ * ```ts
59
+ * const credentials = await getTemporaryCredentials({
60
+ * accessKeyId: env.AWS_ACCESS_KEY_ID,
61
+ * secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
62
+ * roleArn: 'arn:aws:iam::123:role/s3-put-app-only-role',
63
+ * roleSessionName: `session-${userId}-${Date.now()}`,
64
+ * });
65
+ * ```
66
+ */
67
+ export declare function getTemporaryCredentials(options: GetTemporaryCredentialsOptions): Promise<StsCredentials>;
@@ -0,0 +1,66 @@
1
+ import { AwsClient } from 'aws4fetch';
2
+ /** STS SigV4 signing region for the global endpoint (`sts.amazonaws.com`). */
3
+ const DEFAULT_STS_REGION = 'us-east-1';
4
+ /** winecode / airlec default — 15 minutes. */
5
+ const DEFAULT_DURATION_SECONDS = 900;
6
+ const STS_API_VERSION = '2011-06-15';
7
+ /** Global STS endpoint (unchanged from winecode AwsService). */
8
+ const DEFAULT_STS_ENDPOINT = 'https://sts.amazonaws.com/';
9
+ /**
10
+ * Call STS `AssumeRole` via SigV4-signed `fetch` (aws4fetch) and return temporary credentials.
11
+ *
12
+ * Port of winecode / airlec browser-upload credential issuance — no AWS SDK. The consuming app
13
+ * supplies `roleArn` and `roleSessionName`; the kit only performs the signed STS request and XML parse.
14
+ *
15
+ * @param options - Caller AWS keys plus assume-role parameters.
16
+ * @returns Temporary credentials for browser or edge PutObject / GetObject.
17
+ * @throws Error When the STS response is not OK.
18
+ * @example
19
+ * ```ts
20
+ * const credentials = await getTemporaryCredentials({
21
+ * accessKeyId: env.AWS_ACCESS_KEY_ID,
22
+ * secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
23
+ * roleArn: 'arn:aws:iam::123:role/s3-put-app-only-role',
24
+ * roleSessionName: `session-${userId}-${Date.now()}`,
25
+ * });
26
+ * ```
27
+ */
28
+ export async function getTemporaryCredentials(options) {
29
+ const region = options.region ?? DEFAULT_STS_REGION;
30
+ const durationSeconds = options.durationSeconds ?? DEFAULT_DURATION_SECONDS;
31
+ const endpoint = options.endpoint ?? DEFAULT_STS_ENDPOINT;
32
+ const params = new URLSearchParams({
33
+ Action: 'AssumeRole',
34
+ Version: STS_API_VERSION,
35
+ RoleArn: options.roleArn,
36
+ RoleSessionName: options.roleSessionName,
37
+ DurationSeconds: String(durationSeconds),
38
+ });
39
+ const aws = new AwsClient({
40
+ accessKeyId: options.accessKeyId,
41
+ secretAccessKey: options.secretAccessKey,
42
+ sessionToken: options.sessionToken,
43
+ region,
44
+ service: 'sts',
45
+ });
46
+ const response = await aws.fetch(endpoint, {
47
+ method: 'POST',
48
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
49
+ body: params.toString(),
50
+ });
51
+ const xml = await response.text();
52
+ if (!response.ok) {
53
+ throw new Error(`STS AssumeRole failed: ${response.status} ${xml}`);
54
+ }
55
+ const pick = (tag) => {
56
+ const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml);
57
+ return m ? m[1] : undefined;
58
+ };
59
+ const expiration = pick('Expiration');
60
+ return {
61
+ AccessKeyId: pick('AccessKeyId'),
62
+ SecretAccessKey: pick('SecretAccessKey'),
63
+ SessionToken: pick('SessionToken'),
64
+ Expiration: expiration ? new Date(expiration) : undefined,
65
+ };
66
+ }
@@ -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
+ }
package/dist/index.d.ts CHANGED
@@ -47,6 +47,8 @@ export { KVCache } from './cache/kv-cache.js';
47
47
  export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
48
48
  export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
49
49
  export type { CreateStripeClientOptions } from './stripe/client.js';
50
+ export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
51
+ export type { StripeFailureReason, PaymentFailureSource, PaymentFailureRecord, PaymentDeclinedBody, } from './stripe/failure.js';
50
52
  export { retryWhenDeadlock } from './db/retry.js';
51
53
  export { sendInChunks } from './queue/send.js';
52
54
  export type { QueueLike, QueueSendMessage } from './queue/send.js';
@@ -59,6 +61,8 @@ export type { AiGatewayConfig, AiGatewayProvider, AiGatewayBinding, AiGateway, A
59
61
  export { getAuthenticationSecret } from './aws/secrets-manager.js';
60
62
  export type { AwsSecretsOptions } from './aws/secrets-manager.js';
61
63
  export { getCloudFrontSignedUrl } from './aws/cloudfront.js';
64
+ export { getTemporaryCredentials } from './aws/sts.js';
65
+ export type { GetTemporaryCredentialsOptions, StsCredentials } from './aws/sts.js';
62
66
  export type { DecodedIdToken, FirebaseVerifier } from './firebase/firebase-verifier.js';
63
67
  export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier.js';
64
68
  export { IdentityToolkit } from './firebase/identity-toolkit.js';
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ export { createSentryErrorReporter } from './http/http-error.js';
36
36
  export { KVCache } from './cache/kv-cache.js';
37
37
  // stripe
38
38
  export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
39
+ export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
39
40
  // db
40
41
  export { retryWhenDeadlock } from './db/retry.js';
41
42
  // queue
@@ -47,6 +48,7 @@ export { createAiGatewayProvider } from './ai/gateway.js';
47
48
  // aws
48
49
  export { getAuthenticationSecret } from './aws/secrets-manager.js';
49
50
  export { getCloudFrontSignedUrl } from './aws/cloudfront.js';
51
+ export { getTemporaryCredentials } from './aws/sts.js';
50
52
  export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier.js';
51
53
  export { IdentityToolkit } from './firebase/identity-toolkit.js';
52
54
  export { createRemoteFirebaseVerifier, createServiceAccountVerifier } from './firebase/remote-verifier.js';
@@ -0,0 +1,108 @@
1
+ import { HTTPException } from 'hono/http-exception';
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
+ /**
4
+ * Normalized, provider-agnostic description of a failed Stripe payment.
5
+ *
6
+ * @remarks
7
+ * Extracted from a Stripe `PaymentIntent`, `Invoice`, or a thrown `StripeCardError` by
8
+ * {@link extractStripeFailureReason}. Fields are all optional because Stripe does not always populate
9
+ * a decline reason (e.g. a Strong Customer Authentication challenge carries no `decline_code`).
10
+ */
11
+ export interface StripeFailureReason {
12
+ /** `last_payment_error.code`, e.g. `card_declined` / `authentication_required`. */
13
+ code?: string;
14
+ /** `last_payment_error.decline_code`, e.g. `insufficient_funds` / `expired_card`. */
15
+ declineCode?: string;
16
+ /** Stripe's original (English) failure message. Stored for debugging; never shown to users. */
17
+ message?: string;
18
+ /** Id of the PaymentIntent the failure originated from, when resolvable. */
19
+ paymentIntentId?: string;
20
+ /** Id of the Invoice the failure originated from, when resolvable. */
21
+ invoiceId?: string;
22
+ /** Id of the Subscription the failing invoice belongs to, when resolvable. */
23
+ subscriptionId?: string;
24
+ }
25
+ /** Where a {@link PaymentFailureRecord} was captured. */
26
+ export type PaymentFailureSource = 'webhook.invoice.payment_failed' | 'webhook.invoice.payment_action_required' | 'reconcile' | 'checkout' | 'iap';
27
+ /**
28
+ * A payment failure as persisted to the `payment_failed.receipt` column (JSON string).
29
+ *
30
+ * @remarks
31
+ * Only the raw {@link StripeFailureReason} is stored — the user-facing Japanese message is generated
32
+ * on read via {@link stripeFailureMessageJa} so that wording changes never require a data migration.
33
+ */
34
+ export interface PaymentFailureRecord {
35
+ reason: StripeFailureReason;
36
+ source: PaymentFailureSource;
37
+ /** ISO 8601 timestamp of when the failure was captured. */
38
+ occurredAt: string;
39
+ }
40
+ /**
41
+ * Extract a normalized {@link StripeFailureReason} from a Stripe `PaymentIntent`, `Invoice`, a
42
+ * `{ paymentIntent, invoice }` pair, or a thrown Stripe error.
43
+ *
44
+ * @remarks
45
+ * The Stripe SDK is intentionally **not** imported — inputs are typed `unknown` and inspected by
46
+ * duck typing (Stripe objects carry an `object` discriminator: `'payment_intent'` / `'invoice'`).
47
+ * This keeps the helper immune to Stripe SDK version differences between this kit and its consumers,
48
+ * matching the existing `isPermanentPaymentError` approach in the payment services.
49
+ *
50
+ * @param source - A PaymentIntent, Invoice, `{ paymentIntent?, invoice? }`, or thrown Stripe error.
51
+ * @returns The extracted reason, or `null` when nothing identifiable could be found.
52
+ */
53
+ export declare function extractStripeFailureReason(source: unknown): StripeFailureReason | null;
54
+ /**
55
+ * Render a {@link StripeFailureReason} as a single-sentence Japanese message safe to show to users.
56
+ *
57
+ * @remarks
58
+ * `decline_code` wins over `code`; anything unmapped (including `null`) falls back to a generic
59
+ * message. Fraud-related decline codes are deliberately masked with a generic phrase.
60
+ *
61
+ * @param reason - The extracted failure reason, or `null`.
62
+ * @returns A user-facing Japanese message.
63
+ */
64
+ export declare function stripeFailureMessageJa(reason: StripeFailureReason | null): string;
65
+ /**
66
+ * Serialize a {@link PaymentFailureRecord} for storage in the `payment_failed.receipt` column.
67
+ *
68
+ * @param record - The failure record to serialize.
69
+ * @returns A JSON string.
70
+ */
71
+ export declare function serializePaymentFailure(record: PaymentFailureRecord): string;
72
+ /**
73
+ * Parse a `payment_failed.receipt` value back into a {@link PaymentFailureRecord}.
74
+ *
75
+ * @param receipt - The stored JSON string, or `null`/`undefined`.
76
+ * @returns The parsed record, or `null` when absent or malformed.
77
+ */
78
+ export declare function parsePaymentFailure(receipt: string | null | undefined): PaymentFailureRecord | null;
79
+ /** Response body carried by {@link PaymentDeclinedError}. */
80
+ export interface PaymentDeclinedBody {
81
+ statusCode: number;
82
+ /** User-facing Japanese message. */
83
+ message: string;
84
+ code?: string;
85
+ declineCode?: string;
86
+ }
87
+ /**
88
+ * HTTP error for a synchronous card decline, carrying a user-facing Japanese message.
89
+ *
90
+ * @remarks
91
+ * Extends Hono's `HTTPException` and exposes a `body`, so `createHttpErrorHandler` returns that body
92
+ * verbatim (see {@link createHttpErrorHandler}). Defaults to `400` rather than the semantically
93
+ * correct `402` so it rides the fleet's existing client interceptor, which surfaces `4xx` bodies with
94
+ * a `message` to the user.
95
+ */
96
+ export declare class PaymentDeclinedError extends HTTPException {
97
+ readonly body: PaymentDeclinedBody;
98
+ constructor(reason: StripeFailureReason | null, status?: ContentfulStatusCode);
99
+ }
100
+ /**
101
+ * Convert a thrown Stripe error into a {@link PaymentDeclinedError}, or `null` when it is not a card
102
+ * decline (the caller should re-throw so it maps to a generic 500).
103
+ *
104
+ * @param error - The value thrown by a Stripe SDK call.
105
+ * @param status - HTTP status for the resulting error; defaults to `400` (see {@link PaymentDeclinedError}).
106
+ * @returns A {@link PaymentDeclinedError}, or `null` when `error` is not a card decline.
107
+ */
108
+ export declare function toPaymentDeclinedError(error: unknown, status?: ContentfulStatusCode): PaymentDeclinedError | null;
@@ -0,0 +1,208 @@
1
+ import { HTTPException } from 'hono/http-exception';
2
+ const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
3
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
4
+ /** Pull `{ code, decline_code, message }` out of a Stripe error-shaped object, or `null` if empty. */
5
+ function readErrorShape(v) {
6
+ const r = asRecord(v);
7
+ if (!r) {
8
+ return null;
9
+ }
10
+ const code = str(r.code);
11
+ const declineCode = str(r.decline_code);
12
+ const message = str(r.message);
13
+ if (!code && !declineCode && !message) {
14
+ return null;
15
+ }
16
+ return { code, declineCode, message };
17
+ }
18
+ /** Resolve a Stripe reference (id string or expanded object with an `id`) to its id string. */
19
+ function refId(v) {
20
+ if (typeof v === 'string') {
21
+ return str(v);
22
+ }
23
+ return str(asRecord(v)?.id);
24
+ }
25
+ /**
26
+ * Extract a normalized {@link StripeFailureReason} from a Stripe `PaymentIntent`, `Invoice`, a
27
+ * `{ paymentIntent, invoice }` pair, or a thrown Stripe error.
28
+ *
29
+ * @remarks
30
+ * The Stripe SDK is intentionally **not** imported — inputs are typed `unknown` and inspected by
31
+ * duck typing (Stripe objects carry an `object` discriminator: `'payment_intent'` / `'invoice'`).
32
+ * This keeps the helper immune to Stripe SDK version differences between this kit and its consumers,
33
+ * matching the existing `isPermanentPaymentError` approach in the payment services.
34
+ *
35
+ * @param source - A PaymentIntent, Invoice, `{ paymentIntent?, invoice? }`, or thrown Stripe error.
36
+ * @returns The extracted reason, or `null` when nothing identifiable could be found.
37
+ */
38
+ export function extractStripeFailureReason(source) {
39
+ const root = asRecord(source);
40
+ if (!root) {
41
+ return null;
42
+ }
43
+ let paymentIntent = null;
44
+ let invoice = null;
45
+ let thrown = null;
46
+ if (root.paymentIntent !== undefined || root.invoice !== undefined) {
47
+ // Normalized container: { paymentIntent?, invoice? }.
48
+ paymentIntent = asRecord(root.paymentIntent);
49
+ invoice = asRecord(root.invoice);
50
+ }
51
+ else if (str(root.object) === 'payment_intent') {
52
+ paymentIntent = root;
53
+ }
54
+ else if (str(root.object) === 'invoice') {
55
+ invoice = root;
56
+ }
57
+ else if (str(root.code) || str(root.decline_code) || root.type === 'StripeCardError') {
58
+ // A thrown Stripe error; it may carry an expanded PaymentIntent.
59
+ thrown = root;
60
+ paymentIntent = asRecord(root.payment_intent);
61
+ }
62
+ // An invoice may embed its PaymentIntent (when expanded).
63
+ if (invoice && !paymentIntent) {
64
+ paymentIntent = asRecord(invoice.payment_intent);
65
+ }
66
+ const errorShape = readErrorShape(paymentIntent?.last_payment_error) ??
67
+ readErrorShape(invoice?.last_finalization_error) ??
68
+ (thrown ? readErrorShape(thrown) : null);
69
+ const reason = {
70
+ code: errorShape?.code,
71
+ declineCode: errorShape?.declineCode,
72
+ message: errorShape?.message,
73
+ paymentIntentId: refId(paymentIntent?.id ?? thrown?.payment_intent),
74
+ invoiceId: refId(invoice?.id),
75
+ subscriptionId: refId(invoice?.subscription),
76
+ };
77
+ // Strong Customer Authentication: no decline_code, but the intent is stuck on `requires_action`.
78
+ if (!reason.code && !reason.declineCode && str(paymentIntent?.status) === 'requires_action') {
79
+ reason.code = 'authentication_required';
80
+ }
81
+ const hasAny = reason.code ??
82
+ reason.declineCode ??
83
+ reason.message ??
84
+ reason.paymentIntentId ??
85
+ reason.invoiceId ??
86
+ reason.subscriptionId;
87
+ return hasAny ? reason : null;
88
+ }
89
+ const GENERIC_MESSAGE_JA = '前回のカード決済に失敗しました。カード情報をご確認のうえ、再度お試しください。';
90
+ const FRAUD_MESSAGE_JA = 'カードがご利用いただけませんでした。カード会社にお問い合わせいただくか、別のカードをお試しください。';
91
+ /** decline_code → Japanese, taking precedence over {@link CODE_MESSAGES_JA}. */
92
+ const DECLINE_CODE_MESSAGES_JA = {
93
+ insufficient_funds: '残高不足のためカードが承認されませんでした。別のカードをお試しください。',
94
+ expired_card: 'カードの有効期限が切れています。カード情報を更新してください。',
95
+ incorrect_cvc: 'セキュリティコード(CVC)が正しくありません。カード情報をご確認ください。',
96
+ incorrect_number: 'カード番号が正しくありません。カード情報をご確認ください。',
97
+ card_not_supported: 'このカードはご利用いただけません。別のカードをお試しください。',
98
+ currency_not_supported: 'このカードは対象の通貨に対応していません。別のカードをお試しください。',
99
+ card_velocity_exceeded: 'ご利用限度を超えたためカードが承認されませんでした。時間をおいて再度お試しください。',
100
+ withdrawal_count_limit_exceeded: 'ご利用限度を超えたためカードが承認されませんでした。時間をおいて再度お試しください。',
101
+ processing_error: '決済処理中にエラーが発生しました。時間をおいて再度お試しください。',
102
+ try_again_later: '一時的な理由でカードが承認されませんでした。時間をおいて再度お試しください。',
103
+ // Fraud / security signals are collapsed to a generic message (Stripe recommends not revealing the reason).
104
+ lost_card: FRAUD_MESSAGE_JA,
105
+ stolen_card: FRAUD_MESSAGE_JA,
106
+ pickup_card: FRAUD_MESSAGE_JA,
107
+ do_not_honor: FRAUD_MESSAGE_JA,
108
+ generic_decline: FRAUD_MESSAGE_JA,
109
+ fraudulent: FRAUD_MESSAGE_JA,
110
+ };
111
+ /** last_payment_error.code → Japanese, used when no decline_code mapping matched. */
112
+ const CODE_MESSAGES_JA = {
113
+ card_declined: FRAUD_MESSAGE_JA,
114
+ expired_card: 'カードの有効期限が切れています。カード情報を更新してください。',
115
+ incorrect_cvc: 'セキュリティコード(CVC)が正しくありません。カード情報をご確認ください。',
116
+ incorrect_number: 'カード番号が正しくありません。カード情報をご確認ください。',
117
+ processing_error: '決済処理中にエラーが発生しました。時間をおいて再度お試しください。',
118
+ authentication_required: 'カード認証(3Dセキュア)が必要です。お手数ですが、もう一度お手続きをお願いします。',
119
+ };
120
+ /**
121
+ * Render a {@link StripeFailureReason} as a single-sentence Japanese message safe to show to users.
122
+ *
123
+ * @remarks
124
+ * `decline_code` wins over `code`; anything unmapped (including `null`) falls back to a generic
125
+ * message. Fraud-related decline codes are deliberately masked with a generic phrase.
126
+ *
127
+ * @param reason - The extracted failure reason, or `null`.
128
+ * @returns A user-facing Japanese message.
129
+ */
130
+ export function stripeFailureMessageJa(reason) {
131
+ if (!reason) {
132
+ return GENERIC_MESSAGE_JA;
133
+ }
134
+ if (reason.declineCode && DECLINE_CODE_MESSAGES_JA[reason.declineCode]) {
135
+ return DECLINE_CODE_MESSAGES_JA[reason.declineCode];
136
+ }
137
+ if (reason.code && CODE_MESSAGES_JA[reason.code]) {
138
+ return CODE_MESSAGES_JA[reason.code];
139
+ }
140
+ return GENERIC_MESSAGE_JA;
141
+ }
142
+ /**
143
+ * Serialize a {@link PaymentFailureRecord} for storage in the `payment_failed.receipt` column.
144
+ *
145
+ * @param record - The failure record to serialize.
146
+ * @returns A JSON string.
147
+ */
148
+ export function serializePaymentFailure(record) {
149
+ return JSON.stringify(record);
150
+ }
151
+ /**
152
+ * Parse a `payment_failed.receipt` value back into a {@link PaymentFailureRecord}.
153
+ *
154
+ * @param receipt - The stored JSON string, or `null`/`undefined`.
155
+ * @returns The parsed record, or `null` when absent or malformed.
156
+ */
157
+ export function parsePaymentFailure(receipt) {
158
+ if (!receipt) {
159
+ return null;
160
+ }
161
+ try {
162
+ const parsed = JSON.parse(receipt);
163
+ const r = asRecord(parsed);
164
+ if (!r || !asRecord(r.reason) || typeof r.source !== 'string' || typeof r.occurredAt !== 'string') {
165
+ return null;
166
+ }
167
+ return parsed;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /**
174
+ * HTTP error for a synchronous card decline, carrying a user-facing Japanese message.
175
+ *
176
+ * @remarks
177
+ * Extends Hono's `HTTPException` and exposes a `body`, so `createHttpErrorHandler` returns that body
178
+ * verbatim (see {@link createHttpErrorHandler}). Defaults to `400` rather than the semantically
179
+ * correct `402` so it rides the fleet's existing client interceptor, which surfaces `4xx` bodies with
180
+ * a `message` to the user.
181
+ */
182
+ export class PaymentDeclinedError extends HTTPException {
183
+ body;
184
+ constructor(reason, status = 400) {
185
+ const message = stripeFailureMessageJa(reason);
186
+ super(status, { message });
187
+ this.body = { statusCode: status, message, code: reason?.code, declineCode: reason?.declineCode };
188
+ }
189
+ }
190
+ /**
191
+ * Convert a thrown Stripe error into a {@link PaymentDeclinedError}, or `null` when it is not a card
192
+ * decline (the caller should re-throw so it maps to a generic 500).
193
+ *
194
+ * @param error - The value thrown by a Stripe SDK call.
195
+ * @param status - HTTP status for the resulting error; defaults to `400` (see {@link PaymentDeclinedError}).
196
+ * @returns A {@link PaymentDeclinedError}, or `null` when `error` is not a card decline.
197
+ */
198
+ export function toPaymentDeclinedError(error, status = 400) {
199
+ const r = asRecord(error);
200
+ if (!r) {
201
+ return null;
202
+ }
203
+ const isCardError = r.type === 'StripeCardError' || !!str(r.decline_code) || str(r.code) === 'card_declined';
204
+ if (!isCardError) {
205
+ return null;
206
+ }
207
+ return new PaymentDeclinedError(extractStripeFailureReason(error), status);
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.6.3",
3
+ "version": "0.6.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"