@rdlabo/workers-hono-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/dist/ai/gateway.d.ts +40 -0
  4. package/dist/ai/gateway.js +36 -0
  5. package/dist/aws/cloudfront.d.ts +9 -0
  6. package/dist/aws/cloudfront.js +43 -0
  7. package/dist/aws/secrets-manager.d.ts +14 -0
  8. package/dist/aws/secrets-manager.js +43 -0
  9. package/dist/cache/kv-cache.d.ts +51 -0
  10. package/dist/cache/kv-cache.js +88 -0
  11. package/dist/db/connection.d.ts +41 -0
  12. package/dist/db/connection.js +48 -0
  13. package/dist/db/database.d.ts +60 -0
  14. package/dist/db/database.js +63 -0
  15. package/dist/db/index.d.ts +10 -0
  16. package/dist/db/index.js +8 -0
  17. package/dist/db/jst.d.ts +19 -0
  18. package/dist/db/jst.js +48 -0
  19. package/dist/db/orm-config.d.ts +62 -0
  20. package/dist/db/orm-config.js +42 -0
  21. package/dist/db/retry.d.ts +6 -0
  22. package/dist/db/retry.js +22 -0
  23. package/dist/db/write-result.d.ts +16 -0
  24. package/dist/db/write-result.js +13 -0
  25. package/dist/firebase/firebase-verifier.d.ts +17 -0
  26. package/dist/firebase/firebase-verifier.js +1 -0
  27. package/dist/firebase/identity-toolkit.d.ts +24 -0
  28. package/dist/firebase/identity-toolkit.js +64 -0
  29. package/dist/firebase/jose-firebase-verifier.d.ts +32 -0
  30. package/dist/firebase/jose-firebase-verifier.js +52 -0
  31. package/dist/firebase/remote-verifier.d.ts +9 -0
  32. package/dist/firebase/remote-verifier.js +44 -0
  33. package/dist/http/app-env.d.ts +19 -0
  34. package/dist/http/app-env.js +16 -0
  35. package/dist/http/app-info.d.ts +11 -0
  36. package/dist/http/app-info.js +8 -0
  37. package/dist/http/http-status.d.ts +62 -0
  38. package/dist/http/http-status.js +63 -0
  39. package/dist/http/nest-error.d.ts +72 -0
  40. package/dist/http/nest-error.js +65 -0
  41. package/dist/http/user-protocol.d.ts +11 -0
  42. package/dist/http/user-protocol.js +8 -0
  43. package/dist/index.d.ts +30 -0
  44. package/dist/index.js +28 -0
  45. package/dist/middleware/auth.d.ts +36 -0
  46. package/dist/middleware/auth.js +30 -0
  47. package/dist/middleware/finalize-response.d.ts +2 -0
  48. package/dist/middleware/finalize-response.js +53 -0
  49. package/dist/middleware/validation.d.ts +77 -0
  50. package/dist/middleware/validation.js +53 -0
  51. package/dist/middleware/zod-coerce.d.ts +9 -0
  52. package/dist/middleware/zod-coerce.js +50 -0
  53. package/dist/stripe/client.d.ts +19 -0
  54. package/dist/stripe/client.js +23 -0
  55. package/dist/testing/auth.d.ts +36 -0
  56. package/dist/testing/auth.js +42 -0
  57. package/dist/testing/configurable-fake.d.ts +14 -0
  58. package/dist/testing/configurable-fake.js +33 -0
  59. package/dist/testing/db.d.ts +37 -0
  60. package/dist/testing/db.js +74 -0
  61. package/dist/testing/fakes.d.ts +35 -0
  62. package/dist/testing/fakes.js +56 -0
  63. package/dist/testing/index.d.ts +8 -0
  64. package/dist/testing/index.js +10 -0
  65. package/dist/testing/stripe-fixtures.d.ts +13 -0
  66. package/dist/testing/stripe-fixtures.js +76 -0
  67. package/package.json +113 -0
  68. package/scripts/sync-dev-aws.mjs +59 -0
  69. package/src/ai/gateway.ts +81 -0
  70. package/src/aws/cloudfront.ts +65 -0
  71. package/src/aws/secrets-manager.ts +63 -0
  72. package/src/cache/kv-cache.ts +134 -0
  73. package/src/db/connection.ts +73 -0
  74. package/src/db/database.ts +133 -0
  75. package/src/db/index.ts +27 -0
  76. package/src/db/jst.ts +56 -0
  77. package/src/db/orm-config.ts +71 -0
  78. package/src/db/retry.ts +21 -0
  79. package/src/db/write-result.ts +23 -0
  80. package/src/firebase/firebase-verifier.ts +15 -0
  81. package/src/firebase/identity-toolkit.ts +82 -0
  82. package/src/firebase/jose-firebase-verifier.ts +71 -0
  83. package/src/firebase/remote-verifier.ts +49 -0
  84. package/src/http/app-env.ts +20 -0
  85. package/src/http/app-info.ts +16 -0
  86. package/src/http/http-status.ts +62 -0
  87. package/src/http/nest-error.ts +138 -0
  88. package/src/http/user-protocol.ts +16 -0
  89. package/src/index.ts +55 -0
  90. package/src/middleware/auth.ts +67 -0
  91. package/src/middleware/finalize-response.ts +61 -0
  92. package/src/middleware/validation.ts +84 -0
  93. package/src/middleware/zod-coerce.ts +65 -0
  94. package/src/stripe/client.ts +48 -0
  95. package/src/testing/auth.ts +62 -0
  96. package/src/testing/configurable-fake.ts +33 -0
  97. package/src/testing/db.ts +125 -0
  98. package/src/testing/fakes.ts +75 -0
  99. package/src/testing/index.ts +26 -0
  100. package/src/testing/stripe-fixtures.ts +85 -0
