@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.0-beta.2
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-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 +46 -336
- package/dist/runtime/proxy-retry-policy.d.ts +40 -0
- package/dist/runtime/proxy-retry-policy.js +326 -0
- package/dist/runtime/stealth.d.ts +5 -2
- package/dist/runtime/stealth.js +206 -206
- 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 +38 -10
- package/dist/server/types.d.ts +1 -0
- package/dist/server/types.js +1 -0
- package/dist/testing/run.js +5 -1
- package/dist/types.d.ts +150 -0
- package/package.json +9 -2
- 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 +84 -530
- package/src/runtime/proxy-retry-policy.ts +469 -0
- package/src/runtime/stealth.ts +269 -353
- 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 +75 -6
- package/src/server/types.ts +1 -0
- package/src/testing/run.ts +9 -1
- package/src/types.ts +186 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens";
|
|
6
|
+
import { collectSelfTestSensitiveValues, redactSelfTestText } from "./self-test-redaction";
|
|
7
|
+
import { DEFAULT_SELF_TEST_PORT, PROVIDER_RUNTIME_SELF_TEST_PORT_ENV, verifySelfTestAuthorization, } from "./self-test-token";
|
|
8
|
+
export const SELF_TEST_SCHEMA_VERSION = 1;
|
|
9
|
+
export const SELF_TEST_PATH = "/internal/health/self-test";
|
|
10
|
+
export const SELF_TEST_HEALTHZ_PATH = "/healthz";
|
|
11
|
+
/** Overall wall-clock budget for one self-test request (all selected cases). */
|
|
12
|
+
export const PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV = "APIFUSE__PROVIDER_RUNTIME__SELF_TEST_REQUEST_BUDGET_MS";
|
|
13
|
+
export const DEFAULT_SELF_TEST_REQUEST_BUDGET_MS = 120_000;
|
|
14
|
+
const DEFAULT_CASE_TIMEOUT_MS = 30_000;
|
|
15
|
+
const SELF_TEST_BUSY_RETRY_AFTER_MS = 1_000;
|
|
16
|
+
export const SelfTestRequestSchema = z.object({
|
|
17
|
+
schemaVersion: z.number().int(),
|
|
18
|
+
requestId: z.string().min(1),
|
|
19
|
+
/** Single-case form (one case per request): both fields required together. */
|
|
20
|
+
operationId: z.string().min(1).optional(),
|
|
21
|
+
caseName: z.string().min(1).optional(),
|
|
22
|
+
/** Batch form: optional selectors; omitted = every declared case. */
|
|
23
|
+
operations: z.array(z.string().min(1)).optional(),
|
|
24
|
+
caseNames: z.array(z.string().min(1)).optional(),
|
|
25
|
+
/** Per-case timeout override; case/suite/provider defaults apply otherwise. */
|
|
26
|
+
timeoutMs: z.number().int().min(1).max(600_000).optional(),
|
|
27
|
+
/** Credential material for requiresConnection cases; never persisted. */
|
|
28
|
+
credentials: z.object({ inputs: z.record(z.string(), z.string()) }).optional(),
|
|
29
|
+
});
|
|
30
|
+
function resolveSdkVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const packageJsonUrl = new URL("../../package.json", import.meta.url);
|
|
33
|
+
const parsed = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
|
|
34
|
+
if (parsed &&
|
|
35
|
+
typeof parsed === "object" &&
|
|
36
|
+
"version" in parsed &&
|
|
37
|
+
typeof parsed.version === "string") {
|
|
38
|
+
return parsed.version;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// fall through to unknown
|
|
43
|
+
}
|
|
44
|
+
return "unknown";
|
|
45
|
+
}
|
|
46
|
+
const SDK_VERSION = resolveSdkVersion();
|
|
47
|
+
function healthCheckSuite(operation) {
|
|
48
|
+
return operation.healthCheck;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stable sha256 over the provider's declared health plan (operations, case
|
|
52
|
+
* names, timeouts) so the scheduler can detect plan/image skew by comparing
|
|
53
|
+
* the manifest planDigest against the pod-reported one.
|
|
54
|
+
*/
|
|
55
|
+
export function computeSelfTestPlanDigest(provider) {
|
|
56
|
+
const plan = Object.keys(provider.operations)
|
|
57
|
+
.sort()
|
|
58
|
+
.flatMap((operationId) => {
|
|
59
|
+
const operation = provider.operations[operationId];
|
|
60
|
+
const suite = operation ? healthCheckSuite(operation) : undefined;
|
|
61
|
+
if (!suite)
|
|
62
|
+
return [];
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
operationId,
|
|
66
|
+
interval: suite.interval,
|
|
67
|
+
timeoutMs: suite.timeoutMs ?? null,
|
|
68
|
+
requiresConnection: suite.requiresConnection ?? false,
|
|
69
|
+
cases: suite.cases.map((healthCase) => ({
|
|
70
|
+
name: healthCase.name,
|
|
71
|
+
timeoutMs: healthCase.timeoutMs ?? null,
|
|
72
|
+
degradedThresholdMs: healthCase.degradedThresholdMs ?? null,
|
|
73
|
+
expectedStatus: healthCase.expectedStatus ?? "ok",
|
|
74
|
+
})),
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
});
|
|
78
|
+
const canonical = JSON.stringify({
|
|
79
|
+
schemaVersion: SELF_TEST_SCHEMA_VERSION,
|
|
80
|
+
providerId: provider.id,
|
|
81
|
+
plan,
|
|
82
|
+
});
|
|
83
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Fail-closed read-only classification: an operation may self-test only when
|
|
87
|
+
* its own metadata marks it read-only (annotations.readOnly or
|
|
88
|
+
* toolRouter.riskClass "read") and nothing marks it side-effecting. Unclassified
|
|
89
|
+
* operations are refused so the endpoint cannot become a mutation oracle.
|
|
90
|
+
*/
|
|
91
|
+
export function isSelfTestReadOnlyOperation(operation) {
|
|
92
|
+
const annotations = operation.annotations;
|
|
93
|
+
if (annotations?.readOnly === false)
|
|
94
|
+
return false;
|
|
95
|
+
if (annotations?.destructive === true)
|
|
96
|
+
return false;
|
|
97
|
+
const riskClass = operation.toolRouter?.riskClass;
|
|
98
|
+
if (riskClass !== undefined && riskClass !== "read")
|
|
99
|
+
return false;
|
|
100
|
+
return annotations?.readOnly === true || riskClass === "read";
|
|
101
|
+
}
|
|
102
|
+
/** Binds the self-test executor to a tenant app's /v1 pipeline in-process. */
|
|
103
|
+
export function createSelfTestInvoke(app) {
|
|
104
|
+
return async ({ operationId, input, connection, requestId }) => {
|
|
105
|
+
const response = await app.request(`/v1/${encodeURIComponent(operationId)}`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
requestId,
|
|
110
|
+
input: input ?? {},
|
|
111
|
+
...(connection ? { connection } : {}),
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
const text = await response.text();
|
|
115
|
+
let body = text;
|
|
116
|
+
try {
|
|
117
|
+
body = text.length > 0 ? JSON.parse(text) : undefined;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// non-JSON transports keep the raw text as data
|
|
121
|
+
}
|
|
122
|
+
if (response.ok && body && typeof body === "object" && !Array.isArray(body) && "data" in body) {
|
|
123
|
+
const envelope = body;
|
|
124
|
+
return {
|
|
125
|
+
status: response.status,
|
|
126
|
+
data: envelope.data,
|
|
127
|
+
...(envelope.meta ? { meta: envelope.meta } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { status: response.status, data: body };
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
class SelfTestCaseTimeoutError extends Error {
|
|
134
|
+
constructor(timeoutMs) {
|
|
135
|
+
super(`Self-test case timed out after ${timeoutMs}ms`);
|
|
136
|
+
this.name = "SelfTestCaseTimeoutError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function withCaseTimeout(run, timeoutMs) {
|
|
140
|
+
let timer;
|
|
141
|
+
try {
|
|
142
|
+
return await Promise.race([
|
|
143
|
+
run(),
|
|
144
|
+
new Promise((_, reject) => {
|
|
145
|
+
timer = setTimeout(() => reject(new SelfTestCaseTimeoutError(timeoutMs)), timeoutMs);
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (timer !== undefined)
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function objectProperty(value, key) {
|
|
155
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
159
|
+
if (entryKey === key)
|
|
160
|
+
return entryValue;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
function upstreamErrorCode(body) {
|
|
165
|
+
const error = objectProperty(body, "error");
|
|
166
|
+
const code = objectProperty(error, "code");
|
|
167
|
+
return typeof code === "string" ? code : undefined;
|
|
168
|
+
}
|
|
169
|
+
function upstreamErrorMessage(body) {
|
|
170
|
+
const error = objectProperty(body, "error");
|
|
171
|
+
const message = objectProperty(error, "message");
|
|
172
|
+
return typeof message === "string" ? message : undefined;
|
|
173
|
+
}
|
|
174
|
+
function resolveCaseTimeoutMs(execution, suite, healthCase) {
|
|
175
|
+
const providerDefault = (execution.provider.healthProbe ?? execution.provider.healthMonitor)
|
|
176
|
+
?.defaultProbeTimeoutMs;
|
|
177
|
+
return (execution.requestTimeoutMs ??
|
|
178
|
+
healthCase.timeoutMs ??
|
|
179
|
+
suite.timeoutMs ??
|
|
180
|
+
providerDefault ??
|
|
181
|
+
DEFAULT_CASE_TIMEOUT_MS);
|
|
182
|
+
}
|
|
183
|
+
function buildSelfTestConnection(execution, operationId, suite) {
|
|
184
|
+
if (!suite.requiresConnection)
|
|
185
|
+
return {};
|
|
186
|
+
const inputs = execution.credentials ?? {};
|
|
187
|
+
const declaredFields = Object.keys((execution.provider.healthProbe ?? execution.provider.healthMonitor)?.credentialInputs ?? {});
|
|
188
|
+
for (const field of declaredFields) {
|
|
189
|
+
if (!inputs[field]) {
|
|
190
|
+
return { skipReason: `credential_missing:${field}` };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (declaredFields.length === 0 && Object.keys(inputs).length === 0) {
|
|
194
|
+
return { skipReason: "credential_missing:credentials" };
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
connection: {
|
|
198
|
+
id: `self-test-${execution.requestId}`,
|
|
199
|
+
mode: "credentials",
|
|
200
|
+
secrets: { ...inputs },
|
|
201
|
+
metadata: { purpose: "provider-self-test", operationId },
|
|
202
|
+
externalRef: `${execution.provider.id}-${operationId}-self-test`,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function executeSelfTestCase(execution, operationId, suite, healthCase) {
|
|
207
|
+
const { provider, invoke, sensitiveValues } = execution;
|
|
208
|
+
const redact = (text) => redactSelfTestText(text, sensitiveValues);
|
|
209
|
+
const startedAt = new Date().toISOString();
|
|
210
|
+
const startedAtMs = performance.now();
|
|
211
|
+
const finish = (partial) => ({
|
|
212
|
+
operationId,
|
|
213
|
+
caseName: healthCase.name,
|
|
214
|
+
startedAt,
|
|
215
|
+
finishedAt: new Date().toISOString(),
|
|
216
|
+
responseTimeMs: partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
|
|
217
|
+
...partial,
|
|
218
|
+
});
|
|
219
|
+
const defaultLabel = redact(healthCase.description ?? healthCase.name);
|
|
220
|
+
if (healthCase.enabled && healthCase.enabled() === false) {
|
|
221
|
+
return finish({
|
|
222
|
+
status: "skipped",
|
|
223
|
+
label: defaultLabel,
|
|
224
|
+
skipReason: "disabled",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const connectionResolution = buildSelfTestConnection(execution, operationId, suite);
|
|
228
|
+
if ("skipReason" in connectionResolution) {
|
|
229
|
+
return finish({
|
|
230
|
+
status: "skipped",
|
|
231
|
+
label: defaultLabel,
|
|
232
|
+
skipReason: connectionResolution.skipReason,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const connection = connectionResolution.connection;
|
|
236
|
+
const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
|
|
237
|
+
try {
|
|
238
|
+
return await withCaseTimeout(async () => {
|
|
239
|
+
const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
|
|
240
|
+
const preparedInput = healthCase.prepareInput
|
|
241
|
+
? await healthCase.prepareInput({
|
|
242
|
+
providerId: provider.id,
|
|
243
|
+
operationId,
|
|
244
|
+
input: resolvedInput,
|
|
245
|
+
...(connection ? { connectionId: connection.id } : {}),
|
|
246
|
+
gateway: {
|
|
247
|
+
execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
|
|
248
|
+
if (foreignProviderId !== provider.id) {
|
|
249
|
+
throw new Error(`Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`);
|
|
250
|
+
}
|
|
251
|
+
const startedGatewayMs = performance.now();
|
|
252
|
+
const executed = await invoke({
|
|
253
|
+
operationId: gatewayOperationId,
|
|
254
|
+
input: gatewayInput,
|
|
255
|
+
connection,
|
|
256
|
+
requestId: `${execution.requestId}-prepare-${randomUUID()}`,
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
status: executed.status,
|
|
260
|
+
duration: performance.now() - startedGatewayMs,
|
|
261
|
+
data: executed.data,
|
|
262
|
+
meta: executed.meta,
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
: resolvedInput;
|
|
268
|
+
const executed = await invoke({
|
|
269
|
+
operationId,
|
|
270
|
+
input: preparedInput,
|
|
271
|
+
connection,
|
|
272
|
+
requestId: `${execution.requestId}-${randomUUID()}`,
|
|
273
|
+
});
|
|
274
|
+
const durationMs = performance.now() - startedAtMs;
|
|
275
|
+
if (executed.status < 200 || executed.status >= 300) {
|
|
276
|
+
return finish({
|
|
277
|
+
status: "failed",
|
|
278
|
+
label: defaultLabel,
|
|
279
|
+
httpStatus: executed.status,
|
|
280
|
+
error: {
|
|
281
|
+
code: upstreamErrorCode(executed.data) ?? "operation_failed",
|
|
282
|
+
message: redact(upstreamErrorMessage(executed.data) ??
|
|
283
|
+
`Operation invocation failed with status ${executed.status}`),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const assertionContext = {
|
|
288
|
+
status: executed.status,
|
|
289
|
+
data: executed.data,
|
|
290
|
+
durationMs,
|
|
291
|
+
...(executed.meta ? { meta: executed.meta } : {}),
|
|
292
|
+
};
|
|
293
|
+
let assertionResult;
|
|
294
|
+
try {
|
|
295
|
+
assertionResult = await healthCase.assertions(assertionContext);
|
|
296
|
+
}
|
|
297
|
+
catch (assertionError) {
|
|
298
|
+
return finish({
|
|
299
|
+
status: "failed",
|
|
300
|
+
label: defaultLabel,
|
|
301
|
+
httpStatus: executed.status,
|
|
302
|
+
assertion: {
|
|
303
|
+
passed: false,
|
|
304
|
+
message: redact(assertionError instanceof Error ? assertionError.message : String(assertionError)),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const statusValue = objectProperty(assertionResult, "status");
|
|
309
|
+
const overrideStatus = statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
|
|
310
|
+
const labelValue = objectProperty(assertionResult, "label");
|
|
311
|
+
const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
|
|
312
|
+
return finish({
|
|
313
|
+
status: overrideStatus ?? "ok",
|
|
314
|
+
label: overrideLabel ?? defaultLabel,
|
|
315
|
+
httpStatus: executed.status,
|
|
316
|
+
assertion: { passed: true },
|
|
317
|
+
});
|
|
318
|
+
}, timeoutMs);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (error instanceof SelfTestCaseTimeoutError) {
|
|
322
|
+
return finish({
|
|
323
|
+
status: "error",
|
|
324
|
+
label: defaultLabel,
|
|
325
|
+
error: { code: "self_test_timeout", message: redact(error.message) },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return finish({
|
|
329
|
+
status: "error",
|
|
330
|
+
label: defaultLabel,
|
|
331
|
+
error: {
|
|
332
|
+
code: "self_test_execution_error",
|
|
333
|
+
message: redact(error instanceof Error ? error.message : String(error)),
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function selectCases(provider, request) {
|
|
339
|
+
const singleCase = request.operationId !== undefined && request.caseName !== undefined;
|
|
340
|
+
if (singleCase) {
|
|
341
|
+
const operationId = request.operationId;
|
|
342
|
+
const operation = provider.operations[operationId];
|
|
343
|
+
const suite = operation ? healthCheckSuite(operation) : undefined;
|
|
344
|
+
const healthCase = suite?.cases.find((candidate) => candidate.name === request.caseName);
|
|
345
|
+
if (!operation || !suite || !healthCase) {
|
|
346
|
+
return {
|
|
347
|
+
errorStatus: 422,
|
|
348
|
+
errorCode: "case_not_found",
|
|
349
|
+
errorMessage: `Case "${request.caseName}" not found for operation "${operationId}".`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (!isSelfTestReadOnlyOperation(operation)) {
|
|
353
|
+
return {
|
|
354
|
+
errorStatus: 403,
|
|
355
|
+
errorCode: "operation_not_read_only",
|
|
356
|
+
errorMessage: `Operation "${operationId}" is not classified read-only; self-test refuses to execute it.`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return { cases: [{ operationId, operation, suite, healthCase }] };
|
|
360
|
+
}
|
|
361
|
+
const operationFilter = request.operations ? new Set(request.operations) : undefined;
|
|
362
|
+
const caseNameFilter = request.caseNames ? new Set(request.caseNames) : undefined;
|
|
363
|
+
if (operationFilter) {
|
|
364
|
+
for (const operationId of operationFilter) {
|
|
365
|
+
const operation = provider.operations[operationId];
|
|
366
|
+
if (!operation || !healthCheckSuite(operation)) {
|
|
367
|
+
return {
|
|
368
|
+
errorStatus: 422,
|
|
369
|
+
errorCode: "case_not_found",
|
|
370
|
+
errorMessage: `Operation "${operationId}" has no declared healthCheck cases.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const cases = [];
|
|
376
|
+
for (const operationId of Object.keys(provider.operations).sort()) {
|
|
377
|
+
if (operationFilter && !operationFilter.has(operationId))
|
|
378
|
+
continue;
|
|
379
|
+
const operation = provider.operations[operationId];
|
|
380
|
+
const suite = operation ? healthCheckSuite(operation) : undefined;
|
|
381
|
+
if (!operation || !suite)
|
|
382
|
+
continue;
|
|
383
|
+
for (const healthCase of suite.cases) {
|
|
384
|
+
if (caseNameFilter && !caseNameFilter.has(healthCase.name))
|
|
385
|
+
continue;
|
|
386
|
+
cases.push({ operationId, operation, suite, healthCase });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (cases.length === 0) {
|
|
390
|
+
return {
|
|
391
|
+
errorStatus: 422,
|
|
392
|
+
errorCode: "case_not_found",
|
|
393
|
+
errorMessage: "No declared healthCheck cases matched the selection.",
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return { cases };
|
|
397
|
+
}
|
|
398
|
+
function resolveRequestBudgetMs(options) {
|
|
399
|
+
if (options.requestBudgetMs !== undefined)
|
|
400
|
+
return options.requestBudgetMs;
|
|
401
|
+
const env = options.env ?? process.env;
|
|
402
|
+
const raw = env[PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV];
|
|
403
|
+
const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
|
|
404
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SELF_TEST_REQUEST_BUDGET_MS;
|
|
405
|
+
}
|
|
406
|
+
export function resolveSelfTestPort(env = process.env) {
|
|
407
|
+
const raw = env[PROVIDER_RUNTIME_SELF_TEST_PORT_ENV];
|
|
408
|
+
const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
|
|
409
|
+
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535
|
|
410
|
+
? parsed
|
|
411
|
+
: DEFAULT_SELF_TEST_PORT;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Builds the internal self-test Hono app. This app is served on a SEPARATE
|
|
415
|
+
* listener (default :3001) that the tenant-facing gateway never dials; when no
|
|
416
|
+
* master secret is resolvable the self-test route responds 404 (safe default
|
|
417
|
+
* off) while `GET /healthz` stays available for liveness.
|
|
418
|
+
*/
|
|
419
|
+
export function createSelfTestApp(provider, options) {
|
|
420
|
+
const app = new Hono();
|
|
421
|
+
const planDigest = computeSelfTestPlanDigest(provider);
|
|
422
|
+
const requestBudgetMs = resolveRequestBudgetMs(options);
|
|
423
|
+
let busy = false;
|
|
424
|
+
app.notFound((c) => c.json({ error: { code: "not_found", message: "Not found" } }, 404));
|
|
425
|
+
app.get(SELF_TEST_HEALTHZ_PATH, (c) => c.json({ ok: true }));
|
|
426
|
+
const handleSelfTest = async (c) => {
|
|
427
|
+
if (!options.secrets) {
|
|
428
|
+
return c.json({ error: { code: "not_found", message: "Not found" } }, 404);
|
|
429
|
+
}
|
|
430
|
+
const authorized = verifySelfTestAuthorization(c.req.raw.headers.get("authorization") ?? undefined, provider.id, options.secrets);
|
|
431
|
+
if (!authorized) {
|
|
432
|
+
return c.json({ error: { code: "unauthorized", message: "Unauthorized" } }, 401);
|
|
433
|
+
}
|
|
434
|
+
const rawBody = await c.req.raw
|
|
435
|
+
.clone()
|
|
436
|
+
.json()
|
|
437
|
+
.catch(() => undefined);
|
|
438
|
+
const schemaVersion = objectProperty(rawBody, "schemaVersion");
|
|
439
|
+
if (schemaVersion !== SELF_TEST_SCHEMA_VERSION) {
|
|
440
|
+
return c.json({
|
|
441
|
+
error: {
|
|
442
|
+
code: "unsupported_schema_version",
|
|
443
|
+
message: `Unsupported self-test schemaVersion; supported: [${SELF_TEST_SCHEMA_VERSION}].`,
|
|
444
|
+
supported: [SELF_TEST_SCHEMA_VERSION],
|
|
445
|
+
},
|
|
446
|
+
}, 400);
|
|
447
|
+
}
|
|
448
|
+
const parsed = SelfTestRequestSchema.safeParse(rawBody);
|
|
449
|
+
if (!parsed.success) {
|
|
450
|
+
return c.json({
|
|
451
|
+
error: {
|
|
452
|
+
code: "invalid_request",
|
|
453
|
+
message: "Invalid self-test request body",
|
|
454
|
+
},
|
|
455
|
+
}, 400);
|
|
456
|
+
}
|
|
457
|
+
const request = parsed.data;
|
|
458
|
+
if ((request.operationId === undefined) !== (request.caseName === undefined)) {
|
|
459
|
+
return c.json({
|
|
460
|
+
error: {
|
|
461
|
+
code: "invalid_request",
|
|
462
|
+
message: "operationId and caseName must be provided together for single-case requests.",
|
|
463
|
+
},
|
|
464
|
+
}, 400);
|
|
465
|
+
}
|
|
466
|
+
const selection = selectCases(provider, request);
|
|
467
|
+
if ("errorStatus" in selection) {
|
|
468
|
+
return c.json({
|
|
469
|
+
error: {
|
|
470
|
+
code: selection.errorCode,
|
|
471
|
+
message: selection.errorMessage,
|
|
472
|
+
},
|
|
473
|
+
}, selection.errorStatus);
|
|
474
|
+
}
|
|
475
|
+
if (busy) {
|
|
476
|
+
return c.json({
|
|
477
|
+
error: {
|
|
478
|
+
code: "self_test_busy",
|
|
479
|
+
message: "A self-test request is already executing.",
|
|
480
|
+
},
|
|
481
|
+
retryAfterMs: SELF_TEST_BUSY_RETRY_AFTER_MS,
|
|
482
|
+
}, 409);
|
|
483
|
+
}
|
|
484
|
+
busy = true;
|
|
485
|
+
try {
|
|
486
|
+
const execution = {
|
|
487
|
+
provider,
|
|
488
|
+
invoke: options.invoke,
|
|
489
|
+
requestId: request.requestId,
|
|
490
|
+
credentials: request.credentials?.inputs,
|
|
491
|
+
requestTimeoutMs: request.timeoutMs,
|
|
492
|
+
sensitiveValues: collectSelfTestSensitiveValues(provider, {
|
|
493
|
+
env: options.env,
|
|
494
|
+
credentialInputs: request.credentials?.inputs,
|
|
495
|
+
}),
|
|
496
|
+
};
|
|
497
|
+
const deadline = performance.now() + requestBudgetMs;
|
|
498
|
+
const results = [];
|
|
499
|
+
// Sequential execution (parallelism 1): self-tests run on serving pods
|
|
500
|
+
// and must never compete with themselves for upstream quota.
|
|
501
|
+
for (const selected of selection.cases) {
|
|
502
|
+
if (performance.now() >= deadline) {
|
|
503
|
+
const now = new Date().toISOString();
|
|
504
|
+
results.push({
|
|
505
|
+
operationId: selected.operationId,
|
|
506
|
+
caseName: selected.healthCase.name,
|
|
507
|
+
status: "skipped",
|
|
508
|
+
label: selected.healthCase.name,
|
|
509
|
+
responseTimeMs: 0,
|
|
510
|
+
skipReason: "budget_exhausted",
|
|
511
|
+
startedAt: now,
|
|
512
|
+
finishedAt: now,
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (!isSelfTestReadOnlyOperation(selected.operation)) {
|
|
517
|
+
const now = new Date().toISOString();
|
|
518
|
+
results.push({
|
|
519
|
+
operationId: selected.operationId,
|
|
520
|
+
caseName: selected.healthCase.name,
|
|
521
|
+
status: "error",
|
|
522
|
+
label: selected.healthCase.name,
|
|
523
|
+
responseTimeMs: 0,
|
|
524
|
+
error: {
|
|
525
|
+
code: "operation_not_read_only",
|
|
526
|
+
message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
|
|
527
|
+
},
|
|
528
|
+
startedAt: now,
|
|
529
|
+
finishedAt: now,
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
results.push(await executeSelfTestCase(execution, selected.operationId, selected.suite, selected.healthCase));
|
|
534
|
+
}
|
|
535
|
+
const singleCase = request.operationId !== undefined && request.caseName !== undefined;
|
|
536
|
+
const response = {
|
|
537
|
+
schemaVersion: SELF_TEST_SCHEMA_VERSION,
|
|
538
|
+
providerId: provider.id,
|
|
539
|
+
sdkVersion: SDK_VERSION,
|
|
540
|
+
planDigest,
|
|
541
|
+
...(singleCase && results[0] ? { result: results[0] } : {}),
|
|
542
|
+
results,
|
|
543
|
+
};
|
|
544
|
+
return c.json(response, 200);
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
busy = false;
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
app.post(SELF_TEST_PATH, handleSelfTest);
|
|
551
|
+
// Transitional alias so schedulers can address the endpoint by its short
|
|
552
|
+
// path; the canonical path is /internal/health/self-test.
|
|
553
|
+
app.post("/self-test", handleSelfTest);
|
|
554
|
+
return app;
|
|
555
|
+
}
|
package/dist/server/serve.d.ts
CHANGED
|
@@ -59,6 +59,12 @@ export declare function createServerApp(provider: ProviderDefinition, options?:
|
|
|
59
59
|
export interface ServeOptions extends ProviderServerOptions {
|
|
60
60
|
host?: string;
|
|
61
61
|
port?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Port for the internal self-test listener (default 3001 or
|
|
64
|
+
* APIFUSE__PROVIDER_RUNTIME__SELF_TEST_PORT). The listener only starts
|
|
65
|
+
* when APIFUSE__PROVIDER_RUNTIME__SELF_TEST_MASTER_SECRET is present.
|
|
66
|
+
*/
|
|
67
|
+
selfTestPort?: number;
|
|
62
68
|
}
|
|
63
69
|
export declare function serve(provider: ProviderDefinition, options?: ServeOptions): Promise<void>;
|
|
64
70
|
export {};
|