@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rdlabo/workers-hono-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"!src/**/*.spec.ts"
|
|
41
41
|
],
|
|
42
42
|
"bin": {
|
|
43
|
-
"workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs"
|
|
43
|
+
"workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs",
|
|
44
|
+
"workers-hono-kit-check-subrequest-fanout": "./scripts/check-subrequest-fanout.mjs"
|
|
44
45
|
},
|
|
45
46
|
"exports": {
|
|
46
47
|
".": {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-subrequest-fanout — flag per-item external-call fan-outs that scale with data size.
|
|
4
|
+
*
|
|
5
|
+
* Cloudflare Workers cap subrequests per invocation (50 free / 1000 paid). Looping an external call
|
|
6
|
+
* (fetch / AI / Stripe / push / ES) once per row reintroduces an unbounded fan-out that eventually
|
|
7
|
+
* exceeds the cap as the userbase/data grows. This gate greps for the concurrency-loop markers that
|
|
8
|
+
* usually wrap such fan-outs and fails CI unless the site is explicitly annotated as safe.
|
|
9
|
+
*
|
|
10
|
+
* Markers: `runWithConcurrency(`, `PromisePool`, `.withConcurrency(`.
|
|
11
|
+
*
|
|
12
|
+
* To allow a genuinely-safe site (e.g. the loop body only writes to the DB over TCP, which is NOT a
|
|
13
|
+
* subrequest, or the iteration count is hard-capped), put `subrequest-ok` in a comment on the same
|
|
14
|
+
* line or the line immediately above. Prefer a short reason, e.g. `// subrequest-ok: DB writes only`.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node node_modules/@rdlabo/workers-hono-kit/scripts/check-subrequest-fanout.mjs [dir ...]
|
|
18
|
+
* Defaults to scanning `src`. Exits 1 if any un-annotated marker is found.
|
|
19
|
+
*/
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const MARKER = /runWithConcurrency\(|PromisePool|\.withConcurrency\(/;
|
|
24
|
+
const ALLOW = /subrequest-ok/;
|
|
25
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);
|
|
26
|
+
|
|
27
|
+
/** Recursively collect .ts files (excluding *.spec.ts / *.test.ts). */
|
|
28
|
+
function collect(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir);
|
|
33
|
+
} catch {
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
for (const name of entries) {
|
|
37
|
+
const full = join(dir, name);
|
|
38
|
+
const st = statSync(full);
|
|
39
|
+
if (st.isDirectory()) {
|
|
40
|
+
if (!SKIP_DIRS.has(name)) {
|
|
41
|
+
out.push(...collect(full));
|
|
42
|
+
}
|
|
43
|
+
} else if (name.endsWith('.ts') && !name.endsWith('.spec.ts') && !name.endsWith('.test.ts')) {
|
|
44
|
+
out.push(full);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const targets = process.argv.slice(2);
|
|
51
|
+
const roots = targets.length > 0 ? targets : ['src'];
|
|
52
|
+
|
|
53
|
+
const violations = [];
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
for (const file of collect(root)) {
|
|
56
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
57
|
+
lines.forEach((line, i) => {
|
|
58
|
+
if (!MARKER.test(line)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Only flag executable code, not porting notes / JSDoc that merely mention the markers.
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const prev = i > 0 ? lines[i - 1] : '';
|
|
67
|
+
if (ALLOW.test(line) || ALLOW.test(prev)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
violations.push({ file, line: i + 1, text: trimmed });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (violations.length > 0) {
|
|
76
|
+
console.error('✖ subrequest fan-out gate: un-annotated concurrency loop(s) found.');
|
|
77
|
+
console.error(' Each may loop an external call per item (fetch/AI/Stripe/push/ES) and blow the');
|
|
78
|
+
console.error(' Workers subrequest cap as data grows. Move it behind a queue / cap it, or, if the');
|
|
79
|
+
console.error(' loop body is DB-only or hard-capped, annotate with `// subrequest-ok: <reason>`.\n');
|
|
80
|
+
for (const v of violations) {
|
|
81
|
+
console.error(` ${v.file}:${v.line} ${v.text}`);
|
|
82
|
+
}
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('✓ subrequest fan-out gate: no un-annotated concurrency loops.');
|
package/src/ai/gateway.ts
CHANGED
|
@@ -1,61 +1,100 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cloudflare AI Gateway
|
|
3
|
-
* フリート共通 = foodlabel / winecode / receptray hono の AI 呼び出しを必ず Gateway 経由にする。
|
|
2
|
+
* Cloudflare AI Gateway provider factory built on the Vercel AI SDK and `ai-gateway-provider`.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Routes OpenAI, Anthropic, and Google Vertex `@ai-sdk/*` models through the AI Gateway Universal
|
|
5
|
+
* Endpoint. The wrapper returned by `createAiGateway` intercepts the provider-bound requests the SDK
|
|
6
|
+
* assembles (`api.openai.com`, `api.anthropic.com`, `*-aiplatform.googleapis.com`, etc.) and redirects
|
|
7
|
+
* them through the Gateway. Every provider is routed transparently via the same `aigateway(model)` call.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* This module is purely the infrastructure layer: it injects only the Gateway identifier and (optionally)
|
|
11
|
+
* the Gateway authentication token. Provider API keys and Vertex service-account credentials are supplied
|
|
12
|
+
* by the caller at model-construction time and passed through untouched.
|
|
12
13
|
*/
|
|
13
14
|
import { createAiGateway } from 'ai-gateway-provider';
|
|
14
15
|
import type { AiGateway, AiGatewayBindingSettings, AiGatewayOptions } from 'ai-gateway-provider';
|
|
15
16
|
|
|
16
17
|
export type { AiGateway, AiGatewayOptions } from 'ai-gateway-provider';
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Minimal shape of a Workers AI binding (`env.AI.gateway(name)`).
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Cloudflare's runtime `AiGateway` type is structurally compatible with this binding shape.
|
|
24
|
+
*/
|
|
19
25
|
export type AiGatewayBinding = AiGatewayBindingSettings['binding'];
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
|
-
* AI Gateway
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
28
|
+
* Configuration for the AI Gateway provider. This is a union with two mutually exclusive forms.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* - **Binding form** — for the Workers runtime (production and `wrangler dev`). Pass the
|
|
32
|
+
* `env.AI.gateway(name)` binding. Requests through a binding are pre-authenticated within the same
|
|
33
|
+
* Cloudflare account, so no Gateway token is required.
|
|
34
|
+
* - **REST form** — for non-Workers contexts where a binding is unavailable (e.g. a Node evaluation
|
|
35
|
+
* harness). Supply `accountId`, `gateway`, and (for authenticated Gateways) `token` to reach the
|
|
36
|
+
* Gateway over REST.
|
|
26
37
|
*/
|
|
27
38
|
export type AiGatewayConfig =
|
|
28
39
|
| {
|
|
29
|
-
/** `env.AI.gateway(name)
|
|
40
|
+
/** The AI Gateway binding, typically obtained via `env.AI.gateway(name)`. */
|
|
30
41
|
binding: AiGatewayBinding;
|
|
31
|
-
/**
|
|
42
|
+
/** Optional Gateway options such as caching, retries, and request metadata. */
|
|
32
43
|
options?: AiGatewayOptions;
|
|
33
44
|
}
|
|
34
45
|
| {
|
|
35
|
-
/** Cloudflare
|
|
46
|
+
/** Cloudflare account ID that owns the Gateway. */
|
|
36
47
|
accountId: string;
|
|
37
|
-
/** AI Gateway
|
|
48
|
+
/** AI Gateway name. */
|
|
38
49
|
gateway: string;
|
|
39
50
|
/**
|
|
40
|
-
* `cf-aig-authorization`
|
|
41
|
-
*
|
|
51
|
+
* Gateway authentication token sent in the `cf-aig-authorization` header. Required only for an
|
|
52
|
+
* Authenticated Gateway; omit it for an unauthenticated Gateway. This authenticates the request to
|
|
53
|
+
* the Gateway itself and is distinct from any provider API key.
|
|
42
54
|
*/
|
|
43
55
|
token?: string;
|
|
44
|
-
/**
|
|
56
|
+
/** Optional Gateway options such as caching, retries, and request metadata. */
|
|
45
57
|
options?: AiGatewayOptions;
|
|
46
58
|
};
|
|
47
59
|
|
|
60
|
+
/** Provider object exposing the AI Gateway model wrapper. */
|
|
48
61
|
export interface AiGatewayProvider {
|
|
49
62
|
/**
|
|
50
|
-
* `@ai-sdk/*`
|
|
51
|
-
*
|
|
52
|
-
*
|
|
63
|
+
* Wraps an `@ai-sdk/*` model so its requests are routed through the AI Gateway.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Example invocation: `aigateway(createAnthropic({ apiKey })('claude-...'))`. Passing an array of
|
|
67
|
+
* models enables fallback behavior — each model is attempted in order from the start of the array.
|
|
53
68
|
*/
|
|
54
69
|
aigateway: AiGateway;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
/**
|
|
58
|
-
* AI Gateway
|
|
73
|
+
* Create an AI Gateway provider from either the binding form or the REST form of the configuration.
|
|
74
|
+
*
|
|
75
|
+
* @param config - The Gateway configuration; either the binding form or the REST form.
|
|
76
|
+
* @returns A provider whose `aigateway` wrapper routes models through the AI Gateway.
|
|
77
|
+
* @throws Error When the REST form is used and `accountId` or `gateway` is missing (fail-fast).
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // Binding form (Workers runtime: production / wrangler dev)
|
|
81
|
+
* import { createAnthropic } from '@ai-sdk/anthropic';
|
|
82
|
+
*
|
|
83
|
+
* const { aigateway } = createAiGatewayProvider({ binding: env.AI.gateway('my-gateway') });
|
|
84
|
+
* const model = aigateway(createAnthropic({ apiKey: env.ANTHROPIC_API_KEY })('claude-3-5-sonnet-latest'));
|
|
85
|
+
* ```
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* // REST form (non-Workers context, e.g. a Node evaluation harness)
|
|
89
|
+
* import { createOpenAI } from '@ai-sdk/openai';
|
|
90
|
+
*
|
|
91
|
+
* const { aigateway } = createAiGatewayProvider({
|
|
92
|
+
* accountId: process.env.CF_ACCOUNT_ID!,
|
|
93
|
+
* gateway: 'my-gateway',
|
|
94
|
+
* token: process.env.CF_AIG_TOKEN, // only for an Authenticated Gateway
|
|
95
|
+
* });
|
|
96
|
+
* const model = aigateway(createOpenAI({ apiKey: process.env.OPENAI_API_KEY })('gpt-4o'));
|
|
97
|
+
* ```
|
|
59
98
|
*/
|
|
60
99
|
export function createAiGatewayProvider(config: AiGatewayConfig): AiGatewayProvider {
|
|
61
100
|
if ('binding' in config) {
|
|
@@ -63,13 +102,13 @@ export function createAiGatewayProvider(config: AiGatewayConfig): AiGatewayProvi
|
|
|
63
102
|
}
|
|
64
103
|
|
|
65
104
|
if (!config.accountId) {
|
|
66
|
-
throw new Error('AI Gateway: accountId
|
|
105
|
+
throw new Error('AI Gateway: accountId is not set');
|
|
67
106
|
}
|
|
68
107
|
if (!config.gateway) {
|
|
69
|
-
throw new Error('AI Gateway: gateway
|
|
108
|
+
throw new Error('AI Gateway: gateway name is not set');
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
// token
|
|
111
|
+
// The token is sent as apiKey only for an Authenticated Gateway; undefined is fine when unauthenticated.
|
|
73
112
|
return {
|
|
74
113
|
aigateway: createAiGateway({
|
|
75
114
|
accountId: config.accountId,
|
package/src/aws/cloudfront.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CloudFront
|
|
3
|
-
* Cloudflare Workers ネイティブ(aws-sdk 不要)。フリート共通 = tipsys/winecode hono。
|
|
2
|
+
* Generate a CloudFront signed URL using a canned policy, implemented natively for Cloudflare Workers.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Reimplements `getSignedUrl` from `@aws-sdk/cloudfront-signer` on top of the Web Crypto API, so no
|
|
5
|
+
* `@aws-sdk` dependency is required. The canned policy is signed with RSASSA-PKCS1-v1_5 and SHA-1, the
|
|
6
|
+
* signature is converted to AWS URL-safe base64 (`+` -> `-`, `/` -> `~`, `=` -> `_`), and the query
|
|
7
|
+
* parameters are appended in the order `Expires`, `Key-Pair-Id`, `Signature`.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The output is byte-for-byte identical to that of `@aws-sdk/cloudfront-signer`.
|
|
11
|
+
*
|
|
12
|
+
* @param url - The resource URL to sign.
|
|
13
|
+
* @param privateKeyPem - The CloudFront key group private key in PKCS#8 PEM format.
|
|
14
|
+
* @param keyPairId - The CloudFront public key (key pair) ID associated with the private key.
|
|
15
|
+
* @param dateLessThan - Expiry time, accepted as a `Date`, epoch-millisecond number, or date string.
|
|
16
|
+
* @returns The signed URL with the `Expires`, `Key-Pair-Id`, and `Signature` query parameters appended.
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const signedUrl = await getCloudFrontSignedUrl(
|
|
20
|
+
* 'https://cdn.example.com/private/video.mp4',
|
|
21
|
+
* env.CLOUDFRONT_PRIVATE_KEY,
|
|
22
|
+
* env.CLOUDFRONT_KEY_PAIR_ID,
|
|
23
|
+
* Date.now() + 60 * 60 * 1000, // valid for one hour
|
|
24
|
+
* );
|
|
25
|
+
* ```
|
|
8
26
|
*/
|
|
9
27
|
export async function getCloudFrontSignedUrl(
|
|
10
28
|
url: string,
|
|
@@ -34,14 +52,29 @@ export async function getCloudFrontSignedUrl(
|
|
|
34
52
|
const signature = toUrlSafeBase64(arrayBufferToBase64(signatureBuffer));
|
|
35
53
|
const separator = url.includes('?') ? '&' : '?';
|
|
36
54
|
|
|
37
|
-
// @aws-sdk/cloudfront-signer
|
|
55
|
+
// Query order used by @aws-sdk/cloudfront-signer: Expires -> Key-Pair-Id -> Signature
|
|
38
56
|
return `${url}${separator}Expires=${epochSeconds}&Key-Pair-Id=${keyPairId}&Signature=${signature}`;
|
|
39
57
|
}
|
|
40
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Convert standard base64 to the URL-safe alphabet expected in CloudFront signatures.
|
|
61
|
+
*
|
|
62
|
+
* @param value - A standard base64 string.
|
|
63
|
+
* @returns The base64 string with `+` -> `-`, `=` -> `_`, and `/` -> `~`.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
|
|
41
67
|
function toUrlSafeBase64(value: string): string {
|
|
42
68
|
return value.replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~');
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Encode an `ArrayBuffer` to standard base64.
|
|
73
|
+
*
|
|
74
|
+
* @param buffer - The raw bytes to encode.
|
|
75
|
+
* @returns The standard base64 representation of the buffer.
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
45
78
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
46
79
|
const bytes = new Uint8Array(buffer);
|
|
47
80
|
let binary = '';
|
|
@@ -51,6 +84,13 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
|
51
84
|
return btoa(binary);
|
|
52
85
|
}
|
|
53
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Decode a PKCS#8 PEM private key into its DER `ArrayBuffer`.
|
|
89
|
+
*
|
|
90
|
+
* @param pem - The PEM-encoded key, including the BEGIN/END armor.
|
|
91
|
+
* @returns The decoded DER bytes, suitable for `crypto.subtle.importKey('pkcs8', ...)`.
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
54
94
|
function pemToDer(pem: string): ArrayBuffer {
|
|
55
95
|
const base64 = pem
|
|
56
96
|
.replace(/-----BEGIN [^-]+-----/, '')
|
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
import { AwsClient } from 'aws4fetch';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* AWS
|
|
5
|
-
* Cloudflare Workers には AWS SDK も IAM ロールも無いため、AWS の静的キーを Workers secrets として
|
|
6
|
-
* 渡して署名する(移植元 `api/src/secrets-manager.ts` 相当)。DB 認証情報は Hyperdrive 側に持つので対象外。
|
|
4
|
+
* AWS credentials used to sign Secrets Manager requests.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Cloudflare Workers have neither the AWS SDK nor IAM role credentials, so static AWS keys are supplied
|
|
8
|
+
* as Workers secrets and used to produce a SigV4 signature.
|
|
9
9
|
*/
|
|
10
10
|
export interface AwsSecretsOptions {
|
|
11
|
+
/** AWS access key ID. */
|
|
11
12
|
accessKeyId: string;
|
|
13
|
+
/** AWS secret access key. */
|
|
12
14
|
secretAccessKey: string;
|
|
15
|
+
/** Optional STS session token, required when using temporary credentials. */
|
|
13
16
|
sessionToken?: string;
|
|
17
|
+
/** AWS region of the Secrets Manager endpoint, e.g. `ap-northeast-1`. */
|
|
14
18
|
region: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* Per-isolate cache
|
|
19
|
-
*
|
|
20
|
-
*
|
|
22
|
+
* Per-isolate cache for the fetched secret.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* Secrets Manager is queried at most once per isolate. The entry is keyed by
|
|
26
|
+
* `region:accessKeyId:secretId`, so rotating credentials triggers a fresh fetch. The in-flight promise
|
|
27
|
+
* itself is cached so that concurrent first-time callers share a single request. On rejection the cache
|
|
28
|
+
* is cleared so a failed fetch can be retried.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
21
31
|
*/
|
|
22
32
|
let cache: { key: string; value: Promise<unknown> } | null = null;
|
|
23
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Fetch and parse a secret from AWS Secrets Manager, caching the result per isolate.
|
|
36
|
+
*
|
|
37
|
+
* Issues a `GetSecretValue` call to Secrets Manager via a SigV4-signed `fetch` (using aws4fetch), with no
|
|
38
|
+
* AWS SDK involved. The parsed `SecretString` is cached per isolate keyed by region, access key ID, and
|
|
39
|
+
* secret ID; concurrent first-time callers share one in-flight request, and a rejected fetch clears the
|
|
40
|
+
* cache entry so the next call retries.
|
|
41
|
+
*
|
|
42
|
+
* @typeParam T - The shape of the JSON-parsed secret payload, supplied by the caller.
|
|
43
|
+
* @param options - AWS credentials and region used to sign the request.
|
|
44
|
+
* @param secretId - The Secrets Manager secret ID or ARN to retrieve.
|
|
45
|
+
* @returns The parsed secret value cast to `T`.
|
|
46
|
+
* @throws Error When the Secrets Manager response is not OK, or when it contains no `SecretString`.
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* interface DbSecret {
|
|
50
|
+
* username: string;
|
|
51
|
+
* password: string;
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* const secret = await getAuthenticationSecret<DbSecret>(
|
|
55
|
+
* {
|
|
56
|
+
* accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
57
|
+
* secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
58
|
+
* region: 'ap-northeast-1',
|
|
59
|
+
* },
|
|
60
|
+
* 'prod/db/credentials',
|
|
61
|
+
* );
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
24
64
|
export function getAuthenticationSecret<T>(options: AwsSecretsOptions, secretId: string): Promise<T> {
|
|
25
65
|
const key = `${options.region}:${options.accessKeyId}:${secretId}`;
|
|
26
66
|
if (cache?.key !== key) {
|
|
@@ -33,6 +73,15 @@ export function getAuthenticationSecret<T>(options: AwsSecretsOptions, secretId:
|
|
|
33
73
|
return cache.value as Promise<T>;
|
|
34
74
|
}
|
|
35
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Perform the SigV4-signed `GetSecretValue` request and parse the returned `SecretString`.
|
|
78
|
+
*
|
|
79
|
+
* @param options - AWS credentials and region used to sign the request.
|
|
80
|
+
* @param secretId - The Secrets Manager secret ID or ARN to retrieve.
|
|
81
|
+
* @returns The JSON-parsed secret payload.
|
|
82
|
+
* @throws Error When the response is not OK, or when it contains no `SecretString`.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
36
85
|
async function fetchSecret(options: AwsSecretsOptions, secretId: string): Promise<unknown> {
|
|
37
86
|
const aws = new AwsClient({
|
|
38
87
|
accessKeyId: options.accessKeyId,
|