@@ -0,0 +1,138 @@
1
+ import type { Context, Env } from 'hono';
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
+
4
+ /**
5
+ * NestJS の既定例外フィルタが付ける reason phrase(フリート共通 = receptray/winecode/foodlabel の
6
+ * REASON_PHRASE / DEFAULT_MESSAGES と同一)。3 repo がそれぞれ手書きしていたものを一本化する。
7
+ */
8
+ export const NEST_REASON_PHRASES: Record<number, string> = {
9
+ 400: 'Bad Request',
10
+ 401: 'Unauthorized',
11
+ 403: 'Forbidden',
12
+ 404: 'Not Found',
13
+ };
14
+
15
+ /** 想定外エラーの通報先に渡す文脈(request id 相関など)。フリート共通の最小形。 */
16
+ export interface ErrorReportContext {
17
+ requestId?: string;
18
+ }
19
+
20
+ /**
21
+ * 想定外エラーの通報関数(Sentry 等)の型。各 repo の container.reportError がこの形。
22
+ * `createNestErrorHandler({ onUnhandledError })` に `(err, c) => reporter(err, { requestId: c.get('requestId') })`
23
+ * の形で差し込む。Sentry 呼び出し自体は各 repo(@sentry/cloudflare は workers-hono-kit に持ち込まない)。
24
+ */
25
+ export type ErrorReporter = (error: unknown, context?: ErrorReportContext) => void;
26
+
27
+ /** http エラーとみなされた値から status / message / body を読むための最小形。 */
28
+ interface HttpErrorLike {
29
+ status: ContentfulStatusCode;
30
+ message: string;
31
+ /** repo 固有 body の脱出口(winecode の HttpError.body 相当)。あればそのまま render する。 */
32
+ body?: unknown;
33
+ }
34
+
35
+ export interface NestErrorHandlerOptions<E extends Env = Env> {
36
+ /** reason phrase map。既定 `NEST_REASON_PHRASES`。 */
37
+ reasonPhrases?: Record<number, string>;
38
+ /**
39
+ * `error` フィールドを省いて `{ statusCode, message }` のみ返す status。既定 `[401]`
40
+ * (NestJS の generic `HttpException(msg, 401)` は `error` を持たない)。
41
+ */
42
+ bareStatuses?: readonly number[];
43
+ /**
44
+ * 非 bare body のフィールド順序。既定 `'statusCode-first'`(= NestJS canonical / receptray・winecode)。
45
+ * **foodlabel は `'message-first'`** を指定して `{ message, error, statusCode }` の byte-parity を保つ。
46
+ */
47
+ fieldOrder?: 'statusCode-first' | 'message-first';
48
+ /**
49
+ * reasonPhrases に無い(かつ bare でない)status の `error` フォールバック。既定 `undefined`
50
+ * (= reason 無しなら `error` を省く)。**winecode は `'Error'`** を指定し、全 status で `error` を必ず出す
51
+ * (NestJS 既定例外フィルタの「error は常に存在」を忠実再現)。
52
+ */
53
+ fallbackReason?: string;
54
+ /**
55
+ * http エラー判定。既定は hono の `HTTPException`。
56
+ * **winecode は独自 `HttpError` を使う**ため `(e) => e instanceof HttpError` を渡す。
57
+ */
58
+ isHttpError?: (err: unknown) => err is HttpErrorLike;
59
+ /**
60
+ * http エラーでない(= 想定外)エラーを 500 で返す前に呼ぶフック(Sentry 通報など)。
61
+ * **receptray は `container.reportError?.(err, { requestId })`** を差し込む。例外は握り潰す。
62
+ */
63
+ onUnhandledError?: (err: unknown, c: Context<E>) => void;
64
+ /** 想定外エラー時の 500 body。既定 `{ statusCode: 500, message: 'Internal server error' }`。 */
65
+ internalServerErrorBody?: unknown;
66
+ }
67
+
68
+ /**
69
+ * hono の `HTTPException` を **構造的に**判定する(`instanceof` ではない)。workers-hono-kit は consumer に
70
+ * symlink 同梱されるため、workers-hono-kit が解決する `hono` と consumer の `hono` が別インスタンスになり得る
71
+ * (別コピーの HTTPException は `instanceof` で一致しない)。`getResponse()` と数値 `status` を持つかで
72
+ * 判定すればモジュール境界をまたいでも、prod バンドルでも安定する。
73
+ */
74
+ const isHTTPException = (err: unknown): err is HttpErrorLike =>
75
+ err instanceof Error &&
76
+ typeof (err as { getResponse?: unknown }).getResponse === 'function' &&
77
+ typeof (err as { status?: unknown }).status === 'number';
78
+
79
+ /**
80
+ * NestJS の例外フィルタ相当の Hono `onError` ハンドラを作る(フリート共通)。
81
+ * - http エラー(既定 `HTTPException`)→ Nest 形 body にマップ。`body` を持つ場合はそれを verbatim で返す。
82
+ * - bareStatuses(既定 401)は `error` フィールド無し。
83
+ * - それ以外(想定外エラー)→ `onUnhandledError` 通報 + `console.error` + 500。
84
+ *
85
+ * `app.onError(createNestErrorHandler(...))` の形で使う。各 repo の parity 差異(body 順序・
86
+ * エラー型・通報フック)は options で吸収し、本体の分岐ロジックは共有する。
87
+ */
88
+ export function createNestErrorHandler<E extends Env = Env>(options: NestErrorHandlerOptions<E> = {}) {
89
+ const {
90
+ reasonPhrases = NEST_REASON_PHRASES,
91
+ bareStatuses = [401],
92
+ fieldOrder = 'statusCode-first',
93
+ isHttpError = isHTTPException,
94
+ onUnhandledError,
95
+ internalServerErrorBody = { statusCode: 500, message: 'Internal server error' },
96
+ fallbackReason,
97
+ } = options;
98
+
99
+ return (err: Error, c: Context<E>): Response => {
100
+ if (isHttpError(err)) {
101
+ // repo 固有 body の脱出口(winecode)。
102
+ if (err.body !== undefined) {
103
+ return c.json(err.body as object, err.status);
104
+ }
105
+ // reasonPhrases[status] は型上 string だが noUncheckedIndexedAccess 無効のため実際は未定義になり得る。
106
+ // 未登録 status の fallbackReason フォールバックは意図的。
107
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
108
+ const reason = bareStatuses.includes(err.status) ? undefined : (reasonPhrases[err.status] ?? fallbackReason);
109
+ if (reason === undefined) {
110
+ return c.json({ statusCode: err.status, message: err.message }, err.status);
111
+ }
112
+ const body =
113
+ fieldOrder === 'message-first'
114
+ ? { message: err.message, error: reason, statusCode: err.status }
115
+ : { statusCode: err.status, message: err.message, error: reason };
116
+ return c.json(body, err.status);
117
+ }
118
+
119
+ try {
120
+ onUnhandledError?.(err, c);
121
+ } catch {
122
+ // 通報はエラーレスポンスの挙動を変えてはならない。
123
+ }
124
+ console.error(err);
125
+ return c.json(internalServerErrorBody as object, 500);
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Express/Nest 既定の未マッチルート 404 body を返す `notFound` ハンドラ。
131
+ * `app.notFound(nestNotFoundHandler)` で使う(receptray/winecode は未実装の parity ギャップ)。
132
+ */
133
+ export function nestNotFoundHandler(c: Context): Response {
134
+ return c.json(
135
+ { message: `Cannot ${c.req.method} ${new URL(c.req.url).pathname}`, error: 'Not Found', statusCode: 404 },
136
+ 404,
137
+ );
138
+ }
@@ -0,0 +1,16 @@
1
+ import type { Context } from 'hono';
2
+
3
+ /** クライアントの IP / UA(NestJS の @UserProtocol デコレータ相当)。 */
4
+ export interface IUserProtocol {
5
+ ipAddress: string | null;
6
+ userAgent: string | null;
7
+ }
8
+
9
+ /**
10
+ * Hono Context からクライアント IP / UA を取得する。Cloudflare は実 IP を `CF-Connecting-IP` に入れる
11
+ * (`X-Forwarded-For` はフォールバック)。未取得は null(DB の nullable カラムにそのまま入る)。
12
+ */
13
+ export const getUserProtocol = (c: Context): IUserProtocol => ({
14
+ ipAddress: c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null,
15
+ userAgent: c.req.header('user-agent') ?? null,
16
+ });
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ // @rdlabo/workers-hono-kit — フリート共通のインフラ層ヘルパ(receptray / winecode / foodlabel)。
2
+ // ドメイン・DB・各 repo 固有の parity 差異(auth エラー status/body、secretId、Secret スキーマ)は
3
+ // 各 repo 側に残し、ここには「設定注入で汎用化できるインフラ」だけを置く。
4
+
5
+ // middleware
6
+ export { finalizeResponse } from './middleware/finalize-response';
7
+ export { validate, createSentryValidate } from './middleware/validation';
8
+ export type {
9
+ ValidateOptions,
10
+ ValidationTarget,
11
+ ZodErrorLike,
12
+ SentryLike,
13
+ SentryScopeLike,
14
+ } from './middleware/validation';
15
+ export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce';
16
+ export { createAuthMiddleware } from './middleware/auth';
17
+ export type { AuthMiddlewareOptions } from './middleware/auth';
18
+
19
+ // http
20
+ export { getUserProtocol } from './http/user-protocol';
21
+ export type { IUserProtocol } from './http/user-protocol';
22
+ export { getAppInfo } from './http/app-info';
23
+ export type { AppInfo } from './http/app-info';
24
+ export { resolveAppEnv, isProductionEnv } from './http/app-env';
25
+ export type { AppEnv } from './http/app-env';
26
+ export { HttpStatus } from './http/http-status';
27
+ export { createNestErrorHandler, nestNotFoundHandler, NEST_REASON_PHRASES } from './http/nest-error';
28
+ export type { NestErrorHandlerOptions, ErrorReportContext, ErrorReporter } from './http/nest-error';
29
+
30
+ // cache
31
+ export { KVCache } from './cache/kv-cache';
32
+ export type { KVNamespace, KVCacheOptions } from './cache/kv-cache';
33
+
34
+ // stripe
35
+ export { createStripeClient, verifyStripeWebhook } from './stripe/client';
36
+ export type { CreateStripeClientOptions } from './stripe/client';
37
+
38
+ // db
39
+ export { retryWhenDeadlock } from './db/retry';
40
+
41
+ // ai
42
+ export { createAiGatewayProvider } from './ai/gateway';
43
+ export type { AiGatewayConfig, AiGatewayProvider, AiGatewayBinding, AiGateway, AiGatewayOptions } from './ai/gateway';
44
+
45
+ // aws
46
+ export { getAuthenticationSecret } from './aws/secrets-manager';
47
+ export type { AwsSecretsOptions } from './aws/secrets-manager';
48
+ export { getCloudFrontSignedUrl } from './aws/cloudfront';
49
+
50
+ // firebase
51
+ export type { DecodedIdToken, FirebaseVerifier } from './firebase/firebase-verifier';
52
+ export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier';
53
+ export { IdentityToolkit } from './firebase/identity-toolkit';
54
+ export type { ServiceAccount } from './firebase/identity-toolkit';
55
+ export { createRemoteFirebaseVerifier, createServiceAccountVerifier } from './firebase/remote-verifier';
@@ -0,0 +1,67 @@
1
+ import type { Context, Env, MiddlewareHandler } from 'hono';
2
+ import { HTTPException } from 'hono/http-exception';
3
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
4
+ import { getAppInfo } from '../http/app-info';
5
+ import type { AppInfo } from '../http/app-info';
6
+
7
+ export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
8
+ /** ID トークンを載せるヘッダ。既定 `'x-amz-security-token'`(フリート共通)。 */
9
+ tokenHeader?: string;
10
+ /** 生トークンを検証して record/decoded を返す。無効なら throw / reject すること。 */
11
+ verify: (token: string, c: Context<E>) => Promise<Verified>;
12
+ /**
13
+ * DB userId を解決(必要なら新規作成)する。**省略すると token-only**(検証のみ・login 用)になる。
14
+ * create-on-miss(`getUserIdFromFirebase(...).catch(() => createUser(...))`)は repo 側でここに合成する。
15
+ */
16
+ resolveUserId?: (verified: Verified, c: Context<E>, appInfo: AppInfo) => Promise<Id>;
17
+ /** 検証結果を c.var に載せる。repo 固有の var 名(`decodedToken` / `userRecord` / `userProtocol` 等)を注入する。 */
18
+ setContext: (c: Context<E>, data: { verified: Verified; appInfo: AppInfo; userId?: Id }) => void;
19
+ /**
20
+ * 失敗時の挙動。既定は `throw new HTTPException(failureStatus, { message: failureMessage })`
21
+ * (foodlabel/receptray と同形)。**winecode は `c.json(BODY, n)` を返す**ため上書きする。
22
+ */
23
+ onFailure?: (err: unknown, c: Context<E>) => Response;
24
+ /** 既定 onFailure の status。既定 `403`(token-only の 401 等は repo が上書き)。 */
25
+ failureStatus?: ContentfulStatusCode;
26
+ /** 既定 onFailure の message。既定 `'Forbidden resource'`。 */
27
+ failureMessage?: string;
28
+ }
29
+
30
+ /**
31
+ * NestJS の AuthGuard / TokenGuard 相当の認証 middleware を作る(フリート共通)。
32
+ * スケルトン(ヘッダ読取 → verify → getAppInfo → resolveUserId → setContext、失敗で console.error +
33
+ * onFailure)を共有し、repo 固有部分(verify / userId 解決 / var 名 / 失敗レスポンス)だけ注入させる。
34
+ * `resolveUserId` を省けば token-only middleware になる。
35
+ */
36
+ export function createAuthMiddleware<E extends Env = Env, Verified = unknown, Id = unknown>(
37
+ options: AuthMiddlewareOptions<E, Verified, Id>,
38
+ ): MiddlewareHandler<E> {
39
+ const {
40
+ tokenHeader = 'x-amz-security-token',
41
+ verify,
42
+ resolveUserId,
43
+ setContext,
44
+ onFailure,
45
+ failureStatus = 403,
46
+ failureMessage = 'Forbidden resource',
47
+ } = options;
48
+
49
+ return async (c, next) => {
50
+ try {
51
+ const token = c.req.header(tokenHeader) ?? '';
52
+ const verified = await verify(token, c);
53
+ const appInfo = getAppInfo(c);
54
+ const userId = resolveUserId ? await resolveUserId(verified, c, appInfo) : undefined;
55
+ setContext(c, { verified, appInfo, userId });
56
+ } catch (e) {
57
+ // Nest guard が false → ForbiddenException('Forbidden resource')。原因をログし、既定では throw して
58
+ // app.onError に Nest 形 body を描かせる(repo は onFailure で return 形に上書き可能)。
59
+ console.error(e);
60
+ if (onFailure) {
61
+ return onFailure(e, c);
62
+ }
63
+ throw new HTTPException(failureStatus, { message: failureMessage });
64
+ }
65
+ await next();
66
+ };
67
+ }
@@ -0,0 +1,61 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+
3
+ /**
4
+ * レスポンス最終化ミドルウェア。Express/Nest(移植元 `../api`)との byte 一致のため 2 点を行う
5
+ * (フリート共通仕様 = receptray/winecode hono と同一):
6
+ *
7
+ * 1. **JSON の charset**: Express の res.json は `application/json; charset=utf-8` を返すが、
8
+ * Hono の c.json は `application/json`(charset 無し)なので合わせる。
9
+ * 2. **weak ETag**: Express/`etag` パッケージ互換の
10
+ * `W/"<byteLength(16進)>-<sha1(body)をbase64して先頭27文字>"`。Express(=Nest) は GET 等のレスポンスに
11
+ * 既定で付与するため、hono/etag の独自形式ではなく Express の算法に厳密一致させる。
12
+ *
13
+ * SSE(text/event-stream)はストリームを buffer できないため両方スキップ。
14
+ */
15
+ async function weakEtag(body: ArrayBuffer): Promise<string> {
16
+ const digest = await crypto.subtle.digest('SHA-1', body);
17
+ const bytes = new Uint8Array(digest);
18
+ let bin = '';
19
+ for (const b of bytes) {
20
+ bin += String.fromCharCode(b);
21
+ }
22
+ const b64 = btoa(bin).substring(0, 27);
23
+ return `W/"${body.byteLength.toString(16)}-${b64}"`;
24
+ }
25
+
26
+ export function finalizeResponse(): MiddlewareHandler {
27
+ return async (c, next) => {
28
+ await next();
29
+
30
+ const status = c.res.status;
31
+ const contentType = c.res.headers.get('content-type') ?? '';
32
+
33
+ // SSE / ストリームは触らない。
34
+ if (contentType.includes('text/event-stream')) {
35
+ return;
36
+ }
37
+
38
+ // charset 補正対象(JSON で charset 未指定なら付与)。
39
+ const needsCharset = contentType === 'application/json';
40
+ // ETag 対象(Express は 204/304 では付けない・既存 ETag は尊重)。
41
+ const needsEtag = status !== 204 && status !== 304 && !c.res.headers.has('etag') && !!c.res.body;
42
+
43
+ if (!needsCharset && !needsEtag) {
44
+ return;
45
+ }
46
+
47
+ const headers = new Headers(c.res.headers);
48
+ if (needsCharset) {
49
+ headers.set('content-type', 'application/json; charset=utf-8');
50
+ }
51
+
52
+ if (needsEtag) {
53
+ const buf = await c.res.clone().arrayBuffer();
54
+ headers.set('ETag', await weakEtag(buf));
55
+ c.res = new Response(buf, { status, statusText: c.res.statusText, headers });
56
+ } else {
57
+ // body を読まずヘッダだけ差し替え。
58
+ c.res = new Response(c.res.body, { status, statusText: c.res.statusText, headers });
59
+ }
60
+ };
61
+ }
@@ -0,0 +1,84 @@
1
+ import { zValidator } from '@hono/zod-validator';
2
+ import type { Context } from 'hono';
3
+ import type { ZodType } from 'zod';
4
+
5
+ /** zod v3(ZodError) / v4(core $ZodError) どちらの error でも受けられる最小形 */
6
+ export interface ZodErrorLike {
7
+ issues: readonly { path: PropertyKey[]; message: string }[];
8
+ }
9
+
10
+ export type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'cookie' | 'form';
11
+
12
+ export interface ValidateOptions {
13
+ /**
14
+ * 検証失敗時のフック(Sentry 通報など)。**検証の挙動は変えない** — レスポンスは常に
15
+ * NestJS ValidationPipe 同形の 400 を返す。例外を投げても握り潰す。
16
+ * 既定は no-op(receptray 互換 = 4xx を通報しない)。foodlabel は Sentry 実通報を差し込む。
17
+ */
18
+ onValidationError?: (error: ZodErrorLike, c: Context) => void;
19
+ }
20
+
21
+ /**
22
+ * Zod 検証ミドルウェア(フリート共通 = receptray/winecode hono と同一仕様)。
23
+ * 失敗時は NestJS の ValidationPipe と同形の body を返す:
24
+ * { statusCode: 400, message: string[], error: 'Bad Request' }
25
+ *
26
+ * NOTE(parity): message の文字列内容は class-validator と Zod で異なる。各 repo の固定 app の
27
+ * 正常系では DTO 検証 400 は発生しない前提(ビジネス 400 は各エンドポイントで HttpError 忠実再現)。
28
+ */
29
+
30
+ function zodToMessages(error: ZodErrorLike): string[] {
31
+ return error.issues.map((issue) => {
32
+ const path = issue.path.map(String).join('.');
33
+ return path ? `${path}: ${issue.message}` : issue.message;
34
+ });
35
+ }
36
+
37
+ export function validate<T>(target: ValidationTarget, schema: ZodType<T>, options?: ValidateOptions) {
38
+ return zValidator(target, schema, (result, c) => {
39
+ if (!result.success) {
40
+ const messages = zodToMessages(result.error);
41
+ // Surface the failing fields in the runtime log. Without this the 400 is
42
+ // invisible in `wrangler dev`/Workers logs (Sentry is the only sink, and
43
+ // it is not visible locally), so a field-level type mismatch — e.g. a
44
+ // string sent to `z.number()` — dies silently and is painful to diagnose.
45
+ // Paths + zod messages only; no request values are logged.
46
+ console.warn(`[validation] ${c.req.method} ${c.req.path} (${target}) → 400: ${messages.join('; ')}`);
47
+ try {
48
+ options?.onValidationError?.(result.error, c);
49
+ } catch {
50
+ // Reporting must never change validation error behavior.
51
+ }
52
+ return c.json({ statusCode: 400, message: messages, error: 'Bad Request' }, 400);
53
+ }
54
+ return undefined;
55
+ });
56
+ }
57
+
58
+ /** Sentry の最小形(`@sentry/cloudflare` 等への直接依存を避けるための構造型)。 */
59
+ export interface SentryScopeLike {
60
+ setTag(key: string, value: string): void;
61
+ setContext(key: string, context: Record<string, unknown> | null): void;
62
+ }
63
+ export interface SentryLike {
64
+ withScope(callback: (scope: SentryScopeLike) => void): void;
65
+ captureException(error: unknown): void;
66
+ }
67
+
68
+ /**
69
+ * DTO 検証 400 を Sentry に通報する `validate` を返すファクトリ(foodlabel/tipsys/winecode で重複していた
70
+ * reportValidationToSentry を集約)。kit は `@sentry/cloudflare` に依存せず、Sentry モジュールを注入する。
71
+ * 通報は `tag error.type=dto_validation` + `context validation={errorCount, errors}`(各 /api の
72
+ * sentry-validation.pipe.ts 準拠)。検証の挙動・レスポンスは `validate` と同一(通報は副作用のみ)。
73
+ */
74
+ export function createSentryValidate(sentry: SentryLike) {
75
+ const onValidationError = (error: ZodErrorLike): void => {
76
+ const messages = zodToMessages(error);
77
+ sentry.withScope((scope) => {
78
+ scope.setTag('error.type', 'dto_validation');
79
+ scope.setContext('validation', { errorCount: messages.length, errors: messages });
80
+ sentry.captureException(error);
81
+ });
82
+ };
83
+ return <T>(target: ValidationTarget, schema: ZodType<T>) => validate(target, schema, { onValidationError });
84
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * `/api` の class-transformer @Transform(number-transform.util.ts)を Zod preprocess で忠実再現する。
5
+ * path/query は常に文字列で来るため数値強制が必要。空白のみ文字列は NaN にして後段の数値スキーマで弾く
6
+ * (class-validator の @IsInt/@IsNumber が NaN を拒否する挙動と一致。zod v4 は z.number() が NaN を既定拒否)。
7
+ */
8
+
9
+ const isBlankString = (value: unknown): value is string => typeof value === 'string' && value.trim() === '';
10
+
11
+ // toNumber: 空白文字列 → NaN、それ以外 → Number(value)
12
+ const rawToNumber = (value: unknown): unknown => (isBlankString(value) ? Number(undefined) : Number(value));
13
+
14
+ // toNumberWithDefault: undefined/'' → default、空白 → NaN、else Number
15
+ const rawToNumberWithDefault =
16
+ (defaultValue: number) =>
17
+ (value: unknown): unknown => {
18
+ if (value === undefined || value === '') {
19
+ return defaultValue;
20
+ }
21
+ if (isBlankString(value)) {
22
+ return Number(undefined);
23
+ }
24
+ return Number(value);
25
+ };
26
+
27
+ // toOptionalNumber: undefined/null/'' → undefined、空白 → NaN、else Number
28
+ const rawToOptionalNumber = (value: unknown): unknown => {
29
+ if (value === undefined || value === null || value === '') {
30
+ return undefined;
31
+ }
32
+ if (isBlankString(value)) {
33
+ return Number(undefined);
34
+ }
35
+ return Number(value);
36
+ };
37
+
38
+ // toNullableNumber: null/undefined → passthrough、'' → undefined、空白 → NaN、else Number
39
+ const rawToNullableNumber = (value: unknown): unknown => {
40
+ if (value === null || value === undefined) {
41
+ return value;
42
+ }
43
+ if (value === '') {
44
+ return undefined;
45
+ }
46
+ if (isBlankString(value)) {
47
+ return Number(undefined);
48
+ }
49
+ return Number(value);
50
+ };
51
+
52
+ /**
53
+ * 数値強制スキーマ。`inner` に `z.number().int()` 等を渡して制約を足せる(既定は z.number())。
54
+ * 例: zNum(z.number().int()) で整数必須。
55
+ */
56
+ export const zNum = (inner: z.ZodNumber = z.number()): z.ZodType<number> => z.preprocess(rawToNumber, inner);
57
+
58
+ export const zNumWithDefault = (defaultValue: number, inner: z.ZodNumber = z.number()): z.ZodType<number> =>
59
+ z.preprocess(rawToNumberWithDefault(defaultValue), inner);
60
+
61
+ export const zNumOptional = (inner: z.ZodNumber = z.number()): z.ZodType<number | undefined> =>
62
+ z.preprocess(rawToOptionalNumber, inner.optional());
63
+
64
+ export const zNumNullable = (inner: z.ZodNumber = z.number()): z.ZodType<number | null | undefined> =>
65
+ z.preprocess(rawToNullableNumber, inner.nullish());
@@ -0,0 +1,48 @@
1
+ import Stripe from 'stripe';
2
+
3
+ /**
4
+ * Stripe クライアント生成(フリート共通 = receptray/tipsys hono)。Cloudflare Workers には Node の
5
+ * http スタックが無いため、Stripe SDK の fetch ベース HttpClient を使う。
6
+ *
7
+ * `apiVersion` は **任意**: 各 repo の `/api`(NestJS)と挙動を一致させるため、固定したい repo は
8
+ * 渡し(例 tipsys は `'2024-04-10'`)、SDK 既定で良い repo は省く(例 receptray)。
9
+ */
10
+ export interface CreateStripeClientOptions {
11
+ /** 固定する Stripe API バージョン。省略すると SDK 既定。 */
12
+ apiVersion?: string;
13
+ }
14
+
15
+ export function createStripeClient(secret: string, options: CreateStripeClientOptions = {}): Stripe {
16
+ if (!secret) {
17
+ throw new Error('Stripe secret is not set');
18
+ }
19
+ const config: Stripe.StripeConfig = { httpClient: Stripe.createFetchHttpClient() };
20
+ if (options.apiVersion) {
21
+ config.apiVersion = options.apiVersion as Stripe.StripeConfig['apiVersion'];
22
+ }
23
+ return new Stripe(secret, config);
24
+ }
25
+
26
+ /**
27
+ * Webhook 署名の検証。Workers では同期版 `constructEvent` が使えない(SubtleCrypto が非同期)ため
28
+ * `constructEventAsync` + `SubtleCryptoProvider` を使う。`secret` は署名検証には無関係だが、検証用の
29
+ * クライアント生成に必要(API コールはしない)。
30
+ */
31
+ export function verifyStripeWebhook(
32
+ secret: string,
33
+ webhookSecret: string,
34
+ payload: string | ArrayBuffer,
35
+ signature: string,
36
+ ): Promise<Stripe.Event> {
37
+ if (!webhookSecret) {
38
+ throw new Error('Stripe webhook secret is not set');
39
+ }
40
+ const stripe = createStripeClient(secret);
41
+ return stripe.webhooks.constructEventAsync(
42
+ payload as string,
43
+ signature,
44
+ webhookSecret,
45
+ undefined,
46
+ Stripe.createSubtleCryptoProvider(),
47
+ );
48
+ }
@@ -0,0 +1,62 @@
1
+ import type { Pool } from 'mysql2/promise';
2
+ import type { DecodedIdToken } from '../firebase/firebase-verifier';
3
+ import type { FakeFirebaseVerifier } from './fakes';
4
+
5
+ /**
6
+ * app interceptor 互換の認証ヘッダ(`x-amz-security-token` + `x-amz-meta-*`)を組む。
7
+ * fleet 全 hono repo の route spec で同形に重複していたものを集約。
8
+ *
9
+ * - `version` は `app_version`(varchar(10)) に入るため 10 文字以内。
10
+ * - `contentType: null` を渡すと content-type を付けない(GET 等)。
11
+ */
12
+ export function authHeaders(
13
+ token: string,
14
+ opts: { version?: string; uuid?: string; contentType?: string | null } = {},
15
+ ): Record<string, string> {
16
+ const headers: Record<string, string> = {
17
+ 'x-amz-security-token': token,
18
+ 'x-amz-meta-version': opts.version ?? '1.0.0',
19
+ 'x-amz-meta-uuid': opts.uuid ?? 'test-uuid',
20
+ };
21
+ if (opts.contentType !== null) {
22
+ headers['content-type'] = opts.contentType ?? 'application/json';
23
+ }
24
+ return headers;
25
+ }
26
+
27
+ /**
28
+ * fake firebase にトークンを登録するだけの薄いヘルパ(DB を触らない)。戻り値はトークン。
29
+ * `users` テーブル形が repo 固有(例: airlec は email 主)で provisionUser が合わない場合に使う。
30
+ */
31
+ export function registerFirebaseToken(
32
+ firebase: FakeFirebaseVerifier,
33
+ uid: string,
34
+ record: Partial<DecodedIdToken> = {},
35
+ token = `tok-${uid}`,
36
+ ): string {
37
+ firebase.register(token, { uid, ...record });
38
+ return token;
39
+ }
40
+
41
+ /**
42
+ * fake firebase にトークンを登録し、`users(firebase_uid)` 行を用意して userId を返す。
43
+ * `users(id, firebase_uid, agree)` の fleet 共通形を前提(foodlabel/receptray など)。同 uid の
44
+ * 既存行があれば再利用する(冪等)。users テーブル形が異なる repo は registerFirebaseToken + 独自
45
+ * provision を使う。
46
+ */
47
+ export async function provisionUser(
48
+ pool: Pool,
49
+ firebase: FakeFirebaseVerifier,
50
+ opts: { uid: string; token?: string; agree?: number; email?: string },
51
+ ): Promise<{ userId: number; uid: string; token: string }> {
52
+ const token = registerFirebaseToken(firebase, opts.uid, opts.email ? { email: opts.email } : {}, opts.token);
53
+
54
+ const [existing] = await pool.query('SELECT id FROM users WHERE firebase_uid = ?', [opts.uid]);
55
+ const rows = existing as { id: number }[];
56
+ if (rows.length > 0) {
57
+ return { userId: rows[0].id, uid: opts.uid, token };
58
+ }
59
+
60
+ const [res] = await pool.query('INSERT INTO users (agree, firebase_uid) VALUES (?, ?)', [opts.agree ?? 1, opts.uid]);
61
+ return { userId: (res as { insertId: number }).insertId, uid: opts.uid, token };
62
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 部分実装から test double を作る。設定済みメソッドはそのまま、未設定メソッドを呼ぶと
3
+ * `${name}.${method} not configured` で明示的に失敗する。
4
+ *
5
+ * 各 repo の Fake*Gateway に散っていた「`Partial<impl>` を受け取り、未設定なら throw する手書き
6
+ * クラス」の定型を一本化する。interface がドメインごとに異なる gateway(Stripe 等)でも、これで
7
+ * 1 行で必要メソッドだけ差した fake を作れる:
8
+ *
9
+ * const stripe = configurableFake<StripeGateway>(
10
+ * { listPaymentIntents: async () => fakeApiList([fakePaymentIntent()]) },
11
+ * 'FakeStripeGateway',
12
+ * );
13
+ */
14
+ export function configurableFake<T extends object>(impl: Partial<T>, name = 'fake'): T {
15
+ return new Proxy(impl, {
16
+ get(target, prop) {
17
+ if (prop in target) {
18
+ return (target as Record<string | symbol, unknown>)[prop];
19
+ }
20
+ // Promise インターロップ用プロパティには「未設定メソッド」関数を返さない。返すと fake 自身が
21
+ // thenable 扱いされ、誤って await / Promise.resolve した瞬間に then() が呼ばれて throw する罠になる。
22
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') {
23
+ return undefined;
24
+ }
25
+ if (typeof prop === 'string') {
26
+ return () => {
27
+ throw new Error(`${name}.${prop} not configured`);
28
+ };
29
+ }
30
+ return undefined;
31
+ },
32
+ }) as T;
33
+ }