@apifuse/provider-sdk 2.1.0-beta.8 → 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.
- package/AUTHORING.md +134 -0
- package/CHANGELOG.md +61 -0
- package/README.md +21 -9
- package/SUBMISSION.md +10 -11
- package/bin/apifuse-pack-check.ts +22 -0
- package/bin/apifuse-perf.ts +18 -9
- package/bin/apifuse-submit-check.ts +1747 -388
- package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
- package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
- package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
- package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
- package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
- package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
- package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
- package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
- package/dist/auth-turn/fixtures/valid/abort.json +8 -0
- package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
- package/dist/auth-turn/fixtures/valid/complete.json +13 -0
- package/dist/auth-turn/fixtures/valid/form.json +14 -0
- package/dist/auth-turn/fixtures/valid/message.json +13 -0
- package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
- package/dist/auth-turn/fixtures/valid/pending.json +5 -0
- package/dist/auth-turn/fixtures/valid/poll.json +9 -0
- package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
- package/dist/auth-turn/fixtures/valid/retry.json +8 -0
- package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
- package/dist/auth-turn/index.d.ts +195 -0
- package/dist/auth-turn/index.js +133 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +436 -0
- package/dist/ceremonies/index.js +7 -31
- package/dist/cli/create.js +45 -30
- package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
- package/dist/cli/templates/provider/.gitignore.tpl +22 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
- package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
- package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
- package/dist/cli/templates/provider/README.md.tpl +163 -0
- package/dist/cli/templates/provider/dev.ts.tpl +5 -0
- package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
- package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
- package/dist/cli/templates/provider/index.ts.tpl +15 -0
- package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
- package/dist/cli/templates/provider/meta.ts.tpl +7 -0
- package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
- package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
- package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
- package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
- package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
- package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
- package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
- package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
- package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
- package/dist/cli/templates/provider/start.ts.tpl +5 -0
- package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
- package/dist/contract.js +1 -0
- package/dist/define.d.ts +6 -1
- package/dist/define.js +140 -70
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lint.d.ts +1 -0
- package/dist/lint.js +27 -0
- package/dist/provider.d.ts +4 -2
- package/dist/provider.js +2 -1
- package/dist/runtime/auth-flow.js +2 -0
- package/dist/runtime/browser.js +203 -0
- package/dist/runtime/http.js +28 -8
- package/dist/runtime/stealth.d.ts +5 -2
- package/dist/runtime/stealth.js +157 -4
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/self-test-input-tokens.d.ts +1 -0
- package/dist/server/self-test-input-tokens.js +37 -0
- package/dist/server/self-test-redaction.d.ts +20 -0
- package/dist/server/self-test-redaction.js +70 -0
- package/dist/server/self-test-token.d.ts +30 -0
- package/dist/server/self-test-token.js +50 -0
- package/dist/server/self-test.d.ts +98 -0
- package/dist/server/self-test.js +555 -0
- package/dist/server/serve.d.ts +6 -0
- package/dist/server/serve.js +33 -8
- package/dist/testing/run.js +5 -1
- package/dist/types.d.ts +152 -0
- package/package.json +10 -3
- package/src/auth-turn/auth-turn.v1.schema.json +89 -0
- package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
- package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
- package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
- package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
- package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
- package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
- package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
- package/src/auth-turn/fixtures/valid/abort.json +8 -0
- package/src/auth-turn/fixtures/valid/challenge.json +17 -0
- package/src/auth-turn/fixtures/valid/complete.json +13 -0
- package/src/auth-turn/fixtures/valid/form.json +14 -0
- package/src/auth-turn/fixtures/valid/message.json +13 -0
- package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
- package/src/auth-turn/fixtures/valid/pending.json +5 -0
- package/src/auth-turn/fixtures/valid/poll.json +9 -0
- package/src/auth-turn/fixtures/valid/redirect.json +16 -0
- package/src/auth-turn/fixtures/valid/retry.json +8 -0
- package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
- package/src/auth-turn/index.ts +177 -0
- package/src/auth.ts +786 -0
- package/src/ceremonies/index.ts +9 -43
- package/src/cli/create.ts +60 -97
- package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
- package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
- package/src/cli/templates/provider/README.md.tpl +7 -4
- package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
- package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
- package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
- package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
- package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
- package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
- package/src/contract.ts +1 -0
- package/src/define.ts +198 -71
- package/src/index.ts +16 -0
- package/src/lint.ts +33 -0
- package/src/provider.ts +27 -0
- package/src/runtime/auth-flow.ts +2 -0
- package/src/runtime/browser.ts +293 -1
- package/src/runtime/http.ts +48 -7
- package/src/runtime/stealth.ts +190 -6
- package/src/server/index.ts +36 -0
- package/src/server/self-test-input-tokens.ts +46 -0
- package/src/server/self-test-redaction.ts +97 -0
- package/src/server/self-test-token.ts +70 -0
- package/src/server/self-test.ts +725 -0
- package/src/server/serve.ts +67 -4
- package/src/testing/run.ts +9 -1
- package/src/types.ts +188 -0
package/src/runtime/stealth.ts
CHANGED
|
@@ -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"
|
|
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,16 +367,24 @@ 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(
|
|
341
374
|
setCookieHeadersFromResponse(response.headers),
|
|
342
375
|
);
|
|
343
|
-
const
|
|
376
|
+
const bodyBytes = await response.arrayBuffer();
|
|
377
|
+
const body = new TextDecoder().decode(bodyBytes);
|
|
344
378
|
|
|
345
379
|
return {
|
|
346
380
|
status: response.status,
|
|
347
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
|
+
: {}),
|
|
348
388
|
headers,
|
|
349
389
|
rawHeaders: headerEntriesFromHeaders(response.headers),
|
|
350
390
|
body,
|
|
@@ -352,6 +392,12 @@ export async function normalizeResponse(
|
|
|
352
392
|
json<T>(): Promise<T> {
|
|
353
393
|
return Promise.resolve(JSON.parse(body));
|
|
354
394
|
},
|
|
395
|
+
arrayBuffer(): Promise<ArrayBuffer> {
|
|
396
|
+
return Promise.resolve(bodyBytes.slice(0));
|
|
397
|
+
},
|
|
398
|
+
bytes(): Promise<Uint8Array> {
|
|
399
|
+
return Promise.resolve(new Uint8Array(bodyBytes.slice(0)));
|
|
400
|
+
},
|
|
355
401
|
};
|
|
356
402
|
}
|
|
357
403
|
|
|
@@ -736,6 +782,26 @@ function normalizeMethod(method: HttpMethod | string): StealthMethod {
|
|
|
736
782
|
}
|
|
737
783
|
}
|
|
738
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
|
+
|
|
739
805
|
function createSessionFetcher(
|
|
740
806
|
baseUrl: string,
|
|
741
807
|
defaultProfile: string,
|
|
@@ -804,7 +870,7 @@ function createSessionFetcher(
|
|
|
804
870
|
};
|
|
805
871
|
}
|
|
806
872
|
|
|
807
|
-
|
|
873
|
+
const session: StealthSession = {
|
|
808
874
|
async fetch(url, options: StealthFetchOptions = {}) {
|
|
809
875
|
const method = normalizeMethod(options.method ?? "GET");
|
|
810
876
|
const hasExplicitRetryPolicy = options.retry !== undefined;
|
|
@@ -889,9 +955,10 @@ function createSessionFetcher(
|
|
|
889
955
|
const cookieHeader = cookieJar.toString();
|
|
890
956
|
if (cookieHeader) headers.Cookie = cookieHeader;
|
|
891
957
|
}
|
|
892
|
-
const requestInit:
|
|
958
|
+
const requestInit: StealthRequestInit = {
|
|
893
959
|
headers: normalizeHeaders(headers),
|
|
894
960
|
method,
|
|
961
|
+
...(options.redirect ? { redirect: options.redirect } : {}),
|
|
895
962
|
...(options.timeout ? { timeout: options.timeout } : {}),
|
|
896
963
|
};
|
|
897
964
|
if (options.body !== undefined) {
|
|
@@ -902,7 +969,7 @@ function createSessionFetcher(
|
|
|
902
969
|
proxy,
|
|
903
970
|
ignoreTlsErrors,
|
|
904
971
|
).fetch(requestUrl, requestInit);
|
|
905
|
-
const normalized = await normalizeResponse(response);
|
|
972
|
+
const normalized = await normalizeResponse(response, requestUrl);
|
|
906
973
|
cookieJar.setFromCookieStrings(
|
|
907
974
|
setCookieHeadersFromResponse(response.headers),
|
|
908
975
|
);
|
|
@@ -1045,11 +1112,128 @@ function createSessionFetcher(
|
|
|
1045
1112
|
|
|
1046
1113
|
throw normalizeStealthTransportError(lastError);
|
|
1047
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
|
+
},
|
|
1048
1231
|
close() {
|
|
1049
1232
|
closed = true;
|
|
1050
1233
|
clients.clear();
|
|
1051
1234
|
},
|
|
1052
1235
|
};
|
|
1236
|
+
return session;
|
|
1053
1237
|
|
|
1054
1238
|
async function classifyProxyAuthDiagnostic(
|
|
1055
1239
|
profileName: string,
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|