@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.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/AUTHORING.md +102 -0
- package/CHANGELOG.md +14 -0
- package/README.md +100 -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 +47 -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 +28 -9
- package/src/ceremonies/index.ts +747 -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 +28 -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 +54 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/composite.ts +43 -0
- package/src/define.ts +527 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +50 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +14 -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 +10 -2
- 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 +20 -5
- 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 +610 -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 +364 -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;
|
|
@@ -84,6 +87,7 @@ async function doRequest(
|
|
|
84
87
|
throw new TransportError(
|
|
85
88
|
`HTTP ${response.status} ${response.statusText}: ${requestUrl}`,
|
|
86
89
|
{
|
|
90
|
+
code: "upstream_http_error",
|
|
87
91
|
status: response.status,
|
|
88
92
|
fix: `Check the endpoint URL and request parameters. Response: ${text.slice(0, 200)}`,
|
|
89
93
|
},
|
|
@@ -120,12 +124,14 @@ async function doRequest(
|
|
|
120
124
|
throw new TransportError(
|
|
121
125
|
`Request timed out: ${resolveUrl(baseUrl, url)}`,
|
|
122
126
|
{
|
|
127
|
+
code: "transport_timeout",
|
|
123
128
|
fix: `Increase timeout option (current: ${timeout}ms)`,
|
|
124
129
|
},
|
|
125
130
|
);
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
throw new TransportError(`Network error: ${String(error)}`, {
|
|
134
|
+
code: "transport_network_error",
|
|
129
135
|
cause: error instanceof Error ? error : undefined,
|
|
130
136
|
});
|
|
131
137
|
} finally {
|
|
@@ -142,7 +148,9 @@ function createProxyInit(
|
|
|
142
148
|
return {};
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
|
|
151
|
+
const bunRuntime = Object.getOwnPropertyDescriptor(globalThis, "Bun")?.value;
|
|
152
|
+
|
|
153
|
+
if (bunRuntime !== undefined) {
|
|
146
154
|
return { proxy };
|
|
147
155
|
}
|
|
148
156
|
|
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
|
+
}
|