@apifuse/provider-sdk 2.2.0-beta.40 → 2.2.0-beta.42

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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bin/apifuse-check.ts +61 -0
  3. package/bin/apifuse-migrate-shape.ts +202 -0
  4. package/bin/apifuse-submit-check.ts +1773 -222
  5. package/dist/cli/commands.d.ts +1 -1
  6. package/dist/cli/commands.js +8 -0
  7. package/dist/cli/create.js +6 -0
  8. package/dist/cli/migrate-operation-shape.d.ts +44 -0
  9. package/dist/cli/migrate-operation-shape.js +113 -0
  10. package/dist/cli/migrate-provider-shape.d.ts +52 -0
  11. package/dist/cli/migrate-provider-shape.js +578 -0
  12. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  13. package/dist/contract.js +1 -0
  14. package/dist/define.js +22 -1
  15. package/dist/error-observability.d.ts +7 -0
  16. package/dist/error-observability.js +61 -0
  17. package/dist/errors.d.ts +15 -0
  18. package/dist/fixture-sanitization.js +13 -3
  19. package/dist/index.d.ts +1 -1
  20. package/dist/provider.d.ts +1 -1
  21. package/dist/runtime/executor.js +11 -1
  22. package/dist/server/error-observability.d.ts +1 -0
  23. package/dist/server/error-observability.js +1 -0
  24. package/dist/server/index.d.ts +2 -1
  25. package/dist/server/self-test.js +3 -0
  26. package/dist/server/serve-implementation.d.ts +12 -0
  27. package/dist/server/serve-implementation.js +174 -66
  28. package/dist/types.d.ts +18 -10
  29. package/package.json +1 -1
  30. package/src/cli/commands.ts +10 -0
  31. package/src/cli/create.ts +6 -0
  32. package/src/cli/migrate-operation-shape.ts +184 -0
  33. package/src/cli/migrate-provider-shape.ts +772 -0
  34. package/src/cli/templates/provider/provider.json.tpl +6 -0
  35. package/src/contract.ts +1 -0
  36. package/src/define.ts +33 -1
  37. package/src/error-observability.ts +64 -0
  38. package/src/errors.ts +16 -0
  39. package/src/fixture-sanitization.ts +19 -3
  40. package/src/index.ts +1 -0
  41. package/src/provider.ts +1 -0
  42. package/src/runtime/executor.ts +13 -1
  43. package/src/server/error-observability.ts +1 -0
  44. package/src/server/index.ts +2 -0
  45. package/src/server/self-test.ts +5 -0
  46. package/src/server/serve-implementation.ts +214 -84
  47. package/src/types.ts +38 -27
@@ -0,0 +1,6 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "providerId": "{{PROVIDER_ID}}",
4
+ "owner": "bounty",
5
+ "lifecycle": "draft"
6
+ }
package/src/contract.ts CHANGED
@@ -167,6 +167,7 @@ function extractHealthCheck(value: HealthCheckSuite | undefined): JsonValue | un
167
167
  name: item.name,
168
168
  description: item.description,
169
169
  input: toJsonValue(item.input),
170
+ scenario: toJsonValue(item.scenario),
170
171
  degradedThresholdMs: item.degradedThresholdMs,
171
172
  timeoutMs: item.timeoutMs,
172
173
  expectedStatus: item.expectedStatus,
package/src/define.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "./declaration-validation.js";
7
7
  import { SDK_RUNTIME_OWNED_ERROR_CODES } from "./error-resolution.js";
8
8
  import { ProviderError, ValidationError } from "./errors.js";
