@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,133 @@
1
+ import { createConnection } from 'mysql2/promise';
2
+ import type { Connection, Pool } from 'mysql2/promise';
3
+ import { hyperdriveConnectionOptions } from './connection';
4
+ import type { HyperdriveLike } from './connection';
5
+ import { retryWhenDeadlock } from './retry';
6
+
7
+ /**
8
+ * フリート共通のデータ層(NestJS/TypeORM の master/slave + retryWhenDeadlock 置換)。
9
+ * - reads → replica。透明性のため生 SQL を維持(旧 helper.slave().query 相当)。
10
+ * - writes → primary。型安全のため Drizzle 経由。ただし `write(fn)`/`transaction(fn)` 経由のみで、
11
+ * 生 builder は公開しない。builder はここで await される(Drizzle builder は lazy thenable のため
12
+ * `return builder` が黙って no-op になるフットガンを排除)。両者とも ER_LOCK_DEADLOCK を retry。
13
+ *
14
+ * 重要: kit は `drizzle-orm` の **型同一性に依存しない**。各 repo が自分の drizzle-orm で作った orm を
15
+ * 渡す(`Database` は orm 型 `TDrizzle` にジェネリック)。これにより kit と repo で drizzle-orm の
16
+ * コピーが分かれても(symlink 構成)`MySqlTable`/`SQL` のブランド衝突が起きない。
17
+ */
18
+
19
+ /** reads 用の最小接続インターフェース(mysql2 Connection / Pool が構造的に満たす)。 */
20
+ export interface QueryRunner {
21
+ query(sql: string, params?: unknown[]): Promise<unknown>;
22
+ }
23
+
24
+ /** drizzle インスタンスの `.transaction(cb)` が受け取る tx ハンドルの型を取り出す。 */
25
+ export type TxOf<TDrizzle> = TDrizzle extends {
26
+ transaction(cb: (tx: infer Tx) => Promise<unknown>): Promise<unknown>;
27
+ }
28
+ ? Tx
29
+ : unknown;
30
+
31
+ export interface Database<TDrizzle, TTx = TxOf<TDrizzle>> {
32
+ read<T>(sql: string, params?: unknown[]): Promise<T[]>;
33
+ /** 単一 INSERT/UPDATE/DELETE。primary で await + deadlock retry。 */
34
+ write<T>(fn: (dz: TDrizzle) => Promise<T>): Promise<T>;
35
+ /** 複数 write を 1 トランザクションで。deadlock 時は全体を retry。 */
36
+ transaction<T>(fn: (tx: TTx) => Promise<T>): Promise<T>;
37
+ }
38
+
39
+ /** dispose() を持つ Database(接続をモジュール内で開く版=Hyperdrive / Pool 背面)。 */
40
+ export interface DisposableDatabase<TDrizzle, TTx = TxOf<TDrizzle>> extends Database<TDrizzle, TTx> {
41
+ dispose(): Promise<void>;
42
+ }
43
+
44
+ interface DrizzleLike<TTx> {
45
+ transaction<T>(cb: (tx: TTx) => Promise<T>): Promise<T>;
46
+ }
47
+
48
+ export interface CreateMysqlDatabaseOptions<TDrizzle> {
49
+ /** 消費側が自分の drizzle-orm で `drizzle(primary, { schema, ... })` を作って渡す(writes 用)。 */
50
+ orm: TDrizzle;
51
+ /** reads(生 SQL)用の接続。 */
52
+ replica: QueryRunner;
53
+ }
54
+
55
+ /**
56
+ * 接続済みの orm/replica を受け取り Database を組み立てる(receptray/tipsys の MysqlDatabase 相当)。
57
+ * 接続・orm の生成と接続の破棄は呼び出し側(worker entry)が担う。
58
+ */
59
+ export function createMysqlDatabase<TDrizzle>(options: CreateMysqlDatabaseOptions<TDrizzle>): Database<TDrizzle> {
60
+ return databaseFrom(options.orm, options.replica);
61
+ }
62
+
63
+ export interface CreateHyperdriveDatabaseOptions<TDrizzle> {
64
+ primaryHyperdrive: HyperdriveLike;
65
+ replicaHyperdrive: HyperdriveLike;
66
+ /** 消費側の drizzle-orm で primary 接続から orm を作る factory(writes 用)。 */
67
+ createOrm: (primary: Connection) => TDrizzle;
68
+ /** createConnection に渡す追加オプション(timezone など)。disableEval:true は既定で付与。 */
69
+ connectionOptions?: Record<string, unknown>;
70
+ }
71
+
72
+ /**
73
+ * Hyperdrive バインディングから接続を遅延生成する Database(foodlabel の MysqlDatabase 相当)。
74
+ * リクエスト毎に new し、レスポンス後 `dispose()` で接続を閉じる。read/write/transaction の面は同一。
75
+ */
76
+ export function createHyperdriveDatabase<TDrizzle>(
77
+ options: CreateHyperdriveDatabaseOptions<TDrizzle>,
78
+ ): DisposableDatabase<TDrizzle> {
79
+ const { primaryHyperdrive, replicaHyperdrive, createOrm, connectionOptions } = options;
80
+ let primaryConn: Promise<Connection> | undefined;
81
+ let replicaConn: Promise<Connection> | undefined;
82
+ let orm: TDrizzle | undefined;
83
+
84
+ const primary = (): Promise<Connection> => (primaryConn ??= connect(primaryHyperdrive, connectionOptions));
85
+ const replica = (): Promise<Connection> => (replicaConn ??= connect(replicaHyperdrive, connectionOptions));
86
+ const ormFor = async (): Promise<TDrizzle> => (orm ??= createOrm(await primary()));
87
+
88
+ return {
89
+ read<T>(sql: string, params: unknown[] = []): Promise<T[]> {
90
+ return retryWhenDeadlock(async () => {
91
+ const [rows] = (await (await replica()).query(sql, params)) as [unknown, unknown];
92
+ return rows as T[];
93
+ });
94
+ },
95
+ async write<T>(fn: (dz: TDrizzle) => Promise<T>): Promise<T> {
96
+ const dz = await ormFor();
97
+ return retryWhenDeadlock(() => fn(dz));
98
+ },
99
+ async transaction<T>(fn: (tx: TxOf<TDrizzle>) => Promise<T>): Promise<T> {
100
+ const dz = (await ormFor()) as DrizzleLike<TxOf<TDrizzle>>;
101
+ return retryWhenDeadlock(() => dz.transaction(fn));
102
+ },
103
+ async dispose(): Promise<void> {
104
+ await Promise.all([primaryConn?.then((c) => c.end()), replicaConn?.then((c) => c.end())]);
105
+ },
106
+ };
107
+ }
108
+
109
+ /** orm と replica 接続から Database を組み立てる内部ヘルパ。 */
110
+ export function databaseFrom<TDrizzle>(orm: TDrizzle, replica: QueryRunner): Database<TDrizzle> {
111
+ const drizzleLike = orm as DrizzleLike<TxOf<TDrizzle>>;
112
+ return {
113
+ read<T>(sql: string, params: unknown[] = []): Promise<T[]> {
114
+ return retryWhenDeadlock(async () => {
115
+ const [rows] = (await replica.query(sql, params)) as [unknown, unknown];
116
+ return rows as T[];
117
+ });
118
+ },
119
+ write<T>(fn: (dz: TDrizzle) => Promise<T>): Promise<T> {
120
+ return retryWhenDeadlock(() => fn(orm));
121
+ },
122
+ transaction<T>(fn: (tx: TxOf<TDrizzle>) => Promise<T>): Promise<T> {
123
+ return retryWhenDeadlock(() => drizzleLike.transaction(fn));
124
+ },
125
+ };
126
+ }
127
+
128
+ /** Pool/Connection は mysql2 の型。kit の QueryRunner には構造的に代入可能。 */
129
+ export type { Connection, Pool };
130
+
131
+ function connect(hyperdrive: HyperdriveLike, extra?: Record<string, unknown>): Promise<Connection> {
132
+ return createConnection(hyperdriveConnectionOptions(hyperdrive, extra));
133
+ }
@@ -0,0 +1,27 @@
1
+ // @rdlabo/workers-hono-kit/db — mysql2 依存のデータ層ヘルパ(ルート `.` は web 標準のみのため別サブパス)。
2
+ // drizzle-orm の型同一性には依存しない(orm は消費側が渡す)。
3
+
4
+ export { retryWhenDeadlock } from './retry';
5
+
6
+ export { createMysqlDatabase, createHyperdriveDatabase, databaseFrom } from './database';
7
+ export type {
8
+ Database,
9
+ DisposableDatabase,
10
+ QueryRunner,
11
+ TxOf,
12
+ CreateMysqlDatabaseOptions,
13
+ CreateHyperdriveDatabaseOptions,
14
+ Connection,
15
+ Pool,
16
+ } from './database';
17
+
18
+ export { insertIdOf, affectedRowsOf, insertedIdsOf } from './write-result';
19
+ export type { DzWriteResult } from './write-result';
20
+
21
+ export { hyperdriveConnectionOptions, withMysqlConnections } from './connection';
22
+ export type { HyperdriveLike, ExecutionContextLike } from './connection';
23
+
24
+ export { toJstDate, jstTimestampParams, jstDatetimeParams, jstDateParams } from './jst';
25
+
26
+ export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig } from './orm-config';
27
+ export type { HonoDrizzleConfigOptions } from './orm-config';
package/src/db/jst.ts ADDED
@@ -0,0 +1,56 @@
1
+ // JST 日時の整形を「DB 書き込みの副作用」として列の境界に寄せるための共有部品。
2
+ // drizzle-orm の値・型は import しない(kit の脱・型同一性方針)。kit で完成した customType 列を
3
+ // export すると、消費側と drizzle-orm のコピーが分かれた場合に `.default(sql\`…\`)` 等で `SQL` の
4
+ // private プロパティ(shouldInlineParams)が nominal 不一致になり型衝突する(実測で確認)。そのため
5
+ // 列生成は消費側の `customType` に委ね、kit は params とヘルパだけ供給する:
6
+ //
7
+ // import { customType } from 'drizzle-orm/mysql-core';
8
+ // import { jstTimestampParams, jstDateParams } from '@rdlabo/workers-hono-kit/db';
9
+ // export const jstTimestamp = (name: string, opts?: { fsp?: number }) =>
10
+ // customType<{ data: string | Date; driverData: string | Date }>(jstTimestampParams(opts?.fsp))(name);
11
+ // export const jstDate = (name: string) =>
12
+ // customType<{ data: string | null; driverData: string | null }>(jstDateParams())(name);
13
+ //
14
+ // timestamp/datetime は toDriver を置かず Date を素通し → 接続の `timezone:'+09:00'`(hyperdrive
15
+ // 既定)で mysql2 が JST 整形、整形済み文字列も素通し。Drizzle ネイティブ `mode:'date'` は Date を
16
+ // tz 層より前に UTC 文字列化して −9h で壊れるため使わない(customType pass-through が唯一クリーン)。
17
+ // date 列はクライアントの ISO/空文字を MySQL DATE が弾く+JST 日跨ぎ正規化が要るので toJstDate を残す。
18
+
19
+ const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
20
+
21
+ /**
22
+ * クライアント送出の日付(ISO 8601 `...Z` / `YYYY-MM-DD` / 空文字)を MySQL DATE 用の
23
+ * `YYYY-MM-DD`(JST)へ正規化。nullish/空/解釈不能は null。MySQL DATE は ISO を弾く
24
+ * (ER_TRUNCATED_WRONG_VALUE)ため driver では代替できず、列の toDriver に必要。
25
+ */
26
+ export function toJstDate(value: string | null | undefined): string | null {
27
+ if (!value) {
28
+ return null;
29
+ }
30
+ const ms = new Date(value).getTime();
31
+ if (Number.isNaN(ms)) {
32
+ return null;
33
+ }
34
+ const jst = new Date(ms + JST_OFFSET_MS);
35
+ const p = (n: number): string => String(n).padStart(2, '0');
36
+ return `${jst.getUTCFullYear()}-${p(jst.getUTCMonth() + 1)}-${p(jst.getUTCDate())}`;
37
+ }
38
+
39
+ /** `customType` に渡す params: `timestamp(fsp)` 列(created_at 群)。Date 素通し(pass-through)。 */
40
+ export const jstTimestampParams = (fsp?: number): { dataType: () => string } => ({
41
+ dataType: () => (fsp != null ? `timestamp(${fsp})` : 'timestamp'),
42
+ });
43
+
44
+ /** `customType` に渡す params: `datetime` 列(payment.limit_at 等)。Date 素通し(pass-through)。 */
45
+ export const jstDatetimeParams = (fsp?: number): { dataType: () => string } => ({
46
+ dataType: () => (fsp != null ? `datetime(${fsp})` : 'datetime'),
47
+ });
48
+
49
+ /** `customType` に渡す params: `date` 列(expiry_date 等)。toJstDate で文字列を正規化。 */
50
+ export const jstDateParams = (): {
51
+ dataType: () => string;
52
+ toDriver: (value: string | null) => string | null;
53
+ } => ({
54
+ dataType: () => 'date',
55
+ toDriver: (value: string | null) => toJstDate(value),
56
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Drizzle の列名 casing をフリートで一元管理する(標準: snake_case を「config」と「runtime」の両方で固定)。
3
+ *
4
+ * casing は2か所にあり別物:
5
+ * ① drizzle.config.ts の top-level `casing` … `db:generate` が **作る列名** を決める(→ honoDrizzleConfig)
6
+ * ② database.ts の `drizzle(conn, { …casing })` … **実行時の書き込みビルダが参照する列名** を決める(→ DRIZZLE_ORM_OPTIONS)
7
+ *
8
+ * この2つが食い違うと、明示列名を書き忘れた camelCase 複数単語列で generate と実行時がズレて実行時
9
+ * `Unknown column` になる(typecheck もマイグレーションも正常に見えるので発覚が遅い)。両方ここから取れば
10
+ * ズレが構造的に起きない。明示列名がある列では casing は無視されるので、既存挙動は変えない純粋な安全網。
11
+ *
12
+ * 注: runtime の `drizzle()` 呼び出し自体は **消費側 repo が自分の drizzle-orm で行う**(kit が drizzle() を
13
+ * 呼ぶと kit と repo で drizzle-orm が別コピーになり型同一性が壊れるため)。kit は「値」だけを提供する。
14
+ */
15
+
16
+ /**
17
+ * runtime 用。消費側 repo の database.ts で `drizzle(conn, { schema, ...DRIZZLE_ORM_OPTIONS })` と spread して使う。
18
+ * mode/casing を kit が固定し、書き込みビルダの列名解決を snake_case に揃える。
19
+ */
20
+ export const DRIZZLE_ORM_OPTIONS = { mode: 'default', casing: 'snake_case' } as const;
21
+
22
+ export interface HonoDrizzleConfigOptions {
23
+ /** drizzle-kit の dbCredentials.database(localConnectionString とは別)。 */
24
+ database: string;
25
+ host?: string;
26
+ port?: number;
27
+ user?: string;
28
+ password?: string;
29
+ /** 既定 './src/db/schemes'。 */
30
+ schema?: string;
31
+ /** 既定 './drizzle'。 */
32
+ out?: string;
33
+ /** /api と DB を共有する repo は schema 由来テーブルに限定する(省略可)。 */
34
+ tablesFilter?: string[];
35
+ /** db:introspect(DB→JS)の casing。生成方向の `casing:'snake_case'` とは別軸(省略可)。 */
36
+ introspect?: { casing: 'camel' | 'preserve' };
37
+ }
38
+
39
+ /**
40
+ * drizzle.config.ts 用ファクトリ。`export default honoDrizzleConfig({ database })` で使う。
41
+ * casing:'snake_case'・schema/out・dbCredentials(env 既定)を kit が owner として固定する。
42
+ * drizzle-kit を kit の依存にしないため plain object を返す(drizzle-kit CLI は default export を読むだけ)。
43
+ */
44
+ export function honoDrizzleConfig(options: HonoDrizzleConfigOptions) {
45
+ const {
46
+ database,
47
+ host,
48
+ port,
49
+ user,
50
+ password,
51
+ schema = './src/db/schemes',
52
+ out = './drizzle',
53
+ tablesFilter,
54
+ introspect,
55
+ } = options;
56
+ return {
57
+ dialect: 'mysql' as const,
58
+ schema,
59
+ out,
60
+ casing: 'snake_case' as const,
61
+ ...(tablesFilter ? { tablesFilter } : {}),
62
+ ...(introspect ? { introspect } : {}),
63
+ dbCredentials: {
64
+ host: host ?? process.env.DB_HOST ?? '127.0.0.1',
65
+ port: port ?? Number(process.env.DB_PORT ?? 3306),
66
+ user: user ?? process.env.DB_USER ?? 'root',
67
+ password: password ?? process.env.DB_PASSWORD ?? 'root',
68
+ database,
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * ER_LOCK_DEADLOCK を指数バックオフで retry する(NestJS/TypeORM の retryWhenDeadlock 相当)。
3
+ * MySQL はデッドロック時にトランザクション全体をロールバックするため、同じ作業単位の再実行は安全。
4
+ * `fn` は単一文(write)またはトランザクション全体(transaction)であること — retry は `fn` 全体を再実行する。
5
+ */
6
+ export async function retryWhenDeadlock<T>(fn: () => Promise<T>, retries = 3, delay = 100): Promise<T> {
7
+ for (let attempt = 0; attempt < retries; attempt++) {
8
+ try {
9
+ return await fn();
10
+ } catch (error) {
11
+ const code = (error as { code?: string }).code;
12
+ if (code === 'ER_LOCK_DEADLOCK' && attempt < retries - 1) {
13
+ await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
14
+ continue;
15
+ }
16
+ throw error;
17
+ }
18
+ }
19
+ // Unreachable: the loop returns on success and throws on the final failed attempt.
20
+ throw new Error('retryWhenDeadlock: exhausted retries');
21
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Drizzle(mysql2) の write 結果から insertId / affectedRows を型安全に取り出すヘルパ。
3
+ * 生の builder/ResultSetHeader を repo 側に晒さずに、よく使う値だけを取り出す。
4
+ *
5
+ * mysql2 の INSERT/UPDATE/DELETE 結果は `[ResultSetHeader, FieldPacket[]]` 形。
6
+ */
7
+ export type DzWriteResult = readonly [{ insertId: number; affectedRows: number }, ...unknown[]];
8
+
9
+ export function insertIdOf(result: DzWriteResult): number {
10
+ return result[0].insertId;
11
+ }
12
+
13
+ export function affectedRowsOf(result: DzWriteResult): number {
14
+ return result[0].affectedRows;
15
+ }
16
+
17
+ /**
18
+ * 一括 INSERT で連番採番された行の id 群を返す(mysql2 は先頭 insertId のみ返すため count 分を生成)。
19
+ */
20
+ export function insertedIdsOf(result: DzWriteResult, count: number): number[] {
21
+ const base = result[0].insertId;
22
+ return Array.from({ length: count }, (_, i) => base + i);
23
+ }
@@ -0,0 +1,15 @@
1
+ /** Firebase boundary replacing the firebase-admin Auth surface used by `/api`. */
2
+ export interface DecodedIdToken {
3
+ uid: string;
4
+ email?: string;
5
+ [claim: string]: unknown;
6
+ }
7
+
8
+ export interface FirebaseVerifier {
9
+ /** Mirrors firebase-admin getAuth().verifyIdToken(). Throws on invalid token. */
10
+ verifyIdToken(idToken: string): Promise<DecodedIdToken>;
11
+ /** Mirrors getAuth().getUser(); returns null when the user is absent. */
12
+ getUser(uid: string): Promise<{ uid: string; email?: string } | null>;
13
+ /** Mirrors getAuth().deleteUser(). */
14
+ deleteUser(uid: string): Promise<void>;
15
+ }
@@ -0,0 +1,82 @@
1
+ import { SignJWT, importPKCS8 } from 'jose';
2
+
3
+ /**
4
+ * Minimal Google Identity Toolkit client for the operations firebase-admin performed
5
+ * that aren't token verification: accounts:lookup (getUser) and accounts:delete
6
+ * (deleteUser). Replaces the firebase-admin Node SDK, which won't run on workerd.
7
+ *
8
+ * Auth: sign a JWT assertion with the service-account private key (jose), exchange it for
9
+ * an OAuth2 access token, then call the REST API. Tokens are cached in-process.
10
+ */
11
+ export interface ServiceAccount {
12
+ client_email: string;
13
+ private_key: string;
14
+ project_id: string;
15
+ }
16
+
17
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
18
+ const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com/v1';
19
+ const SCOPE = 'https://www.googleapis.com/auth/identitytoolkit https://www.googleapis.com/auth/firebase';
20
+
21
+ export class IdentityToolkit {
22
+ private accessToken: { value: string; expiresAt: number } | null = null;
23
+
24
+ constructor(private readonly sa: ServiceAccount) {}
25
+
26
+ private async getAccessToken(nowSeconds: number): Promise<string> {
27
+ if (this.accessToken && this.accessToken.expiresAt > nowSeconds + 60) {
28
+ return this.accessToken.value;
29
+ }
30
+ const key = await importPKCS8(this.sa.private_key, 'RS256');
31
+ const assertion = await new SignJWT({ scope: SCOPE })
32
+ .setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
33
+ .setIssuer(this.sa.client_email)
34
+ .setSubject(this.sa.client_email)
35
+ .setAudience(TOKEN_URL)
36
+ .setIssuedAt(nowSeconds)
37
+ .setExpirationTime(nowSeconds + 3600)
38
+ .sign(key);
39
+
40
+ const res = await fetch(TOKEN_URL, {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
43
+ body: new URLSearchParams({
44
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
45
+ assertion,
46
+ }),
47
+ });
48
+ if (!res.ok) {
49
+ throw new Error(`Identity Toolkit token exchange failed: ${res.status}`);
50
+ }
51
+ const json = (await res.json()) as { access_token: string; expires_in: number };
52
+ this.accessToken = { value: json.access_token, expiresAt: nowSeconds + json.expires_in };
53
+ return json.access_token;
54
+ }
55
+
56
+ async lookup(uid: string, nowSeconds: number): Promise<{ uid: string; email?: string } | null> {
57
+ const token = await this.getAccessToken(nowSeconds);
58
+ const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:lookup`, {
59
+ method: 'POST',
60
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
61
+ body: JSON.stringify({ localId: [uid] }),
62
+ });
63
+ if (!res.ok) {
64
+ return null;
65
+ }
66
+ const json = (await res.json()) as { users?: { localId: string; email?: string }[] };
67
+ const user = json.users?.[0];
68
+ return user ? { uid: user.localId, email: user.email } : null;
69
+ }
70
+
71
+ async remove(uid: string, nowSeconds: number): Promise<void> {
72
+ const token = await this.getAccessToken(nowSeconds);
73
+ const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:delete`, {
74
+ method: 'POST',
75
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
76
+ body: JSON.stringify({ localId: uid }),
77
+ });
78
+ if (!res.ok) {
79
+ throw new Error(`Identity Toolkit delete failed: ${res.status}`);
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,71 @@
1
+ import { jwtVerify } from 'jose';
2
+ import type { CryptoKey, JWK, JWTVerifyGetKey, KeyObject } from 'jose';
3
+ import type { DecodedIdToken, FirebaseVerifier } from './firebase-verifier';
4
+ import type { IdentityToolkit } from './identity-toolkit';
5
+
6
+ // jose v6 removed `KeyLike`; the verification key is a static key (prod: createRemoteJWKSet,
7
+ // test: a CryptoKey) or a dynamic getKey function. Union both overloads' key params.
8
+ type KeyInput = CryptoKey | KeyObject | JWK | Uint8Array | JWTVerifyGetKey;
9
+
10
+ /**
11
+ * Replaces firebase-admin getAuth().verifyIdToken() with jose RS256 verification against
12
+ * Google's securetoken JWKS. Mirrors the admin SDK's checks: issuer/audience = projectId,
13
+ * RS256, a non-empty subject (the uid), and a valid auth_time.
14
+ *
15
+ * - prod: keyResolver = createRemoteJWKSet(new URL(SECURETOKEN_JWK_URL)).
16
+ * - test: keyResolver = the generated public key (offline, no network).
17
+ *
18
+ * getUser/deleteUser delegate to Identity Toolkit REST (network); absent it throws.
19
+ */
20
+ export const SECURETOKEN_JWK_URL =
21
+ 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
22
+
23
+ export class JoseFirebaseVerifier implements FirebaseVerifier {
24
+ constructor(
25
+ private readonly opts: {
26
+ projectId: string;
27
+ keyResolver: KeyInput;
28
+ identity?: IdentityToolkit;
29
+ now?: () => number; // seconds; injectable for tests
30
+ },
31
+ ) {}
32
+
33
+ async verifyIdToken(idToken: string): Promise<DecodedIdToken> {
34
+ const options = {
35
+ issuer: `https://securetoken.google.com/${this.opts.projectId}`,
36
+ audience: this.opts.projectId,
37
+ algorithms: ['RS256'] as string[],
38
+ };
39
+ // Branch so each call matches a single jwtVerify overload (static key vs getKey fn).
40
+ const key = this.opts.keyResolver;
41
+ const { payload } =
42
+ typeof key === 'function' ? await jwtVerify(idToken, key, options) : await jwtVerify(idToken, key, options);
43
+ // Mirror firebase-admin's extra checks beyond signature/iss/aud/exp:
44
+ if (!payload.sub || typeof payload.sub !== 'string' || payload.sub.length > 128) {
45
+ throw new Error('Firebase ID token has an invalid subject');
46
+ }
47
+ const authTime = payload.auth_time;
48
+ if (typeof authTime !== 'number' || authTime > this.nowSeconds()) {
49
+ throw new Error('Firebase ID token has an invalid auth_time');
50
+ }
51
+ return { ...payload, uid: payload.sub, email: payload.email as string | undefined };
52
+ }
53
+
54
+ async getUser(uid: string): Promise<{ uid: string; email?: string } | null> {
55
+ if (!this.opts.identity) {
56
+ throw new Error('Identity Toolkit not configured');
57
+ }
58
+ return this.opts.identity.lookup(uid, this.nowSeconds());
59
+ }
60
+
61
+ async deleteUser(uid: string): Promise<void> {
62
+ if (!this.opts.identity) {
63
+ throw new Error('Identity Toolkit not configured');
64
+ }
65
+ await this.opts.identity.remove(uid, this.nowSeconds());
66
+ }
67
+
68
+ private nowSeconds(): number {
69
+ return this.opts.now ? this.opts.now() : Math.floor(Date.now() / 1000);
70
+ }
71
+ }
@@ -0,0 +1,49 @@
1
+ import { createRemoteJWKSet } from 'jose';
2
+ import { IdentityToolkit } from './identity-toolkit';
3
+ import type { ServiceAccount } from './identity-toolkit';
4
+ import { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './jose-firebase-verifier';
5
+
6
+ /**
7
+ * 本番用の便宜ファクトリ。`createRemoteJWKSet` で Google securetoken の公開鍵を取り、
8
+ * `JoseFirebaseVerifier` を返す。トークン検証のみ用途(getUser/deleteUser は不要 = Identity Toolkit 無し)。
9
+ *
10
+ * JWKS は URL 固定なので isolate 内で 1 度だけ生成して共有し(jose が内部メモリにキャッシュ)、
11
+ * verifier は projectId ごとにメモ化する。winecode の旧 `verifyFirebaseIdToken`(module-level JWKS)の
12
+ * キャッシュ挙動を保つための置換。
13
+ */
14
+ let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
15
+ const verifiers = new Map<string, JoseFirebaseVerifier>();
16
+
17
+ export function createRemoteFirebaseVerifier(projectId: string): JoseFirebaseVerifier {
18
+ jwks ??= createRemoteJWKSet(new URL(SECURETOKEN_JWK_URL));
19
+ let verifier = verifiers.get(projectId);
20
+ if (!verifier) {
21
+ verifier = new JoseFirebaseVerifier({ projectId, keyResolver: jwks });
22
+ verifiers.set(projectId, verifier);
23
+ }
24
+ return verifier;
25
+ }
26
+
27
+ let saVerifierCache: { key: string; verifier: JoseFirebaseVerifier } | null = null;
28
+
29
+ /**
30
+ * サービスアカウント JSON から検証器を作る便宜ファクトリ(receptray/tipsys hono の `firebaseFor` 相当)。
31
+ * `getUser`/`deleteUser` のため `IdentityToolkit` を内包する点が `createRemoteFirebaseVerifier` との違い。
32
+ * SA JSON 文字列をキーに isolate 内で 1 つだけキャッシュ(秘密が変わったときだけ再生成)し、
33
+ * JWKS は `createRemoteFirebaseVerifier` と共有する。
34
+ */
35
+ export function createServiceAccountVerifier(serviceAccountJson: string): JoseFirebaseVerifier {
36
+ if (saVerifierCache?.key !== serviceAccountJson) {
37
+ jwks ??= createRemoteJWKSet(new URL(SECURETOKEN_JWK_URL));
38
+ const sa = JSON.parse(serviceAccountJson) as ServiceAccount;
39
+ saVerifierCache = {
40
+ key: serviceAccountJson,
41
+ verifier: new JoseFirebaseVerifier({
42
+ projectId: sa.project_id,
43
+ keyResolver: jwks,
44
+ identity: new IdentityToolkit(sa),
45
+ }),
46
+ };
47
+ }
48
+ return saVerifierCache.verifier;
49
+ }
@@ -0,0 +1,20 @@
1
+ export type AppEnv = 'development' | 'production';
2
+
3
+ /**
4
+ * 実行環境(development / production)を解決する。フリート共通の判定。
5
+ *
6
+ * NestJS `/api` は実行時 FS の `.git` 有無で判定し「git が throw → catch → 本番」としていた。
7
+ * Workers は実行時に FS / child_process が無いため同じ手は使えない。代わりに dev シグナルを
8
+ * **コミット済みの起動コマンド**に置く: `wrangler dev --var APP_ENV:development`(`deploy` は無注入)。
9
+ * よって `env.APP_ENV === 'development'` の時だけ development、それ以外(注入無し=本番デプロイ)は
10
+ * production に倒す。これは `/api` の「absence/catch = 本番(安全側)」と同じ意味論。
11
+ *
12
+ * `env` 由来なので fetch / scheduled どちらの文脈でも使え、リクエストヘッダ由来でないため詐称されない。
13
+ */
14
+ export function resolveAppEnv(env: { APP_ENV?: string } | null | undefined): AppEnv {
15
+ return env?.APP_ENV === 'development' ? 'development' : 'production';
16
+ }
17
+
18
+ /** production 判定のショートハンド。 */
19
+ export const isProductionEnv = (env: { APP_ENV?: string } | null | undefined): boolean =>
20
+ resolveAppEnv(env) === 'production';
@@ -0,0 +1,16 @@
1
+ import type { Context } from 'hono';
2
+
3
+ /** クライアントアプリのメタ情報(NestJS の x-amz-meta-* ヘッダ由来。3 repo 共通)。 */
4
+ export interface AppInfo {
5
+ version: string | null;
6
+ uuid: string | null;
7
+ }
8
+
9
+ /**
10
+ * `x-amz-meta-version` / `x-amz-meta-uuid` ヘッダから AppInfo を読む。
11
+ * auth middleware が per-request で c.set('appInfo', ...) する値(3 repo で同一仕様)。
12
+ */
13
+ export const getAppInfo = (c: Context): AppInfo => ({
14
+ version: c.req.header('x-amz-meta-version') ?? null,
15
+ uuid: c.req.header('x-amz-meta-uuid') ?? null,
16
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * NestJS `@nestjs/common` の HttpStatus enum と同一。フリート(NestJS → Hono 移植)で
3
+ * ステータスコードを Nest と同じ名前で参照するための共通定数。`/api` のレスポンス status と
4
+ * バイト一致させる際の単一の参照元にする。
5
+ */
6
+ export enum HttpStatus {
7
+ CONTINUE = 100,
8
+ SWITCHING_PROTOCOLS = 101,
9
+ PROCESSING = 102,
10
+ EARLYHINTS = 103,
11
+ OK = 200,
12
+ CREATED = 201,
13
+ ACCEPTED = 202,
14
+ NON_AUTHORITATIVE_INFORMATION = 203,
15
+ NO_CONTENT = 204,
16
+ RESET_CONTENT = 205,
17
+ PARTIAL_CONTENT = 206,
18
+ MULTI_STATUS = 207,
19
+ ALREADY_REPORTED = 208,
20
+ CONTENT_DIFFERENT = 210,
21
+ AMBIGUOUS = 300,
22
+ MOVED_PERMANENTLY = 301,
23
+ FOUND = 302,
24
+ SEE_OTHER = 303,
25
+ NOT_MODIFIED = 304,
26
+ TEMPORARY_REDIRECT = 307,
27
+ PERMANENT_REDIRECT = 308,
28
+ BAD_REQUEST = 400,
29
+ UNAUTHORIZED = 401,
30
+ PAYMENT_REQUIRED = 402,
31
+ FORBIDDEN = 403,
32
+ NOT_FOUND = 404,
33
+ METHOD_NOT_ALLOWED = 405,
34
+ NOT_ACCEPTABLE = 406,
35
+ PROXY_AUTHENTICATION_REQUIRED = 407,
36
+ REQUEST_TIMEOUT = 408,
37
+ CONFLICT = 409,
38
+ GONE = 410,
39
+ LENGTH_REQUIRED = 411,
40
+ PRECONDITION_FAILED = 412,
41
+ PAYLOAD_TOO_LARGE = 413,
42
+ URI_TOO_LONG = 414,
43
+ UNSUPPORTED_MEDIA_TYPE = 415,
44
+ REQUESTED_RANGE_NOT_SATISFIABLE = 416,
45
+ EXPECTATION_FAILED = 417,
46
+ I_AM_A_TEAPOT = 418,
47
+ MISDIRECTED = 421,
48
+ UNPROCESSABLE_ENTITY = 422,
49
+ LOCKED = 423,
50
+ FAILED_DEPENDENCY = 424,
51
+ PRECONDITION_REQUIRED = 428,
52
+ TOO_MANY_REQUESTS = 429,
53
+ UNRECOVERABLE_ERROR = 456,
54
+ INTERNAL_SERVER_ERROR = 500,
55
+ NOT_IMPLEMENTED = 501,
56
+ BAD_GATEWAY = 502,
57
+ SERVICE_UNAVAILABLE = 503,
58
+ GATEWAY_TIMEOUT = 504,
59
+ HTTP_VERSION_NOT_SUPPORTED = 505,
60
+ INSUFFICIENT_STORAGE = 507,
61
+ LOOP_DETECTED = 508,
62
+ }