@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.
Files changed (79) hide show
  1. package/AUTHORING.md +93 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +133 -28
  4. package/bin/apifuse-check.ts +78 -71
  5. package/bin/apifuse-create.ts +12 -0
  6. package/bin/apifuse-dev.ts +24 -61
  7. package/bin/apifuse-pack-check.ts +87 -0
  8. package/bin/apifuse-pack-smoke.ts +122 -0
  9. package/bin/apifuse-perf.ts +33 -32
  10. package/bin/apifuse-record.ts +17 -7
  11. package/bin/apifuse-test.ts +6 -4
  12. package/bin/apifuse.ts +36 -35
  13. package/package.json +29 -9
  14. package/src/ceremonies/index.ts +768 -0
  15. package/src/cli/commands.ts +87 -0
  16. package/src/cli/create.ts +845 -0
  17. package/src/cli/templates/provider/Dockerfile.tpl +7 -0
  18. package/src/cli/templates/provider/README.md.tpl +41 -0
  19. package/src/cli/templates/provider/dev.ts.tpl +5 -0
  20. package/src/cli/templates/provider/index.test.ts.tpl +13 -0
  21. package/src/cli/templates/provider/index.ts.tpl +58 -0
  22. package/src/cli/templates/provider/start.ts.tpl +5 -0
  23. package/src/config/loader.ts +61 -1
  24. package/src/define.ts +565 -41
  25. package/src/dev.ts +2 -6
  26. package/src/errors.ts +42 -0
  27. package/src/index.ts +44 -38
  28. package/src/lint.ts +574 -0
  29. package/src/provider.ts +13 -0
  30. package/src/runtime/auth-flow.ts +67 -0
  31. package/src/runtime/credential.ts +95 -0
  32. package/src/runtime/env.ts +13 -0
  33. package/src/runtime/executor.ts +13 -14
  34. package/src/runtime/http.ts +36 -12
  35. package/src/runtime/insights.ts +3 -3
  36. package/src/runtime/key-derivation.ts +122 -0
  37. package/src/runtime/keyring.ts +148 -0
  38. package/src/runtime/namespace.ts +33 -0
  39. package/src/runtime/prevalidate.ts +252 -0
  40. package/src/runtime/tls.ts +41 -17
  41. package/src/runtime/waterfall.ts +0 -1
  42. package/src/schema.ts +77 -0
  43. package/src/serve.ts +1 -664
  44. package/src/server/index.ts +22 -0
  45. package/src/server/serve.ts +624 -0
  46. package/src/server/types.ts +78 -0
  47. package/src/stealth/profiles.ts +10 -93
  48. package/src/testing/run.ts +391 -32
  49. package/src/types.ts +390 -41
  50. package/bin/apifuse-init.ts +0 -387
  51. package/src/__tests__/auth.test.ts +0 -396
  52. package/src/__tests__/browser-auth.test.ts +0 -180
  53. package/src/__tests__/browser.test.ts +0 -632
  54. package/src/__tests__/define.test.ts +0 -225
  55. package/src/__tests__/errors.test.ts +0 -69
  56. package/src/__tests__/executor.test.ts +0 -214
  57. package/src/__tests__/http.test.ts +0 -238
  58. package/src/__tests__/insights.test.ts +0 -210
  59. package/src/__tests__/instrumentation.test.ts +0 -290
  60. package/src/__tests__/otlp.test.ts +0 -141
  61. package/src/__tests__/perf.test.ts +0 -60
  62. package/src/__tests__/providers-yaml.test.ts +0 -135
  63. package/src/__tests__/proxy.test.ts +0 -359
  64. package/src/__tests__/recipes.test.ts +0 -36
  65. package/src/__tests__/serve.test.ts +0 -233
  66. package/src/__tests__/session.test.ts +0 -231
  67. package/src/__tests__/state.test.ts +0 -100
  68. package/src/__tests__/stealth.test.ts +0 -57
  69. package/src/__tests__/testing.test.ts +0 -97
  70. package/src/__tests__/tls.test.ts +0 -345
  71. package/src/__tests__/types.test.ts +0 -142
  72. package/src/__tests__/utils.test.ts +0 -62
  73. package/src/__tests__/waterfall.test.ts +0 -270
  74. package/src/config/providers-yaml.ts +0 -370
  75. package/src/index.test.ts +0 -1
  76. package/src/protocol.ts +0 -183
  77. package/src/runtime/auth.ts +0 -245
  78. package/src/runtime/session.ts +0 -573
  79. 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
+ }
@@ -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
- options?: { skipAuth?: boolean },
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 = operation.input.parse(input);
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 useAuth =
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 operation.output.parse(result);
48
+ return parseSchema(
49
+ operation.output,
50
+ result,
51
+ `operations.${operationId}.output`,
52
+ );
54
53
  }
@@ -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, timeout, body } = options;
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
- const text = await response.text().catch(() => "");
86
+ await drainFetchResponse(response);
84
87
  throw new TransportError(
85
- `HTTP ${response.status} ${response.statusText}: ${requestUrl}`,
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
- `Request timed out: ${resolveUrl(baseUrl, url)}`,
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(`Network error: ${String(error)}`, {
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
- if (typeof Bun !== "undefined") {
167
+ const bunRuntime = Object.getOwnPropertyDescriptor(globalThis, "Bun")?.value;
168
+
169
+ if (bunRuntime !== undefined) {
146
170
  return { proxy };
147
171
  }
148
172
 
@@ -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-131' });
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-131' });
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 === "auth.refresh",
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
+ }