@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.
Files changed (78) hide show
  1. package/AUTHORING.md +102 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +100 -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 +47 -0
  8. package/bin/apifuse-perf.ts +33 -32
  9. package/bin/apifuse-record.ts +17 -7
  10. package/bin/apifuse-test.ts +6 -4
  11. package/bin/apifuse.ts +36 -35
  12. package/package.json +28 -9
  13. package/src/ceremonies/index.ts +747 -0
  14. package/src/cli/commands.ts +87 -0
  15. package/src/cli/create.ts +845 -0
  16. package/src/cli/templates/provider/Dockerfile.tpl +7 -0
  17. package/src/cli/templates/provider/README.md.tpl +28 -0
  18. package/src/cli/templates/provider/dev.ts.tpl +5 -0
  19. package/src/cli/templates/provider/index.test.ts.tpl +13 -0
  20. package/src/cli/templates/provider/index.ts.tpl +54 -0
  21. package/src/cli/templates/provider/start.ts.tpl +5 -0
  22. package/src/composite.ts +43 -0
  23. package/src/define.ts +527 -41
  24. package/src/dev.ts +2 -6
  25. package/src/errors.ts +42 -0
  26. package/src/index.ts +50 -38
  27. package/src/lint.ts +574 -0
  28. package/src/provider.ts +14 -0
  29. package/src/runtime/auth-flow.ts +67 -0
  30. package/src/runtime/credential.ts +95 -0
  31. package/src/runtime/env.ts +13 -0
  32. package/src/runtime/executor.ts +13 -14
  33. package/src/runtime/http.ts +10 -2
  34. package/src/runtime/insights.ts +3 -3
  35. package/src/runtime/key-derivation.ts +122 -0
  36. package/src/runtime/keyring.ts +148 -0
  37. package/src/runtime/namespace.ts +33 -0
  38. package/src/runtime/prevalidate.ts +252 -0
  39. package/src/runtime/tls.ts +20 -5
  40. package/src/runtime/waterfall.ts +0 -1
  41. package/src/schema.ts +77 -0
  42. package/src/serve.ts +1 -664
  43. package/src/server/index.ts +22 -0
  44. package/src/server/serve.ts +610 -0
  45. package/src/server/types.ts +78 -0
  46. package/src/stealth/profiles.ts +10 -93
  47. package/src/testing/run.ts +391 -32
  48. package/src/types.ts +364 -41
  49. package/bin/apifuse-init.ts +0 -387
  50. package/src/__tests__/auth.test.ts +0 -396
  51. package/src/__tests__/browser-auth.test.ts +0 -180
  52. package/src/__tests__/browser.test.ts +0 -632
  53. package/src/__tests__/define.test.ts +0 -225
  54. package/src/__tests__/errors.test.ts +0 -69
  55. package/src/__tests__/executor.test.ts +0 -214
  56. package/src/__tests__/http.test.ts +0 -238
  57. package/src/__tests__/insights.test.ts +0 -210
  58. package/src/__tests__/instrumentation.test.ts +0 -290
  59. package/src/__tests__/otlp.test.ts +0 -141
  60. package/src/__tests__/perf.test.ts +0 -60
  61. package/src/__tests__/providers-yaml.test.ts +0 -135
  62. package/src/__tests__/proxy.test.ts +0 -359
  63. package/src/__tests__/recipes.test.ts +0 -36
  64. package/src/__tests__/serve.test.ts +0 -233
  65. package/src/__tests__/session.test.ts +0 -231
  66. package/src/__tests__/state.test.ts +0 -100
  67. package/src/__tests__/stealth.test.ts +0 -57
  68. package/src/__tests__/testing.test.ts +0 -97
  69. package/src/__tests__/tls.test.ts +0 -345
  70. package/src/__tests__/types.test.ts +0 -142
  71. package/src/__tests__/utils.test.ts +0 -62
  72. package/src/__tests__/waterfall.test.ts +0 -270
  73. package/src/config/providers-yaml.ts +0 -370
  74. package/src/index.test.ts +0 -1
  75. package/src/protocol.ts +0 -183
  76. package/src/runtime/auth.ts +0 -245
  77. package/src/runtime/session.ts +0 -573
  78. 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;
@@ -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
- if (typeof Bun !== "undefined") {
151
+ const bunRuntime = Object.getOwnPropertyDescriptor(globalThis, "Bun")?.value;
152
+
153
+ if (bunRuntime !== undefined) {
146
154
  return { proxy };
147
155
  }
148
156
 
@@ -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
+ }