@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.1
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/AUTHORING.md +93 -0
- package/CHANGELOG.md +21 -0
- package/README.md +133 -28
- package/bin/apifuse-check.ts +78 -71
- package/bin/apifuse-create.ts +12 -0
- package/bin/apifuse-dev.ts +24 -61
- package/bin/apifuse-pack-check.ts +87 -0
- package/bin/apifuse-pack-smoke.ts +122 -0
- package/bin/apifuse-perf.ts +33 -32
- package/bin/apifuse-record.ts +17 -7
- package/bin/apifuse-test.ts +6 -4
- package/bin/apifuse.ts +36 -35
- package/package.json +29 -9
- package/src/ceremonies/index.ts +768 -0
- package/src/cli/commands.ts +87 -0
- package/src/cli/create.ts +845 -0
- package/src/cli/templates/provider/Dockerfile.tpl +7 -0
- package/src/cli/templates/provider/README.md.tpl +41 -0
- package/src/cli/templates/provider/dev.ts.tpl +5 -0
- package/src/cli/templates/provider/index.test.ts.tpl +13 -0
- package/src/cli/templates/provider/index.ts.tpl +58 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/config/loader.ts +61 -1
- package/src/define.ts +565 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +44 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +13 -0
- package/src/runtime/auth-flow.ts +67 -0
- package/src/runtime/credential.ts +95 -0
- package/src/runtime/env.ts +13 -0
- package/src/runtime/executor.ts +13 -14
- package/src/runtime/http.ts +36 -12
- package/src/runtime/insights.ts +3 -3
- package/src/runtime/key-derivation.ts +122 -0
- package/src/runtime/keyring.ts +148 -0
- package/src/runtime/namespace.ts +33 -0
- package/src/runtime/prevalidate.ts +252 -0
- package/src/runtime/tls.ts +41 -17
- package/src/runtime/waterfall.ts +0 -1
- package/src/schema.ts +77 -0
- package/src/serve.ts +1 -664
- package/src/server/index.ts +22 -0
- package/src/server/serve.ts +624 -0
- package/src/server/types.ts +78 -0
- package/src/stealth/profiles.ts +10 -93
- package/src/testing/run.ts +391 -32
- package/src/types.ts +390 -41
- package/bin/apifuse-init.ts +0 -387
- package/src/__tests__/auth.test.ts +0 -396
- package/src/__tests__/browser-auth.test.ts +0 -180
- package/src/__tests__/browser.test.ts +0 -632
- package/src/__tests__/define.test.ts +0 -225
- package/src/__tests__/errors.test.ts +0 -69
- package/src/__tests__/executor.test.ts +0 -214
- package/src/__tests__/http.test.ts +0 -238
- package/src/__tests__/insights.test.ts +0 -210
- package/src/__tests__/instrumentation.test.ts +0 -290
- package/src/__tests__/otlp.test.ts +0 -141
- package/src/__tests__/perf.test.ts +0 -60
- package/src/__tests__/providers-yaml.test.ts +0 -135
- package/src/__tests__/proxy.test.ts +0 -359
- package/src/__tests__/recipes.test.ts +0 -36
- package/src/__tests__/serve.test.ts +0 -233
- package/src/__tests__/session.test.ts +0 -231
- package/src/__tests__/state.test.ts +0 -100
- package/src/__tests__/stealth.test.ts +0 -57
- package/src/__tests__/testing.test.ts +0 -97
- package/src/__tests__/tls.test.ts +0 -345
- package/src/__tests__/types.test.ts +0 -142
- package/src/__tests__/utils.test.ts +0 -62
- package/src/__tests__/waterfall.test.ts +0 -270
- package/src/config/providers-yaml.ts +0 -370
- package/src/index.test.ts +0 -1
- package/src/protocol.ts +0 -183
- package/src/runtime/auth.ts +0 -245
- package/src/runtime/session.ts +0 -573
- package/src/runtime/state.ts +0 -124
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CredentialModeError } from "../errors";
|
|
2
|
+
import type { AuthMode, CredentialContext } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface CreateCredentialContextOptions {
|
|
5
|
+
allowedKeys?: string[];
|
|
6
|
+
mode?: AuthMode;
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
values?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getAllowedKeys(
|
|
12
|
+
allowedKeys: string[] | undefined,
|
|
13
|
+
values: Record<string, string>,
|
|
14
|
+
): string[] {
|
|
15
|
+
if (allowedKeys) {
|
|
16
|
+
return allowedKeys;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Object.keys(values);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeScopes(
|
|
23
|
+
mode: AuthMode,
|
|
24
|
+
values: Record<string, string>,
|
|
25
|
+
scopes?: string[],
|
|
26
|
+
): string[] {
|
|
27
|
+
if (mode !== "oauth2") {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (scopes) {
|
|
32
|
+
return [...scopes];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rawScopes = values.scope ?? values.scopes;
|
|
36
|
+
if (!rawScopes) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return rawScopes
|
|
41
|
+
.split(/[\s,]+/)
|
|
42
|
+
.map((scope) => scope.trim())
|
|
43
|
+
.filter((scope) => scope.length > 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createCredentialContext(
|
|
47
|
+
options: CreateCredentialContextOptions = {},
|
|
48
|
+
): CredentialContext {
|
|
49
|
+
const mode = options.mode ?? "none";
|
|
50
|
+
const values = options.values ?? {};
|
|
51
|
+
const allowedKeys = getAllowedKeys(options.allowedKeys, values);
|
|
52
|
+
const allowedKeySet = new Set(allowedKeys);
|
|
53
|
+
const normalizedScopes = normalizeScopes(mode, values, options.scopes);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
mode,
|
|
57
|
+
get(key: string): string | undefined {
|
|
58
|
+
if (!allowedKeySet.has(key)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return values[key];
|
|
63
|
+
},
|
|
64
|
+
getAll(): Record<string, string> {
|
|
65
|
+
const result: Record<string, string> = {};
|
|
66
|
+
|
|
67
|
+
for (const key of allowedKeys) {
|
|
68
|
+
const value = values[key];
|
|
69
|
+
if (value !== undefined) {
|
|
70
|
+
result[key] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
getAccessToken(): string | undefined {
|
|
77
|
+
if (mode !== "oauth2") {
|
|
78
|
+
throw new CredentialModeError(
|
|
79
|
+
"Access tokens are only available for oauth2 credential mode.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return values.access_token;
|
|
84
|
+
},
|
|
85
|
+
getScopes(): string[] {
|
|
86
|
+
if (mode !== "oauth2") {
|
|
87
|
+
throw new CredentialModeError(
|
|
88
|
+
"OAuth scopes are only available for oauth2 credential mode.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [...normalizedScopes];
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EnvContext } from "../types";
|
|
2
|
+
|
|
3
|
+
export function createEnvContext(allowedKeys?: string[]): EnvContext {
|
|
4
|
+
return {
|
|
5
|
+
get(key: string): string | undefined {
|
|
6
|
+
if (allowedKeys && !allowedKeys.includes(key)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return process.env[key];
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
package/src/runtime/executor.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { ProviderError } from "../errors";
|
|
2
|
+
import { parseSchema } from "../schema";
|
|
2
3
|
import type { ProviderContext, ProviderDefinition } from "../types";
|
|
3
4
|
|
|
4
|
-
import { createAuthManager } from "./auth";
|
|
5
|
-
|
|
6
5
|
/**
|
|
7
6
|
* Execute a provider operation by calling its handler.
|
|
8
7
|
*
|
|
@@ -19,7 +18,7 @@ export async function executeOperation(
|
|
|
19
18
|
operationId: string,
|
|
20
19
|
ctx: ProviderContext,
|
|
21
20
|
input: unknown,
|
|
22
|
-
|
|
21
|
+
_options?: { skipAuth?: boolean },
|
|
23
22
|
): Promise<unknown> {
|
|
24
23
|
const operation = provider.operations[operationId];
|
|
25
24
|
|
|
@@ -33,22 +32,22 @@ export async function executeOperation(
|
|
|
33
32
|
);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
const validatedInput =
|
|
35
|
+
const validatedInput = await parseSchema(
|
|
36
|
+
operation.input,
|
|
37
|
+
input,
|
|
38
|
+
`operations.${operationId}.input`,
|
|
39
|
+
);
|
|
37
40
|
|
|
38
41
|
const execute = () =>
|
|
39
42
|
ctx.trace.span(`handler:${operationId}`, () =>
|
|
40
43
|
operation.handler(ctx, validatedInput),
|
|
41
44
|
);
|
|
42
45
|
|
|
43
|
-
const
|
|
44
|
-
!options?.skipAuth && provider.auth && provider.auth.mode !== "none";
|
|
45
|
-
|
|
46
|
-
const result = useAuth
|
|
47
|
-
? await createAuthManager(provider.auth, ctx.session).wrapWithAutoRefresh(
|
|
48
|
-
ctx,
|
|
49
|
-
execute,
|
|
50
|
-
)
|
|
51
|
-
: await execute();
|
|
46
|
+
const result = await execute();
|
|
52
47
|
|
|
53
|
-
return
|
|
48
|
+
return parseSchema(
|
|
49
|
+
operation.output,
|
|
50
|
+
result,
|
|
51
|
+
`operations.${operationId}.output`,
|
|
52
|
+
);
|
|
54
53
|
}
|
package/src/runtime/http.ts
CHANGED
|
@@ -17,6 +17,8 @@ const require = createRequire(import.meta.url);
|
|
|
17
17
|
const MISSING_PROXY_WARNING =
|
|
18
18
|
"[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
|
|
19
19
|
|
|
20
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
21
|
+
|
|
20
22
|
type FetchProxyInit = RequestInit & {
|
|
21
23
|
dispatcher?: unknown;
|
|
22
24
|
proxy?: string;
|
|
@@ -56,7 +58,8 @@ async function doRequest(
|
|
|
56
58
|
proxy: string | undefined,
|
|
57
59
|
options: RequestOptions & { body?: unknown } = {},
|
|
58
60
|
): Promise<HttpResponse> {
|
|
59
|
-
const { headers, params,
|
|
61
|
+
const { headers, params, body } = options;
|
|
62
|
+
const timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
60
63
|
|
|
61
64
|
const controller = new AbortController();
|
|
62
65
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -80,12 +83,12 @@ async function doRequest(
|
|
|
80
83
|
});
|
|
81
84
|
|
|
82
85
|
if (!response.ok) {
|
|
83
|
-
|
|
86
|
+
await drainFetchResponse(response);
|
|
84
87
|
throw new TransportError(
|
|
85
|
-
`
|
|
88
|
+
`Upstream request failed with status ${response.status}`,
|
|
86
89
|
{
|
|
90
|
+
code: "upstream_http_error",
|
|
87
91
|
status: response.status,
|
|
88
|
-
fix: `Check the endpoint URL and request parameters. Response: ${text.slice(0, 200)}`,
|
|
89
92
|
},
|
|
90
93
|
);
|
|
91
94
|
}
|
|
@@ -117,15 +120,13 @@ async function doRequest(
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
if (error instanceof Error && error.name === "AbortError") {
|
|
120
|
-
throw new TransportError(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
fix: `Increase timeout option (current: ${timeout}ms)`,
|
|
124
|
-
},
|
|
125
|
-
);
|
|
123
|
+
throw new TransportError("Request timed out", {
|
|
124
|
+
code: "transport_timeout",
|
|
125
|
+
});
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
throw new TransportError(
|
|
128
|
+
throw new TransportError("Network error", {
|
|
129
|
+
code: "transport_network_error",
|
|
129
130
|
cause: error instanceof Error ? error : undefined,
|
|
130
131
|
});
|
|
131
132
|
} finally {
|
|
@@ -135,6 +136,27 @@ async function doRequest(
|
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
async function drainFetchResponse(response: Response): Promise<void> {
|
|
140
|
+
const reader = response.body?.getReader();
|
|
141
|
+
if (!reader) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
while (true) {
|
|
147
|
+
const { done } = await reader.read();
|
|
148
|
+
if (done) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Best-effort drain for transport reuse only; callers still receive the
|
|
154
|
+
// sanitized upstream error below.
|
|
155
|
+
} finally {
|
|
156
|
+
reader.releaseLock();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
function createProxyInit(
|
|
139
161
|
proxy?: string,
|
|
140
162
|
): Pick<FetchProxyInit, "dispatcher" | "proxy"> {
|
|
@@ -142,7 +164,9 @@ function createProxyInit(
|
|
|
142
164
|
return {};
|
|
143
165
|
}
|
|
144
166
|
|
|
145
|
-
|
|
167
|
+
const bunRuntime = Object.getOwnPropertyDescriptor(globalThis, "Bun")?.value;
|
|
168
|
+
|
|
169
|
+
if (bunRuntime !== undefined) {
|
|
146
170
|
return { proxy };
|
|
147
171
|
}
|
|
148
172
|
|
package/src/runtime/insights.ts
CHANGED
|
@@ -27,7 +27,7 @@ const DNS_WARN_MS = 5;
|
|
|
27
27
|
const BROWSER_IDLE_MS = 5_000;
|
|
28
28
|
const REFRESH_WARN_RATE = 0.1;
|
|
29
29
|
|
|
30
|
-
const TLS_REUSE_FIX = `const session = ctx.tls.createSession({ profile: 'chrome-
|
|
30
|
+
const TLS_REUSE_FIX = `const session = ctx.tls.createSession({ profile: 'chrome-146' });
|
|
31
31
|
const resp = await session.fetch(url, opts);`;
|
|
32
32
|
|
|
33
33
|
const TRANSFORM_FIX = `transformResponse: (raw) => {
|
|
@@ -39,7 +39,7 @@ const LARGE_RESPONSE_FIX = `const resp = await ctx.http.get('/items', {
|
|
|
39
39
|
});`;
|
|
40
40
|
|
|
41
41
|
const DNS_FIX = `// Enable DNS caching or reuse a long-lived session per host.
|
|
42
|
-
const session = ctx.tls.createSession({ profile: 'chrome-
|
|
42
|
+
const session = ctx.tls.createSession({ profile: 'chrome-146' });
|
|
43
43
|
await session.fetch(url, opts);`;
|
|
44
44
|
|
|
45
45
|
const PROXY_FIX = `// Re-check whether this operation really needs a proxy.
|
|
@@ -375,7 +375,7 @@ function getBrowserIdleInsight(spans: Span[]): InsightResult {
|
|
|
375
375
|
|
|
376
376
|
function getSessionExpiryInsight(spans: Span[]): InsightResult {
|
|
377
377
|
const refreshCount = spans.filter(
|
|
378
|
-
(span) => span.name === "
|
|
378
|
+
(span) => span.name === "credential.refresh",
|
|
379
379
|
).length;
|
|
380
380
|
const requestCount = spans.filter(isRequestSpan).length;
|
|
381
381
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash, hkdfSync } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HKDF purpose enum. Closed set; adding a purpose requires a spec amendment in
|
|
5
|
+
* `provider-isolation-hardening`. Each purpose uses a distinct salt so a leak
|
|
6
|
+
* of one subkey cannot reveal another for the same provider.
|
|
7
|
+
*
|
|
8
|
+
* Tenant-scoped columns (`connections.external_ref`, `connections.metadata`)
|
|
9
|
+
* are plaintext with RLS + log redaction + audit log; no HKDF purpose exists
|
|
10
|
+
* for them.
|
|
11
|
+
*/
|
|
12
|
+
export type KeyPurpose =
|
|
13
|
+
| "credential-encryption"
|
|
14
|
+
| "context-namespace"
|
|
15
|
+
| "token-signing";
|
|
16
|
+
|
|
17
|
+
const SALT_PREFIX = "apifuse:v1:";
|
|
18
|
+
const OUTPUT_LENGTH = 32;
|
|
19
|
+
|
|
20
|
+
const cache = new Map<string, Buffer>();
|
|
21
|
+
|
|
22
|
+
function cacheKey(
|
|
23
|
+
keyVersion: number,
|
|
24
|
+
providerId: string,
|
|
25
|
+
purpose: KeyPurpose,
|
|
26
|
+
): string {
|
|
27
|
+
return `${keyVersion}\u0000${providerId}\u0000${purpose}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function computeSalt(purpose: KeyPurpose): Buffer {
|
|
31
|
+
return createHash("sha256")
|
|
32
|
+
.update(SALT_PREFIX + purpose)
|
|
33
|
+
.digest();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function computeInfo(providerId: string): Buffer {
|
|
37
|
+
return Buffer.from(`provider=${providerId}`, "utf8");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @internal Trusted loaders only; not re-exported to provider-importable paths. */
|
|
41
|
+
export function decodeMasterKey(encoded: string): Buffer {
|
|
42
|
+
if (typeof encoded !== "string" || encoded.length === 0) {
|
|
43
|
+
throw new ConfigurationError(
|
|
44
|
+
"master key is empty; set APIFUSE_MASTER_KEY_V{n} in the external secret manager",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
49
|
+
const padded =
|
|
50
|
+
normalized.length % 4 === 0
|
|
51
|
+
? normalized
|
|
52
|
+
: normalized + "=".repeat(4 - (normalized.length % 4));
|
|
53
|
+
|
|
54
|
+
// Pre-decode character set guard — Buffer.from silently accepts invalid base64.
|
|
55
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(padded)) {
|
|
56
|
+
throw new ConfigurationError("master key is not valid base64");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const raw = Buffer.from(padded, "base64");
|
|
60
|
+
|
|
61
|
+
// Invalid base64 truncates silently; compare against the expected decode length.
|
|
62
|
+
const expectedMinLength = Math.floor((padded.length * 3) / 4) - 2;
|
|
63
|
+
if (raw.length < expectedMinLength) {
|
|
64
|
+
throw new ConfigurationError("master key is not valid base64");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (raw.length < 32) {
|
|
68
|
+
throw new ConfigurationError(
|
|
69
|
+
`master key must be ≥ 32 bytes after base64 decode (got ${raw.length})`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return raw;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ConfigurationError extends Error {
|
|
77
|
+
constructor(message: string) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "ConfigurationError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @internal Trusted loaders only; not re-exported to provider-importable paths. */
|
|
84
|
+
export function deriveSubkey(
|
|
85
|
+
masterSecret: Buffer,
|
|
86
|
+
providerId: string,
|
|
87
|
+
purpose: KeyPurpose,
|
|
88
|
+
keyVersion: number,
|
|
89
|
+
): Buffer {
|
|
90
|
+
if (masterSecret.length < 32) {
|
|
91
|
+
throw new ConfigurationError(
|
|
92
|
+
`master key must be ≥ 32 bytes (got ${masterSecret.length})`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (providerId.length === 0) {
|
|
96
|
+
throw new ConfigurationError("providerId is empty");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const key = cacheKey(keyVersion, providerId, purpose);
|
|
100
|
+
const cached = cache.get(key);
|
|
101
|
+
if (cached) {
|
|
102
|
+
return cached;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const salt = computeSalt(purpose);
|
|
106
|
+
const info = computeInfo(providerId);
|
|
107
|
+
const subkey = Buffer.from(
|
|
108
|
+
hkdfSync("sha256", masterSecret, salt, info, OUTPUT_LENGTH),
|
|
109
|
+
);
|
|
110
|
+
cache.set(key, subkey);
|
|
111
|
+
return subkey;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Invalidate the subkey cache. Used by the master-key rotation worker after a
|
|
116
|
+
* writer version change, and by tests to assert determinism.
|
|
117
|
+
*
|
|
118
|
+
* @internal
|
|
119
|
+
*/
|
|
120
|
+
export function invalidateSubkeyCache(): void {
|
|
121
|
+
cache.clear();
|
|
122
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ConfigurationError, decodeMasterKey } from "./key-derivation";
|
|
2
|
+
|
|
3
|
+
export interface KeyRingOptions {
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
envPrefix?: string;
|
|
6
|
+
acceptListVar?: string;
|
|
7
|
+
writerVersionVar?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface KeyRingEntry {
|
|
11
|
+
version: number;
|
|
12
|
+
key: Buffer;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KeyRing {
|
|
16
|
+
accept(version: number): KeyRingEntry;
|
|
17
|
+
activeWriter(): KeyRingEntry;
|
|
18
|
+
versions(): number[];
|
|
19
|
+
purgeVersion(
|
|
20
|
+
version: number,
|
|
21
|
+
isActiveInStore: (v: number) => Promise<boolean>,
|
|
22
|
+
): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_KEY_PREFIX = "APIFUSE_MASTER_KEY_V";
|
|
26
|
+
const DEFAULT_ACCEPT_LIST_VAR = "APIFUSE_MASTER_KEY_ACCEPT_LIST";
|
|
27
|
+
const DEFAULT_WRITER_VERSION_VAR = "APIFUSE_MASTER_KEY_WRITER_VERSION";
|
|
28
|
+
|
|
29
|
+
function parseAcceptList(raw: string | undefined): number[] {
|
|
30
|
+
if (!raw || raw.trim().length === 0) {
|
|
31
|
+
throw new ConfigurationError(
|
|
32
|
+
`accept-list env var is empty; expected comma-separated master key versions`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parts = raw
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter((s) => s.length > 0);
|
|
40
|
+
const versions: number[] = [];
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
const v = Number.parseInt(part, 10);
|
|
43
|
+
if (!Number.isInteger(v) || v <= 0 || String(v) !== part) {
|
|
44
|
+
throw new ConfigurationError(
|
|
45
|
+
`accept-list contains invalid version "${part}"; expected positive integers`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
versions.push(v);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (versions.length === 0) {
|
|
52
|
+
throw new ConfigurationError("accept-list is empty after parsing");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return versions;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseWriterVersion(
|
|
59
|
+
raw: string | undefined,
|
|
60
|
+
acceptList: number[],
|
|
61
|
+
): number {
|
|
62
|
+
if (!raw || raw.trim().length === 0) {
|
|
63
|
+
throw new ConfigurationError("writer-version env var is empty");
|
|
64
|
+
}
|
|
65
|
+
const trimmed = raw.trim();
|
|
66
|
+
const v = Number.parseInt(trimmed, 10);
|
|
67
|
+
if (!Number.isInteger(v) || v <= 0 || String(v) !== trimmed) {
|
|
68
|
+
throw new ConfigurationError(
|
|
69
|
+
`writer-version "${trimmed}" is not a positive integer`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!acceptList.includes(v)) {
|
|
73
|
+
throw new ConfigurationError(
|
|
74
|
+
`writer-version ${v} is not in the accept-list [${acceptList.join(", ")}]`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @internal Trusted loaders only; not re-exported to provider-importable paths.
|
|
82
|
+
*
|
|
83
|
+
* Loads master keys from the external-secret-manager-injected env (one entry per
|
|
84
|
+
* accepted version). Caller must keep the resulting {@link KeyRing} alive for the
|
|
85
|
+
* process lifetime; rotation requires a restart (or an explicit reload utility
|
|
86
|
+
* added later).
|
|
87
|
+
*/
|
|
88
|
+
export function loadKeyRing(options: KeyRingOptions): KeyRing {
|
|
89
|
+
const env = options.env;
|
|
90
|
+
const prefix = options.envPrefix ?? DEFAULT_KEY_PREFIX;
|
|
91
|
+
const acceptListVar = options.acceptListVar ?? DEFAULT_ACCEPT_LIST_VAR;
|
|
92
|
+
const writerVersionVar =
|
|
93
|
+
options.writerVersionVar ?? DEFAULT_WRITER_VERSION_VAR;
|
|
94
|
+
|
|
95
|
+
const acceptList = parseAcceptList(env[acceptListVar]);
|
|
96
|
+
const writerVersion = parseWriterVersion(env[writerVersionVar], acceptList);
|
|
97
|
+
|
|
98
|
+
const entries = new Map<number, KeyRingEntry>();
|
|
99
|
+
for (const version of acceptList) {
|
|
100
|
+
const raw = env[`${prefix}${version}`];
|
|
101
|
+
if (raw === undefined) {
|
|
102
|
+
throw new ConfigurationError(
|
|
103
|
+
`${prefix}${version} is missing (listed in accept-list)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const key = decodeMasterKey(raw);
|
|
107
|
+
entries.set(version, { version, key });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
accept(version) {
|
|
112
|
+
const entry = entries.get(version);
|
|
113
|
+
if (!entry) {
|
|
114
|
+
throw new ConfigurationError(
|
|
115
|
+
`version ${version} is not accepted; accept-list is [${acceptList.join(", ")}]`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return entry;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
activeWriter() {
|
|
122
|
+
const entry = entries.get(writerVersion);
|
|
123
|
+
if (!entry) {
|
|
124
|
+
throw new ConfigurationError(
|
|
125
|
+
`writer version ${writerVersion} is missing from accept-list entries`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return entry;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
versions() {
|
|
132
|
+
return [...acceptList];
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async purgeVersion(version, isActiveInStore) {
|
|
136
|
+
if (!entries.has(version)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const active = await isActiveInStore(version);
|
|
140
|
+
if (active) {
|
|
141
|
+
throw new ConfigurationError(
|
|
142
|
+
`cannot purge master-key version ${version}: active connection rows still reference it`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
entries.delete(version);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { deriveSubkey } from "./key-derivation";
|
|
4
|
+
|
|
5
|
+
const HMAC_HEX_LENGTH = 16;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @internal Trusted loaders only; not re-exported to provider-importable paths.
|
|
9
|
+
*
|
|
10
|
+
* Returns `provider:{HMAC16(contextNamespaceSubkey, providerId)}:{sessionId}`
|
|
11
|
+
* per the `context-namespace` HKDF purpose. Knowing `providerId` alone is
|
|
12
|
+
* insufficient to reconstruct the namespace — the HMAC requires the derived
|
|
13
|
+
* subkey, which is never exposed outside trusted code.
|
|
14
|
+
*/
|
|
15
|
+
export function deriveContextNamespace(
|
|
16
|
+
masterSecret: Buffer,
|
|
17
|
+
providerId: string,
|
|
18
|
+
sessionId: string,
|
|
19
|
+
keyVersion: number,
|
|
20
|
+
): string {
|
|
21
|
+
if (sessionId.length === 0) {
|
|
22
|
+
throw new Error("sessionId is empty");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const subkey = deriveSubkey(
|
|
26
|
+
masterSecret,
|
|
27
|
+
providerId,
|
|
28
|
+
"context-namespace",
|
|
29
|
+
keyVersion,
|
|
30
|
+
);
|
|
31
|
+
const hmac = createHmac("sha256", subkey).update(providerId).digest("hex");
|
|
32
|
+
return `provider:${hmac.slice(0, HMAC_HEX_LENGTH)}:${sessionId}`;
|
|
33
|
+
}
|