@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.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 (132) hide show
  1. package/AUTHORING.md +134 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +21 -9
  4. package/SUBMISSION.md +10 -11
  5. package/bin/apifuse-pack-check.ts +22 -0
  6. package/bin/apifuse-submit-check.ts +1747 -388
  7. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  8. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  9. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  10. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  11. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  12. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  13. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  14. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  15. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  16. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  17. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  18. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  19. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  20. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  21. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  22. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  23. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  24. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  25. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  26. package/dist/auth-turn/index.d.ts +195 -0
  27. package/dist/auth-turn/index.js +133 -0
  28. package/dist/auth.d.ts +76 -0
  29. package/dist/auth.js +436 -0
  30. package/dist/ceremonies/index.js +7 -31
  31. package/dist/cli/create.js +45 -30
  32. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  33. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  34. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  35. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  36. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  37. package/dist/cli/templates/provider/README.md.tpl +163 -0
  38. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  39. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  40. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  41. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  42. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  43. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  44. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  45. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  46. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  47. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  48. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  49. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  50. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  51. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  52. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  53. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  54. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  55. package/dist/contract.js +1 -0
  56. package/dist/define.d.ts +6 -1
  57. package/dist/define.js +140 -70
  58. package/dist/index.d.ts +3 -2
  59. package/dist/index.js +2 -1
  60. package/dist/lint.d.ts +1 -0
  61. package/dist/lint.js +27 -0
  62. package/dist/provider.d.ts +4 -2
  63. package/dist/provider.js +2 -1
  64. package/dist/runtime/auth-flow.js +2 -0
  65. package/dist/runtime/browser.js +203 -0
  66. package/dist/runtime/http.js +28 -8
  67. package/dist/runtime/stealth.d.ts +5 -2
  68. package/dist/runtime/stealth.js +149 -3
  69. package/dist/server/index.d.ts +4 -0
  70. package/dist/server/index.js +4 -0
  71. package/dist/server/self-test-input-tokens.d.ts +1 -0
  72. package/dist/server/self-test-input-tokens.js +37 -0
  73. package/dist/server/self-test-redaction.d.ts +20 -0
  74. package/dist/server/self-test-redaction.js +70 -0
  75. package/dist/server/self-test-token.d.ts +30 -0
  76. package/dist/server/self-test-token.js +50 -0
  77. package/dist/server/self-test.d.ts +98 -0
  78. package/dist/server/self-test.js +555 -0
  79. package/dist/server/serve.d.ts +6 -0
  80. package/dist/server/serve.js +33 -8
  81. package/dist/testing/run.js +5 -1
  82. package/dist/types.d.ts +150 -0
  83. package/package.json +9 -2
  84. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  85. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  86. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  87. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  88. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  89. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  90. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  91. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  92. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  93. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  94. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  95. package/src/auth-turn/fixtures/valid/form.json +14 -0
  96. package/src/auth-turn/fixtures/valid/message.json +13 -0
  97. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  98. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  99. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  100. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  101. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  102. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  103. package/src/auth-turn/index.ts +177 -0
  104. package/src/auth.ts +786 -0
  105. package/src/ceremonies/index.ts +9 -43
  106. package/src/cli/create.ts +60 -97
  107. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  108. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  109. package/src/cli/templates/provider/README.md.tpl +7 -4
  110. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  111. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  112. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  113. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  114. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  115. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  116. package/src/contract.ts +1 -0
  117. package/src/define.ts +198 -71
  118. package/src/index.ts +16 -0
  119. package/src/lint.ts +33 -0
  120. package/src/provider.ts +27 -0
  121. package/src/runtime/auth-flow.ts +2 -0
  122. package/src/runtime/browser.ts +293 -1
  123. package/src/runtime/http.ts +48 -7
  124. package/src/runtime/stealth.ts +182 -5
  125. package/src/server/index.ts +36 -0
  126. package/src/server/self-test-input-tokens.ts +46 -0
  127. package/src/server/self-test-redaction.ts +97 -0
  128. package/src/server/self-test-token.ts +70 -0
  129. package/src/server/self-test.ts +725 -0
  130. package/src/server/serve.ts +67 -4
  131. package/src/testing/run.ts +9 -1
  132. package/src/types.ts +186 -0