9
+ import { HealthScenarioSchema } from "./health-scenario.js";
9
10
  import {
10
11
  NativeEgressPolicyValidationError,
11
12
  validateNativeProviderConfig,
@@ -1730,6 +1731,7 @@ const HEALTH_CHECK_CASE_FIELDS = new Set([
1730
1731
  "input",
1731
1732
  "prepareInput",
1732
1733
  "assertions",
1734
+ "scenario",
1733
1735
  "degradedThresholdMs",
1734
1736
  "timeoutMs",
1735
1737
  "expectedStatus",
@@ -1979,7 +1981,19 @@ function validateHealthCheckCase(
1979
1981
  throw new ValidationError(
1980
1982
  `Provider "${providerId}" ${fieldPath}.name must be a non-empty string.`,
1981
1983
  );
1982
- if (typeof c.assertions !== "function")
1984
+ const hasScenario = c.scenario !== undefined;
1985
+ const imperativeFields = [
1986
+ ...(c.prepareInput === undefined ? [] : ["prepareInput"]),
1987
+ ...(c.assertions === undefined ? [] : ["assertions"]),
1988
+ ];
1989
+ if (hasScenario && imperativeFields.length > 0)
1990
+ throw new ValidationError(
1991
+ `Provider "${providerId}" operation "${operationName}" health-check case "${c.name}" cannot declare scenario with ${imperativeFields.join(" and ")}.`,
1992
+ {
1993
+ fix: `Remove ${imperativeFields.map((field) => `${fieldPath}.${field}`).join(" and ")} and keep ${fieldPath}.scenario, or remove ${fieldPath}.scenario to keep the imperative hooks.`,
1994
+ },
1995
+ );
1996
+ if (!hasScenario && typeof c.assertions !== "function")
1983
1997
  throw new ValidationError(
1984
1998
  `Provider "${providerId}" ${fieldPath}.assertions must be a function.`,
1985
1999
  {
@@ -1990,6 +2004,24 @@ function validateHealthCheckCase(
1990
2004
  throw new ValidationError(
1991
2005
  `Provider "${providerId}" ${fieldPath}.prepareInput must be a function.`,
1992
2006
  );
2007
+ if (hasScenario) {
2008
+ const parsedScenario = HealthScenarioSchema.safeParse(c.scenario);
2009
+ if (!parsedScenario.success)
2010
+ throw new ValidationError(
2011
+ `Provider "${providerId}" operation "${operationName}" health-check case "${c.name}" has an invalid scenario: it must conform to HealthScenario.`,
2012
+ { fix: `Build ${fieldPath}.scenario with defineHealthScenario().` },
2013
+ );
2014
+ const unrelatedOperation = parsedScenario.data.coversOperations.find(
2015
+ (operationId) => operationId !== operationName,
2016
+ );
2017
+ if (unrelatedOperation !== undefined)
2018
+ throw new ValidationError(
2019
+ `Provider "${providerId}" operation "${operationName}" health-check case "${c.name}" scenario.coversOperations cannot claim unrelated operation "${unrelatedOperation}".`,
2020
+ {
2021
+ fix: `Set ${fieldPath}.scenario.coversOperations to ["${operationName}"].`,
2022
+ },
2023
+ );
2024
+ }
1993
2025
  if (
1994
2026
  c.degradedThresholdMs !== undefined &&
1995
2027
  (typeof c.degradedThresholdMs !== "number" ||
@@ -0,0 +1,64 @@
1
+ import { isProviderError, type ProviderErrorObservability } from "./errors.js";
2
+
3
+ const PROVIDER_OBSERVABILITY_TOKEN_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4
+ const PROVIDER_OBSERVABILITY_FINGERPRINT_PATTERN = /^[A-Fa-f0-9]{12}$/;
5
+ const MAX_PROVIDER_OBSERVABILITY_MESSAGE_LENGTH = 10_000_000;
6
+
7
+ /**
8
+ * Extracts only own data properties from branded provider errors. In
9
+ * particular, descriptor reads reject options/observability accessors without
10
+ * invoking provider-controlled getters.
11
+ */
12
+ export function safeProviderErrorObservability(
13
+ error: unknown,
14
+ ): ProviderErrorObservability | undefined {
15
+ if (!isProviderError(error)) return undefined;
16
+ let candidate: unknown;
17
+ let reason: unknown;
18
+ let fingerprint: unknown;
19
+ let messageLength: unknown;
20
+ try {
21
+ const optionsDescriptor = Object.getOwnPropertyDescriptor(error, "options");
22
+ if (optionsDescriptor === undefined || !Object.hasOwn(optionsDescriptor, "value")) {
23
+ return undefined;
24
+ }
25
+ const options = optionsDescriptor.value;
26
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
27
+ return undefined;
28
+ }
29
+ const observabilityDescriptor = Object.getOwnPropertyDescriptor(options, "observability");
30
+ if (observabilityDescriptor === undefined || !Object.hasOwn(observabilityDescriptor, "value")) {
31
+ return undefined;
32
+ }
33
+ candidate = observabilityDescriptor.value;
34
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
35
+ return undefined;
36
+ }
37
+ const ownValue = (key: keyof ProviderErrorObservability): unknown => {
38
+ const descriptor = Object.getOwnPropertyDescriptor(candidate as object, key);
39
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : undefined;
40
+ };
41
+ reason = ownValue("reason");
42
+ fingerprint = ownValue("fingerprint");
43
+ messageLength = ownValue("messageLength");
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ const safe: ProviderErrorObservability = {
48
+ ...(typeof reason === "string" && PROVIDER_OBSERVABILITY_TOKEN_PATTERN.test(reason)
49
+ ? { reason }
50
+ : {}),
51
+ ...(typeof fingerprint === "string" &&
52
+ PROVIDER_OBSERVABILITY_FINGERPRINT_PATTERN.test(fingerprint)
53
+ ? { fingerprint }
54
+ : {}),
55
+ ...(typeof messageLength === "number" &&
56
+ Number.isInteger(messageLength) &&
57
+ messageLength >= 0 &&
58
+ messageLength <= MAX_PROVIDER_OBSERVABILITY_MESSAGE_LENGTH
59
+ ? { messageLength }
60
+ : {}),
61
+ };
62
+
63
+ return Object.keys(safe).length > 0 ? safe : undefined;
64
+ }
package/src/errors.ts CHANGED
@@ -46,6 +46,22 @@ export type ProviderErrorOptions = {
46
46
  cause?: Error;
47
47
  category?: ProviderErrorCategory;
48
48
  retryable?: boolean;
49
+ /** Provider-authored, bounded metadata safe for operational logs and error headers. */
50
+ observability?: ProviderErrorObservability;
51
+ };
52
+
53
+ /**
54
+ * Provider-authored error diagnostics whose runtime values are validated before emission.
55
+ * Classification tokens such as `reason` are source literals, not runtime user input or
56
+ * credentials. The gateway removes the observability header from tenant responses.
57
+ */
58
+ export type ProviderErrorObservability = {
59
+ /** A 1-64 character `[A-Za-z0-9_.-]` classification token, for example `LOGIN_COMPLETE_FAILED`. */
60
+ reason?: string;
61
+ /** A provider-computed 12-hex-character fingerprint of private diagnostic input. */
62
+ fingerprint?: string;
63
+ /** The non-negative length of the private diagnostic input, capped at 10,000,000. */
64
+ messageLength?: number;
49
65
  };
50
66
 
51
67
  export class ProviderError extends Error {
@@ -5,6 +5,12 @@ export const REDACTED_FIXTURE_VALUE = "[REDACTED]";
5
5
  const OPAQUE_TOKEN = /^[A-Za-z0-9_+/=.:~-]+$/;
6
6
  const OPAQUE_TOKEN_RUN = /[A-Za-z0-9_+/=.:~-]{24,}/g;
7
7
  const URL_RUN = /https?:\/\/[^\s"'<>]+/gi;
8
+ const EMAIL_ADDRESS_RUN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
9
+ const DIAGNOSTIC_URL_SENTINEL_DELIMITER = String.fromCodePoint(0);
10
+ const DIAGNOSTIC_URL_SENTINEL_RUN = new RegExp(
11
+ `${DIAGNOSTIC_URL_SENTINEL_DELIMITER}APIFUSE_URL(\\d+)${DIAGNOSTIC_URL_SENTINEL_DELIMITER}`,
12
+ "g",
13
+ );
8
14
  const PEM_PRIVATE_KEY =
9
15
  /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g;
10
16
 
@@ -149,9 +155,15 @@ export function requestPathForFixture(value: string): string {
149
155
 
150
156
  /** Scrubs secrets and terminal/log control characters before diagnostic text is emitted. */
151
157
  export function sanitizeDiagnosticText(value: string): string {
152
- let sanitized = value
153
- .replace(URL_RUN, (url) => sanitizeUrlForLogs(url))
154
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED_FIXTURE_VALUE}`);
158
+ const retainedUrls: string[] = [];
159
+ // Remove attacker-controlled NUL delimiters before introducing internal URL sentinels.
160
+ let sanitized = encodeDiagnosticControls(value)
161
+ .replace(URL_RUN, (url) => {
162
+ const index = retainedUrls.push(sanitizeUrlForLogs(url)) - 1;
163
+ return `${DIAGNOSTIC_URL_SENTINEL_DELIMITER}APIFUSE_URL${index}${DIAGNOSTIC_URL_SENTINEL_DELIMITER}`;
164
+ })
165
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED_FIXTURE_VALUE}`)
166
+ .replace(EMAIL_ADDRESS_RUN, REDACTED_FIXTURE_VALUE);
155
167
  sanitized = redactSensitiveAssignments(sanitized);
156
168
  sanitized = sanitized.replace(OPAQUE_TOKEN_RUN, (candidate, offset: number, source: string) => {
157
169
  if (/^(?:request|trace|correlation)[-_]?id[:=]/i.test(candidate)) return candidate;
@@ -159,6 +171,10 @@ export function sanitizeDiagnosticText(value: string): string {
159
171
  if (/(?:request|trace|correlation)[-_]?id\s*[:=]\s*$/i.test(prefix)) return candidate;
160
172
  return isSensitiveFixtureValue(candidate) ? REDACTED_FIXTURE_VALUE : candidate;
161
173
  });
174
+ sanitized = sanitized.replace(
175
+ DIAGNOSTIC_URL_SENTINEL_RUN,
176
+ (_match, index: string) => retainedUrls[Number(index)] ?? REDACTED_FIXTURE_VALUE,
177
+ );
162
178
  return encodeDiagnosticControls(sanitized);
163
179
  }
164
180
 
package/src/index.ts CHANGED
@@ -254,6 +254,7 @@ export {
254
254
  createServerApp,
255
255
  createServerAppAsync,
256
256
  ERROR_OBSERVABILITY_HEADER,
257
+ type ProviderErrorCauseFrame,
257
258
  type ServeOptions,
258
259
  serve,
259
260
  } from "./server/serve.js";
package/src/provider.ts CHANGED
@@ -122,6 +122,7 @@ export {
122
122
  isSessionExpiredError,
123
123
  isTransportError,
124
124
  ProviderError,
125
+ type ProviderErrorObservability,
125
126
  SessionExpiredError,
126
127
  TransportError,
127
128
  ValidationError,
@@ -1,7 +1,9 @@
1
+ import { safeProviderErrorObservability } from "../error-observability.js";
1
2
  import {
2
3
  isSessionExpiredError,
3
4
  isValidationError,
4
5
  ProviderError,
6
+ type ProviderErrorOptions,
5
7
  SessionExpiredError,
6
8
  ValidationError,
7
9
  } from "../errors.js";
@@ -15,6 +17,13 @@ export function isStreamingOperation(provider: ProviderDefinition, operationId:
15
17
  return kind !== "json";
16
18
  }
17
19
 
20
+ function preservedSessionExpiredOptions(error: unknown): ProviderErrorOptions {
21
+ const observability = safeProviderErrorObservability(error);
22
+ return observability
23
+ ? { observability, retryable: true }
24
+ : { retryable: true };
25
+ }
26
+
18
27
  /**
19
28
  * Execute a provider operation by calling its handler.
20
29
  *
@@ -84,7 +93,10 @@ export async function executeOperation<
84
93
  // executor's, which `instanceof` would miss — dropping the retryable
85
94
  // upgrade and stranding an operation that opted into auth refresh.
86
95
  if (isSessionExpiredError(error) && operation.retryOnAuthRefresh) {
87
- throw new SessionExpiredError(error.message, { retryable: true });
96
+ // Preserve provider-authored safe metadata while forcing the retry signal.
97
+ // `cause` intentionally remains dropped, matching the pre-existing
98
+ // reconstruction semantics.
99
+ throw new SessionExpiredError(error.message, preservedSessionExpiredOptions(error));
88
100
  }
89
101
  throw error;
90
102
  }
@@ -0,0 +1 @@
1
+ export { safeProviderErrorObservability } from "../error-observability.js";
@@ -17,6 +17,7 @@ export {
17
17
  type ErrorObservabilityDetails,
18
18
  type ProviderServerCloseOptions,
19
19
  type ProviderServerHandle,
20
+ type ProviderErrorCauseFrame,
20
21
  type ProviderServerLogEvent,
21
22
  type ProviderServerLogger,
22
23
  type ProviderServerOperationExecutor,
@@ -26,6 +27,7 @@ export {
26
27
  type ServeOptions,
27
28
  serve,
28
29
  } from "./serve.js";
30
+ export type { ProviderErrorObservability } from "../errors.js";
29
31
  export {
30
32
  computeSelfTestPlanDigest,
31
33
  createSelfTestApp,
@@ -1106,6 +1106,11 @@ async function executeSelfTestCase(
1106
1106
  const { startedAtMs, finish } = caseScope;
1107
1107
  try {
1108
1108
  return await runWithCaseTimeout(async () => {
1109
+ if (healthCase.assertions === undefined) {
1110
+ throw new Error(
1111
+ `Self-test cannot execute declarative health-check case "${healthCase.name}"; run its scenario through the health monitor.`,
1112
+ );
1113
+ }
1109
1114
  const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
1110
1115
  const preparedInput = healthCase.prepareInput
1111
1116
  ? await healthCase.prepareInput({