@rdlabo/workers-hono-kit 0.2.0 → 0.3.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.
- package/README.md +126 -11
- package/dist/ai/gateway.d.ts +54 -16
- package/dist/ai/gateway.js +37 -12
- package/dist/aws/cloudfront.d.ts +23 -5
- package/dist/aws/cloudfront.js +45 -6
- package/dist/aws/secrets-manager.d.ts +38 -4
- package/dist/aws/secrets-manager.js +48 -3
- package/dist/cache/kv-cache.d.ts +173 -10
- package/dist/cache/kv-cache.js +139 -7
- package/dist/db/connection.d.ts +56 -14
- package/dist/db/connection.js +39 -13
- package/dist/db/database.d.ts +159 -23
- package/dist/db/database.js +49 -5
- package/dist/db/index.d.ts +11 -0
- package/dist/db/index.js +11 -2
- package/dist/db/jst.d.ts +89 -6
- package/dist/db/jst.js +89 -23
- package/dist/db/orm-config.d.ts +61 -19
- package/dist/db/orm-config.js +43 -14
- package/dist/db/retry.d.ts +25 -3
- package/dist/db/retry.js +25 -3
- package/dist/db/write-result.d.ts +27 -4
- package/dist/db/write-result.js +22 -1
- package/dist/firebase/firebase-verifier.d.ts +53 -4
- package/dist/firebase/identity-toolkit.d.ts +54 -5
- package/dist/firebase/identity-toolkit.js +51 -0
- package/dist/firebase/jose-firebase-verifier.d.ts +79 -7
- package/dist/firebase/jose-firebase-verifier.js +68 -7
- package/dist/firebase/remote-verifier.d.ts +42 -4
- package/dist/firebase/remote-verifier.js +58 -9
- package/dist/http/app-env.d.ts +41 -8
- package/dist/http/app-env.js +38 -8
- package/dist/http/app-info.d.ts +25 -3
- package/dist/http/app-info.js +16 -2
- package/dist/http/http-status.d.ts +12 -3
- package/dist/http/http-status.js +12 -3
- package/dist/http/nest-error.d.ts +90 -29
- package/dist/http/nest-error.js +59 -18
- package/dist/http/user-protocol.d.ts +23 -3
- package/dist/http/user-protocol.js +14 -2
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -3
- package/dist/middleware/auth.d.ts +74 -13
- package/dist/middleware/auth.js +30 -6
- package/dist/middleware/finalize-response.d.ts +30 -0
- package/dist/middleware/finalize-response.js +41 -12
- package/dist/middleware/validation.d.ts +83 -9
- package/dist/middleware/validation.js +52 -9
- package/dist/middleware/zod-coerce.d.ts +56 -2
- package/dist/middleware/zod-coerce.js +68 -9
- package/dist/queue/consumer.d.ts +112 -0
- package/dist/queue/consumer.js +80 -0
- package/dist/queue/send.d.ts +90 -0
- package/dist/queue/send.js +85 -0
- package/dist/stripe/client.d.ts +46 -9
- package/dist/stripe/client.js +41 -3
- package/dist/testing/auth.d.ts +58 -10
- package/dist/testing/auth.js +58 -10
- package/dist/testing/configurable-fake.d.ts +20 -9
- package/dist/testing/configurable-fake.js +23 -11
- package/dist/testing/db.d.ts +81 -12
- package/dist/testing/db.js +23 -1
- package/dist/testing/fakes.d.ts +77 -9
- package/dist/testing/fakes.js +69 -7
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +10 -5
- package/dist/testing/stripe-fixtures.d.ts +93 -3
- package/dist/testing/stripe-fixtures.js +93 -3
- package/package.json +3 -2
- package/scripts/check-subrequest-fanout.mjs +86 -0
- package/src/ai/gateway.ts +66 -27
- package/src/aws/cloudfront.ts +46 -6
- package/src/aws/secrets-manager.ts +56 -7
- package/src/cache/kv-cache.ts +194 -12
- package/src/db/connection.ts +56 -14
- package/src/db/database.ts +160 -24
- package/src/db/index.ts +11 -2
- package/src/db/jst.ts +89 -23
- package/src/db/orm-config.ts +61 -19
- package/src/db/retry.ts +25 -3
- package/src/db/write-result.ts +27 -4
- package/src/firebase/firebase-verifier.ts +53 -4
- package/src/firebase/identity-toolkit.ts +57 -5
- package/src/firebase/jose-firebase-verifier.ts +79 -9
- package/src/firebase/remote-verifier.ts +58 -9
- package/src/http/app-env.ts +41 -8
- package/src/http/app-info.ts +25 -3
- package/src/http/http-status.ts +12 -3
- package/src/http/nest-error.ts +106 -37
- package/src/http/user-protocol.ts +23 -3
- package/src/index.ts +17 -3
- package/src/middleware/auth.ts +77 -15
- package/src/middleware/finalize-response.ts +41 -12
- package/src/middleware/validation.ts +89 -15
- package/src/middleware/zod-coerce.ts +68 -9
- package/src/queue/consumer.ts +146 -0
- package/src/queue/send.ts +129 -0
- package/src/stripe/client.ts +46 -9
- package/src/testing/auth.ts +58 -10
- package/src/testing/configurable-fake.ts +23 -11
- package/src/testing/db.ts +82 -13
- package/src/testing/fakes.ts +77 -9
- package/src/testing/index.ts +10 -5
- package/src/testing/stripe-fixtures.ts +93 -3
package/src/db/jst.ts
CHANGED
|
@@ -1,27 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared building blocks for normalizing JST (Asia/Tokyo) date/time values.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* JST normalization is applied at the column boundary as a write-time side effect, and neither
|
|
6
|
+
* values nor types from `drizzle-orm` are imported here on purpose. Exporting a fully-built
|
|
7
|
+
* `customType` column from the kit would cause type collisions when the kit and the consumer
|
|
8
|
+
* resolve separate copies of `drizzle-orm` (the private `SQL` brand stops being nominally
|
|
9
|
+
* compatible). Instead the kit ships only the params and helpers, and the consumer builds the
|
|
10
|
+
* column with its own `customType`:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { customType } from 'drizzle-orm/mysql-core';
|
|
14
|
+
* import { jstTimestampParams, jstDateParams } from '@rdlabo/workers-hono-kit/db';
|
|
15
|
+
*
|
|
16
|
+
* export const jstTimestamp = (name: string, opts?: { fsp?: number }) =>
|
|
17
|
+
* customType<{ data: string | Date; driverData: string | Date }>(jstTimestampParams(opts?.fsp))(name);
|
|
18
|
+
* export const jstDate = (name: string) =>
|
|
19
|
+
* customType<{ data: string | null; driverData: string | null }>(jstDateParams())(name);
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `timestamp`/`datetime` columns omit `toDriver` and pass `Date` values straight through, so the
|
|
23
|
+
* connection's `timezone: '+09:00'` default makes mysql2 format them as JST; pre-formatted strings
|
|
24
|
+
* also pass through. Drizzle's native `mode: 'date'` is avoided because it stringifies `Date` to
|
|
25
|
+
* UTC before the timezone layer, shifting values by -9h. `date` columns keep `toJstDate` because
|
|
26
|
+
* MySQL `DATE` rejects ISO/empty strings and a JST day-boundary normalization is required.
|
|
27
|
+
*/
|
|
18
28
|
|
|
19
29
|
const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
32
|
+
* Normalize a client-supplied date to the `YYYY-MM-DD` (JST) form accepted by a MySQL `DATE` column.
|
|
33
|
+
*
|
|
34
|
+
* Accepts ISO 8601 (`...Z`), `YYYY-MM-DD`, or an empty string. Nullish, empty, or unparseable input
|
|
35
|
+
* resolves to `null`.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* MySQL `DATE` rejects ISO strings with `ER_TRUNCATED_WRONG_VALUE`, so this cannot be handled by the
|
|
39
|
+
* driver alone; it is needed as the `toDriver` transform for a `date` column.
|
|
40
|
+
*
|
|
41
|
+
* @param value - the raw date string from the client (ISO 8601, `YYYY-MM-DD`, or empty), or nullish.
|
|
42
|
+
* @returns the JST calendar date as `YYYY-MM-DD`, or `null` when the input is empty or unparseable.
|
|
25
43
|
*/
|
|
26
44
|
export function toJstDate(value: string | null | undefined): string | null {
|
|
27
45
|
if (!value) {
|
|
@@ -36,17 +54,65 @@ export function toJstDate(value: string | null | undefined): string | null {
|
|
|
36
54
|
return `${jst.getUTCFullYear()}-${p(jst.getUTCMonth() + 1)}-${p(jst.getUTCDate())}`;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Build the params for a `customType` backing a MySQL `timestamp` column with `Date` pass-through.
|
|
59
|
+
*
|
|
60
|
+
* The column omits `toDriver`, so `Date` values flow straight to mysql2 and are formatted as JST by
|
|
61
|
+
* the connection's `timezone: '+09:00'` default.
|
|
62
|
+
*
|
|
63
|
+
* @param fsp - optional fractional-seconds precision; when provided, emits `timestamp(fsp)`.
|
|
64
|
+
* @returns the `customType` params object exposing the column's `dataType`.
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* import { customType } from 'drizzle-orm/mysql-core';
|
|
68
|
+
* import { jstTimestampParams } from '@rdlabo/workers-hono-kit/db';
|
|
69
|
+
*
|
|
70
|
+
* const jstTimestamp = (name: string) =>
|
|
71
|
+
* customType<{ data: string | Date; driverData: string | Date }>(jstTimestampParams())(name);
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
40
74
|
export const jstTimestampParams = (fsp?: number): { dataType: () => string } => ({
|
|
41
75
|
dataType: () => (fsp != null ? `timestamp(${fsp})` : 'timestamp'),
|
|
42
76
|
});
|
|
43
77
|
|
|
44
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Build the params for a `customType` backing a MySQL `datetime` column with `Date` pass-through.
|
|
80
|
+
*
|
|
81
|
+
* Behaves like {@link jstTimestampParams} but emits a `datetime` data type; `Date` values pass
|
|
82
|
+
* through and are formatted as JST by the connection's `timezone: '+09:00'` default.
|
|
83
|
+
*
|
|
84
|
+
* @param fsp - optional fractional-seconds precision; when provided, emits `datetime(fsp)`.
|
|
85
|
+
* @returns the `customType` params object exposing the column's `dataType`.
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { customType } from 'drizzle-orm/mysql-core';
|
|
89
|
+
* import { jstDatetimeParams } from '@rdlabo/workers-hono-kit/db';
|
|
90
|
+
*
|
|
91
|
+
* const jstDatetime = (name: string) =>
|
|
92
|
+
* customType<{ data: string | Date; driverData: string | Date }>(jstDatetimeParams())(name);
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
45
95
|
export const jstDatetimeParams = (fsp?: number): { dataType: () => string } => ({
|
|
46
96
|
dataType: () => (fsp != null ? `datetime(${fsp})` : 'datetime'),
|
|
47
97
|
});
|
|
48
98
|
|
|
49
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Build the params for a `customType` backing a MySQL `date` column with JST normalization.
|
|
101
|
+
*
|
|
102
|
+
* Unlike the timestamp/datetime params, this defines a `toDriver` transform that runs
|
|
103
|
+
* {@link toJstDate} so client-supplied ISO/empty strings are normalized to a JST `YYYY-MM-DD` value
|
|
104
|
+
* the column accepts.
|
|
105
|
+
*
|
|
106
|
+
* @returns the `customType` params object exposing the column's `dataType` and `toDriver`.
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* import { customType } from 'drizzle-orm/mysql-core';
|
|
110
|
+
* import { jstDateParams } from '@rdlabo/workers-hono-kit/db';
|
|
111
|
+
*
|
|
112
|
+
* const jstDate = (name: string) =>
|
|
113
|
+
* customType<{ data: string | null; driverData: string | null }>(jstDateParams())(name);
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
50
116
|
export const jstDateParams = (): {
|
|
51
117
|
dataType: () => string;
|
|
52
118
|
toDriver: (value: string | null) => string | null;
|
package/src/db/orm-config.ts
CHANGED
|
@@ -1,45 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Drizzle
|
|
2
|
+
* Centralizes Drizzle column-name casing so it is fixed (standard: `snake_case`) in both the
|
|
3
|
+
* config and the runtime ORM.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* ② database.ts の `drizzle(conn, { …casing })` … **実行時の書き込みビルダが参照する列名** を決める(→ DRIZZLE_ORM_OPTIONS)
|
|
5
|
+
* @remarks
|
|
6
|
+
* Casing is configured in two distinct places:
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* 1. The top-level `casing` in `drizzle.config.ts` decides the column names that `db:generate`
|
|
9
|
+
* **creates** (see {@link honoDrizzleConfig}).
|
|
10
|
+
* 2. The `drizzle(conn, { …casing })` call decides the column names the **runtime write builder**
|
|
11
|
+
* resolves to (see {@link DRIZZLE_ORM_OPTIONS}).
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* If these two disagree, a multi-word camelCase column without an explicit column name will be
|
|
14
|
+
* generated with one name but queried with another, producing a runtime `Unknown column` error —
|
|
15
|
+
* something neither the type-check nor the migration surface, so it is caught late. Sourcing both
|
|
16
|
+
* from here makes the mismatch structurally impossible. Casing is ignored for columns that declare
|
|
17
|
+
* an explicit name, so this is a pure safety net that does not change existing behavior.
|
|
18
|
+
*
|
|
19
|
+
* The runtime `drizzle()` call itself is made by the consuming app with its own `drizzle-orm`; the
|
|
20
|
+
* kit only ever provides values, never the ORM instance, to avoid splitting `drizzle-orm` into two
|
|
21
|
+
* copies and breaking type identity.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
25
|
+
* Runtime ORM options shared by the consuming app's `drizzle()` call.
|
|
26
|
+
*
|
|
27
|
+
* Spread into the runtime ORM as `drizzle(conn, { schema, ...DRIZZLE_ORM_OPTIONS })` so the write
|
|
28
|
+
* builder resolves column names as `snake_case`, matching what `db:generate` creates.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* Fixes `mode: 'default'` and `casing: 'snake_case'`. See the module-level documentation for why
|
|
32
|
+
* the same casing must be used by both the config and the runtime ORM.
|
|
19
33
|
*/
|
|
20
34
|
export const DRIZZLE_ORM_OPTIONS = { mode: 'default', casing: 'snake_case' } as const;
|
|
21
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Options for {@link honoDrizzleConfig}.
|
|
38
|
+
*/
|
|
22
39
|
export interface HonoDrizzleConfigOptions {
|
|
23
|
-
/** drizzle-kit
|
|
40
|
+
/** drizzle-kit `dbCredentials.database` — the database name to connect to. */
|
|
24
41
|
database: string;
|
|
42
|
+
/** Database host; defaults to `process.env.DB_HOST` then `127.0.0.1`. */
|
|
25
43
|
host?: string;
|
|
44
|
+
/** Database port; defaults to `process.env.DB_PORT` then `3306`. */
|
|
26
45
|
port?: number;
|
|
46
|
+
/** Database user; defaults to `process.env.DB_USER` then `root`. */
|
|
27
47
|
user?: string;
|
|
48
|
+
/** Database password; defaults to `process.env.DB_PASSWORD` then `root`. */
|
|
28
49
|
password?: string;
|
|
29
|
-
/**
|
|
50
|
+
/** Path to the schema directory; defaults to `'./src/db/schemes'`. */
|
|
30
51
|
schema?: string;
|
|
31
|
-
/**
|
|
52
|
+
/** Output directory for generated migrations; defaults to `'./drizzle'`. */
|
|
32
53
|
out?: string;
|
|
33
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Optional table allow-list. Use this to restrict drizzle-kit to the schema's own tables when the
|
|
56
|
+
* database is shared with another application.
|
|
57
|
+
*/
|
|
34
58
|
tablesFilter?: string[];
|
|
35
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Optional `db:introspect` (DB → JS) casing. This is an independent axis from the generation-side
|
|
61
|
+
* `casing: 'snake_case'` and only affects introspection output.
|
|
62
|
+
*/
|
|
36
63
|
introspect?: { casing: 'camel' | 'preserve' };
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
/**
|
|
40
|
-
* drizzle.config.ts
|
|
41
|
-
*
|
|
42
|
-
*
|
|
67
|
+
* Build a `drizzle.config.ts` configuration object with the kit's standard defaults.
|
|
68
|
+
*
|
|
69
|
+
* Fixes `casing: 'snake_case'`, the `schema`/`out` paths, and `dbCredentials` (with env-based
|
|
70
|
+
* defaults), while leaving `tablesFilter` and `introspect` opt-in.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* Returns a plain object rather than a typed drizzle-kit config so that `drizzle-kit` need not be a
|
|
74
|
+
* dependency of the kit; the drizzle-kit CLI only reads the default export.
|
|
75
|
+
*
|
|
76
|
+
* @param options - configuration overrides; only `database` is required.
|
|
77
|
+
* @returns a plain configuration object suitable for `export default` in `drizzle.config.ts`.
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // drizzle.config.ts
|
|
81
|
+
* import { honoDrizzleConfig } from '@rdlabo/workers-hono-kit/db';
|
|
82
|
+
*
|
|
83
|
+
* export default honoDrizzleConfig({ database: 'app' });
|
|
84
|
+
* ```
|
|
43
85
|
*/
|
|
44
86
|
export function honoDrizzleConfig(options: HonoDrizzleConfigOptions) {
|
|
45
87
|
const {
|
package/src/db/retry.ts
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Run an async unit of work, retrying it on MySQL deadlock errors with exponential backoff.
|
|
3
|
+
*
|
|
4
|
+
* Retries are triggered only by the `ER_LOCK_DEADLOCK` error code. Each failed attempt waits
|
|
5
|
+
* `delay * attempt` milliseconds (linear growth of the base delay) before the next try, and any
|
|
6
|
+
* non-deadlock error is rethrown immediately without retrying.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* MySQL rolls back the entire transaction when it detects a deadlock, so re-running the same unit
|
|
10
|
+
* of work is safe. Pass a `fn` that represents one complete unit — a single statement or an entire
|
|
11
|
+
* transaction — because the whole `fn` is re-executed on each retry.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam T - resolved value produced by `fn`.
|
|
14
|
+
* @param fn - the unit of work to execute; it is invoked again from scratch on each retry.
|
|
15
|
+
* @param retries - maximum number of attempts (default `3`).
|
|
16
|
+
* @param delay - base backoff in milliseconds; attempt N waits `delay * N` (default `100`).
|
|
17
|
+
* @returns the value resolved by the first successful call to `fn`.
|
|
18
|
+
* @throws the last error thrown by `fn` once retries are exhausted, or any non-deadlock error on
|
|
19
|
+
* the first occurrence.
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* await retryWhenDeadlock(() => db.transaction(async (tx) => {
|
|
23
|
+
* await tx.insert(orders).values(order);
|
|
24
|
+
* await tx.update(stock).set({ qty: sql`qty - 1` }).where(eq(stock.id, order.itemId));
|
|
25
|
+
* }));
|
|
26
|
+
* ```
|
|
5
27
|
*/
|
|
6
28
|
export async function retryWhenDeadlock<T>(fn: () => Promise<T>, retries = 3, delay = 100): Promise<T> {
|
|
7
29
|
for (let attempt = 0; attempt < retries; attempt++) {
|
package/src/db/write-result.ts
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Drizzle(mysql2)
|
|
3
|
-
* 生の builder/ResultSetHeader を repo 側に晒さずに、よく使う値だけを取り出す。
|
|
2
|
+
* Shape of a Drizzle (mysql2) write result, narrowed to the fields callers actually read.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* A mysql2 INSERT/UPDATE/DELETE result is the tuple `[ResultSetHeader, FieldPacket[]]`. Typing the
|
|
6
|
+
* result this way lets repositories extract the common values without exposing the raw query
|
|
7
|
+
* builder or the full `ResultSetHeader` to the rest of the codebase.
|
|
6
8
|
*/
|
|
7
9
|
export type DzWriteResult = readonly [{ insertId: number; affectedRows: number }, ...unknown[]];
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Extract the auto-increment `insertId` from a write result.
|
|
13
|
+
*
|
|
14
|
+
* @param result - the result of a Drizzle (mysql2) INSERT/UPDATE/DELETE.
|
|
15
|
+
* @returns the `insertId` reported by mysql2 (the id of the first inserted row).
|
|
16
|
+
*/
|
|
9
17
|
export function insertIdOf(result: DzWriteResult): number {
|
|
10
18
|
return result[0].insertId;
|
|
11
19
|
}
|
|
12
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Extract the number of affected rows from a write result.
|
|
23
|
+
*
|
|
24
|
+
* @param result - the result of a Drizzle (mysql2) INSERT/UPDATE/DELETE.
|
|
25
|
+
* @returns the `affectedRows` count reported by mysql2.
|
|
26
|
+
*/
|
|
13
27
|
export function affectedRowsOf(result: DzWriteResult): number {
|
|
14
28
|
return result[0].affectedRows;
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
/**
|
|
18
|
-
*
|
|
32
|
+
* Reconstruct the auto-increment ids assigned by a bulk INSERT.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* mysql2 reports only the first `insertId` for a multi-row INSERT, so the remaining ids are derived
|
|
36
|
+
* by assuming a contiguous sequence (`base`, `base + 1`, …). This holds for tables with a standard
|
|
37
|
+
* `AUTO_INCREMENT` column and the default `innodb_autoinc_lock_mode`.
|
|
38
|
+
*
|
|
39
|
+
* @param result - the result of a bulk INSERT.
|
|
40
|
+
* @param count - the number of rows that were inserted.
|
|
41
|
+
* @returns an array of the `count` auto-increment ids, starting at the reported `insertId`.
|
|
19
42
|
*/
|
|
20
43
|
export function insertedIdsOf(result: DzWriteResult, count: number): number[] {
|
|
21
44
|
const base = result[0].insertId;
|
|
@@ -1,15 +1,64 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Decoded Firebase ID token payload.
|
|
3
|
+
*
|
|
4
|
+
* Shaped to match the subset of `firebase-admin`'s `DecodedIdToken` that consumers
|
|
5
|
+
* typically rely on: a stable `uid` plus an optional `email`. The index signature keeps
|
|
6
|
+
* every other JWT claim (e.g. `name`, `picture`, custom claims) accessible without
|
|
7
|
+
* enumerating them here.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* This is the return type of {@link FirebaseVerifier.verifyIdToken}. The `uid` is derived
|
|
11
|
+
* from the token's `sub` claim.
|
|
12
|
+
*/
|
|
2
13
|
export interface DecodedIdToken {
|
|
14
|
+
/** The authenticated user's unique id, taken from the token's `sub` claim. */
|
|
3
15
|
uid: string;
|
|
16
|
+
/** The user's email address, when present on the token. */
|
|
4
17
|
email?: string;
|
|
18
|
+
/** Any additional JWT claim carried by the token (custom claims, `name`, `picture`, ...). */
|
|
5
19
|
[claim: string]: unknown;
|
|
6
20
|
}
|
|
7
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Abstract authentication boundary that replaces the `firebase-admin` Auth surface
|
|
24
|
+
* (`verifyIdToken` / `getUser` / `deleteUser`) for environments where the Node SDK cannot
|
|
25
|
+
* run, such as Cloudflare Workers.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Implementations verify Firebase ID tokens and look up or delete accounts without the
|
|
29
|
+
* `firebase-admin` Node dependency. See `JoseFirebaseVerifier` for the `jose`-based
|
|
30
|
+
* implementation and the `createRemoteFirebaseVerifier` / `createServiceAccountVerifier`
|
|
31
|
+
* factories for ready-made instances.
|
|
32
|
+
*/
|
|
8
33
|
export interface FirebaseVerifier {
|
|
9
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Verify a Firebase ID token and return its decoded payload.
|
|
36
|
+
*
|
|
37
|
+
* Mirrors `firebase-admin` `getAuth().verifyIdToken()`.
|
|
38
|
+
*
|
|
39
|
+
* @param idToken - The raw Firebase ID token (JWT) to verify.
|
|
40
|
+
* @returns The decoded token payload.
|
|
41
|
+
* @throws If the token signature, issuer, audience, expiry, or other required claims are invalid.
|
|
42
|
+
*/
|
|
10
43
|
verifyIdToken(idToken: string): Promise<DecodedIdToken>;
|
|
11
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Look up a user record by uid.
|
|
46
|
+
*
|
|
47
|
+
* Mirrors `firebase-admin` `getAuth().getUser()`.
|
|
48
|
+
*
|
|
49
|
+
* @param uid - The user's unique id.
|
|
50
|
+
* @returns The user's `uid` and optional `email`, or `null` when the user does not exist.
|
|
51
|
+
* @throws If the backing user-management service is not configured or the lookup fails.
|
|
52
|
+
*/
|
|
12
53
|
getUser(uid: string): Promise<{ uid: string; email?: string } | null>;
|
|
13
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Delete a user by uid.
|
|
56
|
+
*
|
|
57
|
+
* Mirrors `firebase-admin` `getAuth().deleteUser()`.
|
|
58
|
+
*
|
|
59
|
+
* @param uid - The user's unique id.
|
|
60
|
+
* @returns A promise that resolves once the user has been deleted.
|
|
61
|
+
* @throws If the backing user-management service is not configured or the deletion fails.
|
|
62
|
+
*/
|
|
14
63
|
deleteUser(uid: string): Promise<void>;
|
|
15
64
|
}
|
|
@@ -1,28 +1,63 @@
|
|
|
1
1
|
import { SignJWT, importPKCS8 } from 'jose';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Minimal
|
|
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.
|
|
4
|
+
* Minimal service-account credential consumed by {@link IdentityToolkit}.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Corresponds to the relevant fields of a Google service-account JSON key file.
|
|
10
8
|
*/
|
|
11
9
|
export interface ServiceAccount {
|
|
10
|
+
/** The service account's email, used as the JWT assertion issuer and subject. */
|
|
12
11
|
client_email: string;
|
|
12
|
+
/** The PEM-encoded PKCS#8 RSA private key used to sign the OAuth2 assertion. */
|
|
13
13
|
private_key: string;
|
|
14
|
+
/** The Google/Firebase project id the Identity Toolkit calls target. */
|
|
14
15
|
project_id: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** Google OAuth2 token endpoint used to exchange a signed JWT assertion for an access token. */
|
|
17
19
|
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
20
|
+
/** Base URL of the Google Identity Toolkit v1 REST API. */
|
|
18
21
|
const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com/v1';
|
|
22
|
+
/** OAuth2 scopes required for Identity Toolkit account lookup and deletion. */
|
|
19
23
|
const SCOPE = 'https://www.googleapis.com/auth/identitytoolkit https://www.googleapis.com/auth/firebase';
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Minimal Google Identity Toolkit REST client for the user-management operations that token
|
|
27
|
+
* verification does not cover: `accounts:lookup` (getUser) and `accounts:delete` (deleteUser).
|
|
28
|
+
*
|
|
29
|
+
* This replaces the parts of the `firebase-admin` Node SDK that cannot run on Cloudflare
|
|
30
|
+
* Workers (workerd).
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* Authentication follows the JWT-bearer flow: a JWT assertion is signed with the service
|
|
34
|
+
* account's private key (via `jose`), exchanged at the OAuth2 token endpoint for an access
|
|
35
|
+
* token, and that token is then used to call the REST API. Access tokens are cached in-process
|
|
36
|
+
* and reused until shortly before they expire.
|
|
37
|
+
*/
|
|
21
38
|
export class IdentityToolkit {
|
|
39
|
+
/** Cached OAuth2 access token and its absolute expiry (Unix seconds), or `null` when none. */
|
|
22
40
|
private accessToken: { value: string; expiresAt: number } | null = null;
|
|
23
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Create a client bound to a single service account.
|
|
44
|
+
*
|
|
45
|
+
* @param sa - The service-account credential used to authenticate REST calls.
|
|
46
|
+
*/
|
|
24
47
|
constructor(private readonly sa: ServiceAccount) {}
|
|
25
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Return a valid OAuth2 access token, minting a new one when the cache is empty or expiring.
|
|
51
|
+
*
|
|
52
|
+
* Signs a short-lived JWT assertion with the service-account key and exchanges it at the
|
|
53
|
+
* Google OAuth2 token endpoint. The result is cached and reused while it remains valid
|
|
54
|
+
* (with a 60-second safety margin).
|
|
55
|
+
*
|
|
56
|
+
* @param nowSeconds - The current Unix time in seconds, used for cache validity and JWT timestamps.
|
|
57
|
+
* @returns A bearer access token for the Identity Toolkit API.
|
|
58
|
+
* @throws If the token exchange request fails.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
26
61
|
private async getAccessToken(nowSeconds: number): Promise<string> {
|
|
27
62
|
if (this.accessToken && this.accessToken.expiresAt > nowSeconds + 60) {
|
|
28
63
|
return this.accessToken.value;
|
|
@@ -53,6 +88,15 @@ export class IdentityToolkit {
|
|
|
53
88
|
return json.access_token;
|
|
54
89
|
}
|
|
55
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Look up a user record by uid via the `accounts:lookup` endpoint.
|
|
93
|
+
*
|
|
94
|
+
* @param uid - The user's unique id (`localId`).
|
|
95
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
96
|
+
* @returns The user's `uid` and optional `email`, or `null` when the request is unsuccessful
|
|
97
|
+
* or no matching user is returned.
|
|
98
|
+
* @throws If acquiring an access token fails.
|
|
99
|
+
*/
|
|
56
100
|
async lookup(uid: string, nowSeconds: number): Promise<{ uid: string; email?: string } | null> {
|
|
57
101
|
const token = await this.getAccessToken(nowSeconds);
|
|
58
102
|
const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:lookup`, {
|
|
@@ -68,6 +112,14 @@ export class IdentityToolkit {
|
|
|
68
112
|
return user ? { uid: user.localId, email: user.email } : null;
|
|
69
113
|
}
|
|
70
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Delete a user by uid via the `accounts:delete` endpoint.
|
|
117
|
+
*
|
|
118
|
+
* @param uid - The user's unique id (`localId`).
|
|
119
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
120
|
+
* @returns A promise that resolves once the user has been deleted.
|
|
121
|
+
* @throws If acquiring an access token fails or the delete request is unsuccessful.
|
|
122
|
+
*/
|
|
71
123
|
async remove(uid: string, nowSeconds: number): Promise<void> {
|
|
72
124
|
const token = await this.getAccessToken(nowSeconds);
|
|
73
125
|
const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:delete`, {
|
|
@@ -3,24 +3,62 @@ import type { CryptoKey, JWK, JWTVerifyGetKey, KeyObject } from 'jose';
|
|
|
3
3
|
import type { DecodedIdToken, FirebaseVerifier } from './firebase-verifier.js';
|
|
4
4
|
import type { IdentityToolkit } from './identity-toolkit.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Union of every key shape `jose`'s `jwtVerify` accepts.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* `jose` v6 removed `KeyLike`, so the verification key is modelled here as either a static
|
|
11
|
+
* key (production uses `createRemoteJWKSet`, tests use a generated `CryptoKey`) or a dynamic
|
|
12
|
+
* `JWTVerifyGetKey` resolver function. This union covers both `jwtVerify` overloads' key
|
|
13
|
+
* parameters.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
8
17
|
type KeyInput = CryptoKey | KeyObject | JWK | Uint8Array | JWTVerifyGetKey;
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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).
|
|
20
|
+
* URL of Google's securetoken JWKS endpoint, which serves the public keys used to sign
|
|
21
|
+
* Firebase ID tokens.
|
|
17
22
|
*
|
|
18
|
-
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* Passed to `createRemoteJWKSet` (see `createRemoteFirebaseVerifier`) so RS256 signatures can
|
|
25
|
+
* be verified against Google's rotating public keys.
|
|
19
26
|
*/
|
|
20
27
|
export const SECURETOKEN_JWK_URL =
|
|
21
28
|
'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Verifies Firebase ID tokens with `jose` RS256 against Google's securetoken JWKS, and
|
|
32
|
+
* optionally looks up or deletes users via the Google Identity Toolkit REST API.
|
|
33
|
+
*
|
|
34
|
+
* This replaces the `firebase-admin` Auth surface (`verifyIdToken` / `getUser` /
|
|
35
|
+
* `deleteUser`) in environments where the Node SDK cannot run, such as Cloudflare Workers.
|
|
36
|
+
* Token verification mirrors the admin SDK's checks: issuer and audience equal to the
|
|
37
|
+
* project id, an RS256 signature, a non-empty subject (the uid), and a valid `auth_time`.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* The verification key is supplied as `keyResolver`:
|
|
41
|
+
* - Production: `createRemoteJWKSet(new URL(SECURETOKEN_JWK_URL))`, which fetches and caches
|
|
42
|
+
* Google's public keys.
|
|
43
|
+
* - Tests: a generated public key, allowing fully offline verification with no network.
|
|
44
|
+
*
|
|
45
|
+
* `getUser` / `deleteUser` delegate to {@link IdentityToolkit} (a network call); when no
|
|
46
|
+
* `IdentityToolkit` is configured they throw.
|
|
47
|
+
*
|
|
48
|
+
* @see {@link FirebaseVerifier} for the abstract boundary this implements.
|
|
49
|
+
*/
|
|
23
50
|
export class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
51
|
+
/**
|
|
52
|
+
* Create a verifier.
|
|
53
|
+
*
|
|
54
|
+
* @param opts - Verifier configuration.
|
|
55
|
+
* @param opts.projectId - The Firebase project id, used as both the expected token issuer
|
|
56
|
+
* (`https://securetoken.google.com/<projectId>`) and audience.
|
|
57
|
+
* @param opts.keyResolver - The RS256 verification key or a dynamic key resolver function.
|
|
58
|
+
* @param opts.identity - Optional Identity Toolkit client enabling `getUser` / `deleteUser`.
|
|
59
|
+
* @param opts.now - Optional clock returning the current time in seconds; injectable for
|
|
60
|
+
* deterministic tests. Defaults to the system clock.
|
|
61
|
+
*/
|
|
24
62
|
constructor(
|
|
25
63
|
private readonly opts: {
|
|
26
64
|
projectId: string;
|
|
@@ -30,6 +68,18 @@ export class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
|
30
68
|
},
|
|
31
69
|
) {}
|
|
32
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Verify a Firebase ID token and return its decoded payload.
|
|
73
|
+
*
|
|
74
|
+
* Checks the RS256 signature against the configured key, enforces the expected issuer and
|
|
75
|
+
* audience (the project id), and applies the admin SDK's extra checks: a non-empty string
|
|
76
|
+
* subject of at most 128 characters and an `auth_time` that is a number not in the future.
|
|
77
|
+
*
|
|
78
|
+
* @param idToken - The raw Firebase ID token (JWT) to verify.
|
|
79
|
+
* @returns The decoded payload, with `uid` set from `sub` and `email` lifted to a top-level field.
|
|
80
|
+
* @throws If the signature, issuer, audience, or expiry are invalid, if the subject is
|
|
81
|
+
* missing/non-string/too long, or if `auth_time` is missing or in the future.
|
|
82
|
+
*/
|
|
33
83
|
async verifyIdToken(idToken: string): Promise<DecodedIdToken> {
|
|
34
84
|
const options = {
|
|
35
85
|
issuer: `https://securetoken.google.com/${this.opts.projectId}`,
|
|
@@ -51,6 +101,13 @@ export class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
|
51
101
|
return { ...payload, uid: payload.sub, email: payload.email as string | undefined };
|
|
52
102
|
}
|
|
53
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Look up a user record by uid via the Identity Toolkit REST API.
|
|
106
|
+
*
|
|
107
|
+
* @param uid - The user's unique id.
|
|
108
|
+
* @returns The user's `uid` and optional `email`, or `null` when the user does not exist.
|
|
109
|
+
* @throws If no Identity Toolkit client was configured on this verifier.
|
|
110
|
+
*/
|
|
54
111
|
async getUser(uid: string): Promise<{ uid: string; email?: string } | null> {
|
|
55
112
|
if (!this.opts.identity) {
|
|
56
113
|
throw new Error('Identity Toolkit not configured');
|
|
@@ -58,6 +115,13 @@ export class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
|
58
115
|
return this.opts.identity.lookup(uid, this.nowSeconds());
|
|
59
116
|
}
|
|
60
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Delete a user by uid via the Identity Toolkit REST API.
|
|
120
|
+
*
|
|
121
|
+
* @param uid - The user's unique id.
|
|
122
|
+
* @returns A promise that resolves once the user has been deleted.
|
|
123
|
+
* @throws If no Identity Toolkit client was configured on this verifier, or the deletion fails.
|
|
124
|
+
*/
|
|
61
125
|
async deleteUser(uid: string): Promise<void> {
|
|
62
126
|
if (!this.opts.identity) {
|
|
63
127
|
throw new Error('Identity Toolkit not configured');
|
|
@@ -65,6 +129,12 @@ export class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
|
65
129
|
await this.opts.identity.remove(uid, this.nowSeconds());
|
|
66
130
|
}
|
|
67
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Return the current time in seconds, using the injected clock when provided.
|
|
134
|
+
*
|
|
135
|
+
* @returns The current Unix time in seconds.
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
68
138
|
private nowSeconds(): number {
|
|
69
139
|
return this.opts.now ? this.opts.now() : Math.floor(Date.now() / 1000);
|
|
70
140
|
}
|