@@ -18,6 +18,7 @@ import type {
18
18
  HttpRetryOptions,
19
19
  StealthClient,
20
20
  StealthFetchOptions,
21
+ StealthRedirectHop,
21
22
  StealthResponse,
22
23
  StealthSession,
23
24
  } from "../types";
@@ -128,10 +129,16 @@ const FIREFOX_IMPIT_BY_MAJOR: Record<number, ImpitBrowser> = {
128
129
 
129
130
  type StealthTransportResponse = Pick<
130
131
  ImpitResponse,
131
- "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text" | "url"
132
- >;
132
+ "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"
133
+ > & {
134
+ url?: string;
135
+ redirected?: boolean;
136
+ };
133
137
 
134
138
  type StealthMethod = NonNullable<ImpitRequestInit["method"]>;
139
+ type StealthRequestInit = ImpitRequestInit & {
140
+ redirect?: NonNullable<StealthFetchOptions["redirect"]>;
141
+ };
135
142
  type NormalizedStealthRetryOptions = {
136
143
  attempts: number;
137
144
  methods: readonly string[];
@@ -177,12 +184,37 @@ class CookieJarImpl implements CookieJar {
177
184
  return { ...this.cookies };
178
185
  }
179
186
 
187
+ has(name: string): boolean {
188
+ return Object.hasOwn(this.cookies, name);
189
+ }
190
+
180
191
  toString(): string {
181
192
  return Object.entries(this.cookies)
182
193
  .map(([name, value]) => `${name}=${value}`)
183
194
  .join("; ");
184
195
  }
185
196
 
197
+ toHeader(): string {
198
+ return this.toString();
199
+ }
200
+
201
+ snapshot(): Record<string, string> {
202
+ return this.getAll();
203
+ }
204
+
205
+ restore(cookies: Record<string, string>): void {
206
+ this.clear();
207
+ for (const [name, value] of Object.entries(cookies)) {
208
+ if (name) this.cookies[name] = value;
209
+ }
210
+ }
211
+
212
+ clear(): void {
213
+ for (const name of Object.keys(this.cookies)) {
214
+ delete this.cookies[name];
215
+ }
216
+ }
217
+
186
218
  find(predicate: (cookie: string) => boolean): string | undefined {
187
219
  for (const [name, value] of Object.entries(this.cookies)) {
188
220
  const cookie = `${name}=${value}`;
@@ -335,6 +367,7 @@ function splitCombinedSetCookieHeader(headerValue: string): string[] {
335
367
 
336
368
  export async function normalizeResponse(
337
369
  response: StealthTransportResponse,
370
+ requestUrl?: string,
338
371
  ): Promise<StealthResponse> {
339
372
  const headers = Object.fromEntries(response.headers.entries());
340
373
  const cookies = new CookieJarImpl(
@@ -346,6 +379,12 @@ export async function normalizeResponse(
346
379
  return {
347
380
  status: response.status,
348
381
  ok: response.status >= 200 && response.status < 300,
382
+ ...(response.url ? { url: response.url } : {}),
383
+ ...(response.redirected !== undefined
384
+ ? { redirected: response.redirected }
385
+ : requestUrl && response.url
386
+ ? { redirected: response.url !== requestUrl }
387
+ : {}),
349
388
  headers,
350
389
  rawHeaders: headerEntriesFromHeaders(response.headers),
351
390
  body,
@@ -743,6 +782,26 @@ function normalizeMethod(method: HttpMethod | string): StealthMethod {
743
782
  }
744
783
  }
745
784
 
785
+ function isRedirectStatus(status: number): boolean {
786
+ return [301, 302, 303, 307, 308].includes(status);
787
+ }
788
+
789
+ function nextRedirectMethod(
790
+ status: number,
791
+ method: StealthMethod,
792
+ ): StealthMethod {
793
+ if (status === 303 && method !== "HEAD") return "GET";
794
+ if ((status === 301 || status === 302) && method === "POST") return "GET";
795
+ return method;
796
+ }
797
+
798
+ function locationHeader(headers: Record<string, string>): string | undefined {
799
+ for (const [name, value] of Object.entries(headers)) {
800
+ if (name.toLowerCase() === "location") return value;
801
+ }
802
+ return undefined;
803
+ }
804
+
746
805
  function createSessionFetcher(
747
806
  baseUrl: string,
748
807
  defaultProfile: string,
@@ -811,7 +870,7 @@ function createSessionFetcher(
811
870
  };
812
871
  }
813
872
 
814
- return {
873
+ const session: StealthSession = {
815
874
  async fetch(url, options: StealthFetchOptions = {}) {
816
875
  const method = normalizeMethod(options.method ?? "GET");
817
876
  const hasExplicitRetryPolicy = options.retry !== undefined;
@@ -896,9 +955,10 @@ function createSessionFetcher(
896
955
  const cookieHeader = cookieJar.toString();
897
956
  if (cookieHeader) headers.Cookie = cookieHeader;
898
957
  }
899
- const requestInit: ImpitRequestInit = {
958
+ const requestInit: StealthRequestInit = {
900
959
  headers: normalizeHeaders(headers),
901
960
  method,
961
+ ...(options.redirect ? { redirect: options.redirect } : {}),
902
962
  ...(options.timeout ? { timeout: options.timeout } : {}),
903
963
  };
904
964
  if (options.body !== undefined) {
@@ -909,7 +969,7 @@ function createSessionFetcher(
909
969
  proxy,
910
970
  ignoreTlsErrors,
911
971
  ).fetch(requestUrl, requestInit);
912
- const normalized = await normalizeResponse(response);
972
+ const normalized = await normalizeResponse(response, requestUrl);
913
973
  cookieJar.setFromCookieStrings(
914
974
  setCookieHeadersFromResponse(response.headers),
915
975
  );
@@ -1052,11 +1112,128 @@ function createSessionFetcher(
1052
1112
 
1053
1113
  throw normalizeStealthTransportError(lastError);
1054
1114
  },
1115
+ cookies: cookieJar,
1116
+ redirects: {
1117
+ async run(options) {
1118
+ const maxHops =
1119
+ options.maxHops === undefined || !Number.isFinite(options.maxHops)
1120
+ ? 10
1121
+ : Math.max(0, Math.floor(options.maxHops));
1122
+ const hops: StealthRedirectHop[] = [];
1123
+ let currentUrl = resolveUrl(baseUrl, options.url);
1124
+ let method = normalizeMethod(options.method ?? "GET");
1125
+ let body = options.body;
1126
+ let response: StealthResponse | undefined;
1127
+ const visitedRequests = new Set<string>();
1128
+
1129
+ const {
1130
+ url: _url,
1131
+ maxHops: _maxHops,
1132
+ stopWhen,
1133
+ params,
1134
+ ...fetchOptions
1135
+ } = options;
1136
+
1137
+ for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
1138
+ visitedRequests.add(`${method} ${currentUrl}`);
1139
+ response = await session.fetch(currentUrl, {
1140
+ ...fetchOptions,
1141
+ body,
1142
+ method,
1143
+ ...(hopIndex === 0 && params ? { params } : {}),
1144
+ redirect: "manual",
1145
+ throwOnHttpError: false,
1146
+ });
1147
+
1148
+ if (!isRedirectStatus(response.status)) {
1149
+ return {
1150
+ final: response,
1151
+ hops,
1152
+ reason: "completed",
1153
+ cookies: cookieJar.snapshot(),
1154
+ };
1155
+ }
1156
+
1157
+ const location = locationHeader(response.headers);
1158
+ const nextUrl = location
1159
+ ? new URL(location, response.url ?? currentUrl).toString()
1160
+ : undefined;
1161
+ const hop: StealthRedirectHop = {
1162
+ url: response.url ?? currentUrl,
1163
+ status: response.status,
1164
+ method,
1165
+ ...(location ? { location } : {}),
1166
+ ...(nextUrl ? { nextUrl } : {}),
1167
+ };
1168
+ hops.push(hop);
1169
+
1170
+ if (stopWhen && (await stopWhen(hop))) {
1171
+ return {
1172
+ final: response,
1173
+ hops,
1174
+ reason: "stopped",
1175
+ cookies: cookieJar.snapshot(),
1176
+ };
1177
+ }
1178
+
1179
+ if (!nextUrl) {
1180
+ return {
1181
+ final: response,
1182
+ hops,
1183
+ reason: "missing_location",
1184
+ cookies: cookieJar.snapshot(),
1185
+ };
1186
+ }
1187
+
1188
+ if (hops.length > maxHops) {
1189
+ return {
1190
+ final: response,
1191
+ hops,
1192
+ reason: "max_hops",
1193
+ cookies: cookieJar.snapshot(),
1194
+ };
1195
+ }
1196
+
1197
+ const nextMethod = nextRedirectMethod(response.status, method);
1198
+ if (nextMethod !== method) {
1199
+ body = undefined;
1200
+ }
1201
+ if (visitedRequests.has(`${nextMethod} ${nextUrl}`)) {
1202
+ return {
1203
+ final: response,
1204
+ hops,
1205
+ reason: "loop",
1206
+ cookies: cookieJar.snapshot(),
1207
+ };
1208
+ }
1209
+ method = nextMethod;
1210
+ currentUrl = nextUrl;
1211
+ }
1212
+
1213
+ if (!response) {
1214
+ response = await session.fetch(currentUrl, {
1215
+ ...fetchOptions,
1216
+ body,
1217
+ method,
1218
+ ...(params ? { params } : {}),
1219
+ redirect: "manual",
1220
+ throwOnHttpError: false,
1221
+ });
1222
+ }
1223
+ return {
1224
+ final: response,
1225
+ hops,
1226
+ reason: "max_hops",
1227
+ cookies: cookieJar.snapshot(),
1228
+ };
1229
+ },
1230
+ },
1055
1231
  close() {
1056
1232
  closed = true;
1057
1233
  clients.clear();
1058
1234
  },
1059
1235
  };
1236
+ return session;
1060
1237
 
1061
1238
  async function classifyProxyAuthDiagnostic(
1062
1239
  profileName: string,
@@ -1,4 +1,40 @@
1
1
  export { createServerApp, type ServeOptions, serve } from "./serve";
2
+ export {
3
+ computeSelfTestPlanDigest,
4
+ createSelfTestApp,
5
+ createSelfTestInvoke,
6
+ DEFAULT_SELF_TEST_REQUEST_BUDGET_MS,
7
+ isSelfTestReadOnlyOperation,
8
+ PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV,
9
+ resolveSelfTestPort,
10
+ SELF_TEST_HEALTHZ_PATH,
11
+ SELF_TEST_PATH,
12
+ SELF_TEST_SCHEMA_VERSION,
13
+ type SelfTestAppOptions,
14
+ type SelfTestCaseResult,
15
+ type SelfTestCaseStatus,
16
+ type SelfTestOperationInvoke,
17
+ type SelfTestRequest,
18
+ SelfTestRequestSchema,
19
+ type SelfTestResponse,
20
+ } from "./self-test";
21
+ export { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens";
22
+ export {
23
+ collectSelfTestSensitiveValues,
24
+ redactSelfTestText,
25
+ SELF_TEST_MAX_TEXT_LENGTH,
26
+ SELF_TEST_REDACTED_PLACEHOLDER,
27
+ } from "./self-test-redaction";
28
+ export {
29
+ DEFAULT_SELF_TEST_PORT,
30
+ deriveSelfTestToken,
31
+ PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV,
32
+ PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV,
33
+ PROVIDER_RUNTIME_SELF_TEST_PORT_ENV,
34
+ resolveSelfTestMasterSecrets,
35
+ type SelfTestMasterSecrets,
36
+ verifySelfTestAuthorization,
37
+ } from "./self-test-token";
2
38
  export type {
3
39
  AuthFlowRequest,
4
40
  AuthFlowResponse,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Relative-KST date token resolution for health-check case inputs, ported from
3
+ * the monorepo health-monitor (`apps/health-monitor/src/lib/health-check-input.ts`)
4
+ * so provider self-tests resolve durable probe inputs identically.
5
+ *
6
+ * Supported token: `+<days>d` or `+<days>d:YYYYMMDD` (1..365 days ahead, KST).
7
+ */
8
+ const RELATIVE_KST_DATE_TOKEN = /^\+(\d{1,3})d(?::(YYYYMMDD))?$/i;
9
+
10
+ function dateFromKstDaysAhead(
11
+ daysAhead: number,
12
+ now = new Date(),
13
+ format: "YYYY-MM-DD" | "YYYYMMDD" = "YYYY-MM-DD",
14
+ ): string {
15
+ const kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000);
16
+ const date = new Date(
17
+ Date.UTC(kstNow.getUTCFullYear(), kstNow.getUTCMonth(), kstNow.getUTCDate() + daysAhead),
18
+ );
19
+ const isoDate = date.toISOString().slice(0, 10);
20
+ return format === "YYYYMMDD" ? isoDate.replace(/-/g, "") : isoDate;
21
+ }
22
+
23
+ export function resolveHealthCheckInputDateTokens(value: unknown, now = new Date()): unknown {
24
+ if (typeof value === "string") {
25
+ const relative = value.match(RELATIVE_KST_DATE_TOKEN);
26
+ if (!relative) return value;
27
+ const daysAhead = Number(relative[1]);
28
+ if (!Number.isInteger(daysAhead) || daysAhead < 1 || daysAhead > 365) {
29
+ return value;
30
+ }
31
+ const format = relative[2]?.toUpperCase() === "YYYYMMDD" ? "YYYYMMDD" : "YYYY-MM-DD";
32
+ return dateFromKstDaysAhead(daysAhead, now, format);
33
+ }
34
+ if (Array.isArray(value)) {
35
+ return value.map((entry) => resolveHealthCheckInputDateTokens(entry, now));
36
+ }
37
+ if (value && typeof value === "object") {
38
+ return Object.fromEntries(
39
+ Object.entries(value).map(([key, entry]) => [
40
+ key,
41
+ resolveHealthCheckInputDateTokens(entry, now),
42
+ ]),
43
+ );
44
+ }
45
+ return value;
46
+ }
@@ -0,0 +1,97 @@
1
+ import type { ProviderDefinition } from "../types";
2
+ import {
3
+ PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV,
4
+ PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV,
5
+ } from "./self-test-token";
6
+
7
+ export const SELF_TEST_REDACTED_PLACEHOLDER = "[REDACTED]";
8
+
9
+ /** Maximum length of any string echoed in a self-test response. */
10
+ export const SELF_TEST_MAX_TEXT_LENGTH = 300;
11
+
12
+ /** Values shorter than this are too generic to be treated as secrets. */
13
+ const MIN_SENSITIVE_VALUE_LENGTH = 4;
14
+
15
+ const HEADER_SHAPED_SECRETS: readonly RegExp[] = [
16
+ /(authorization\s*[:=]\s*)[^\s;,]+/gi,
17
+ /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
18
+ /((?:set-)?cookie\s*[:=]\s*)[^;\n]+/gi,
19
+ ];
20
+
21
+ /**
22
+ * Collects every secret-shaped value known to the self-test runtime so probe
23
+ * results can be scrubbed before they leave the pod: master secrets, declared
24
+ * provider env secrets, health-probe credential env values, and request-supplied
25
+ * credential inputs.
26
+ */
27
+ export function collectSelfTestSensitiveValues(
28
+ provider: ProviderDefinition,
29
+ options: {
30
+ env?: Readonly<Record<string, string | undefined>>;
31
+ credentialInputs?: Readonly<Record<string, string>>;
32
+ } = {},
33
+ ): string[] {
34
+ const env = options.env ?? process.env;
35
+ const envNames = new Set<string>([
36
+ PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV,
37
+ PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV,
38
+ ]);
39
+ for (const secret of provider.secrets ?? []) {
40
+ envNames.add(secret.name);
41
+ }
42
+ const healthProbe = provider.healthProbe ?? provider.healthMonitor;
43
+ for (const name of healthProbe?.requiredSecrets ?? []) {
44
+ envNames.add(name);
45
+ }
46
+ for (const name of Object.values(healthProbe?.credentialInputs ?? {})) {
47
+ envNames.add(name);
48
+ }
49
+
50
+ const values = new Set<string>();
51
+ for (const name of envNames) {
52
+ const value = env[name];
53
+ if (typeof value === "string" && value.length >= MIN_SENSITIVE_VALUE_LENGTH) {
54
+ values.add(value);
55
+ }
56
+ }
57
+ for (const value of Object.values(options.credentialInputs ?? {})) {
58
+ if (value.length >= MIN_SENSITIVE_VALUE_LENGTH) {
59
+ values.add(value);
60
+ }
61
+ }
62
+ return [...values];
63
+ }
64
+
65
+ function escapeRegExp(value: string): string {
66
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ }
68
+
69
+ /**
70
+ * Scrubs known secret values and header-shaped credentials from a string and
71
+ * caps its length. Every string embedded in a SelfTestResponse MUST pass
72
+ * through this before serialization.
73
+ */
74
+ export function redactSelfTestText(
75
+ text: string,
76
+ sensitiveValues: readonly string[],
77
+ maxLength: number = SELF_TEST_MAX_TEXT_LENGTH,
78
+ ): string {
79
+ let redacted = text;
80
+ for (const value of sensitiveValues) {
81
+ if (value.length < MIN_SENSITIVE_VALUE_LENGTH) continue;
82
+ redacted = redacted.replace(
83
+ new RegExp(escapeRegExp(value), "g"),
84
+ SELF_TEST_REDACTED_PLACEHOLDER,
85
+ );
86
+ }
87
+ for (const pattern of HEADER_SHAPED_SECRETS) {
88
+ redacted = redacted.replace(
89
+ pattern,
90
+ (_match, prefix: string) => `${prefix}${SELF_TEST_REDACTED_PLACEHOLDER}`,
91
+ );
92
+ }
93
+ if (redacted.length > maxLength) {
94
+ redacted = `${redacted.slice(0, maxLength)}… [truncated]`;
95
+ }
96
+ return redacted;
97
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Shared master secret injected into every provider pod. The per-provider
5
+ * bearer token is derived from it, so compromise of one pod's env cannot
6
+ * unlock another provider's self-test endpoint.
7
+ */
8
+ export const PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV =
9
+ "APIFUSE__PROVIDER_RUNTIME__SELF_TEST_MASTER_SECRET";
10
+
11
+ /**
12
+ * Previous master secret accepted during a rotation window so fleet-wide
13
+ * rotation does not need to be atomic.
14
+ */
15
+ export const PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV =
16
+ "APIFUSE__PROVIDER_RUNTIME__SELF_TEST_MASTER_SECRET_PREVIOUS";
17
+
18
+ /** Internal self-test listener port (never the tenant-facing serve() port). */
19
+ export const PROVIDER_RUNTIME_SELF_TEST_PORT_ENV = "APIFUSE__PROVIDER_RUNTIME__SELF_TEST_PORT";
20
+
21
+ export const DEFAULT_SELF_TEST_PORT = 3001;
22
+
23
+ export interface SelfTestMasterSecrets {
24
+ readonly current: string;
25
+ readonly previous?: string;
26
+ }
27
+
28
+ /**
29
+ * Per-provider self-test bearer token: HMAC-SHA256(masterSecret, providerId),
30
+ * hex-encoded. Computed identically by the provider pod and the scheduler.
31
+ */
32
+ export function deriveSelfTestToken(masterSecret: string, providerId: string): string {
33
+ return createHmac("sha256", masterSecret).update(providerId).digest("hex");
34
+ }
35
+
36
+ export function resolveSelfTestMasterSecrets(
37
+ env: Readonly<Record<string, string | undefined>> = process.env,
38
+ ): SelfTestMasterSecrets | undefined {
39
+ const current = env[PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV]?.trim();
40
+ if (!current) return undefined;
41
+ const previous = env[PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV]?.trim();
42
+ return previous ? { current, previous } : { current };
43
+ }
44
+
45
+ function digestsMatch(presented: string, expected: string): boolean {
46
+ // Hash both sides before comparison so timingSafeEqual sees equal-length
47
+ // buffers regardless of what the caller presented (constant-time path).
48
+ const presentedDigest = createHash("sha256").update(presented).digest();
49
+ const expectedDigest = createHash("sha256").update(expected).digest();
50
+ return timingSafeEqual(presentedDigest, expectedDigest);
51
+ }
52
+
53
+ /**
54
+ * Verifies an Authorization header against the current-or-previous derived
55
+ * token (dual acceptance for one rotation window). Constant-time comparison;
56
+ * returns only a boolean so callers cannot leak which check failed.
57
+ */
58
+ export function verifySelfTestAuthorization(
59
+ authorizationHeader: string | undefined,
60
+ providerId: string,
61
+ secrets: SelfTestMasterSecrets,
62
+ ): boolean {
63
+ const match = /^Bearer\s+(\S+)$/i.exec(authorizationHeader ?? "");
64
+ const presented = match?.[1] ?? "";
65
+ const currentOk = digestsMatch(presented, deriveSelfTestToken(secrets.current, providerId));
66
+ const previousOk = secrets.previous
67
+ ? digestsMatch(presented, deriveSelfTestToken(secrets.previous, providerId))
68
+ : false;
69
+ return currentOk || previousOk;
70
+ }