@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68
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 +28 -1
- package/CHANGELOG.md +8 -0
- package/README.md +8 -4
- package/bin/apifuse-record.ts +207 -31
- package/bin/apifuse-submit-check.ts +273 -74
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +493 -2
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +2 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +43 -29
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/package.json +1 -1
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/lint.ts +541 -1
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +2 -1
- package/src/runtime/provider.ts +15 -0
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +56 -30
- package/src/server/state-backend-report.ts +185 -0
|
@@ -15,7 +15,7 @@ import { createEngineForMode, isUnavailableProviderEngine, resolveProviderEngine
|
|
|
15
15
|
import { SDK_VERSION } from "./sdk-version.js";
|
|
16
16
|
import { safeProviderErrorObservability } from "../error-observability.js";
|
|
17
17
|
import { providerErrorStackFrames } from "./error-stack-frames.js";
|
|
18
|
-
import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
|
|
18
|
+
import { registeredUpstreamErrorCategory, SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
|
|
19
19
|
import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, SDKError, } from "../errors.js";
|
|
20
20
|
import { sanitizeDiagnosticText } from "../fixture-sanitization.js";
|
|
21
21
|
import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
|
|
@@ -37,7 +37,7 @@ import { HttpTelemetryCollector } from "../runtime/http-telemetry.js";
|
|
|
37
37
|
import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
|
|
38
38
|
import { createOcrClientFromEnv } from "../runtime/ocr.js";
|
|
39
39
|
import { bindOcrTelemetry, OcrTelemetryCollector } from "../runtime/ocr-telemetry.js";
|
|
40
|
-
import { getProviderBaseUrl } from "../runtime/provider.js";
|
|
40
|
+
import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../runtime/provider.js";
|
|
41
41
|
import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
|
|
42
42
|
import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector, } from "../runtime/proxy-telemetry.js";
|
|
43
43
|
import { closedEnum, RequestTelemetry, tenantOpaqueKeys, } from "../runtime/request-telemetry.js";
|
|
@@ -62,6 +62,7 @@ import { getStealthProfile } from "../stealth/profiles.js";
|
|
|
62
62
|
import { APIFUSE_STREAM_DONE_EVENT, APIFUSE_STREAM_ERROR_EVENT, encodeSseEvent, error as streamError, } from "../stream.js";
|
|
63
63
|
import { sanitizeSpanNameForOutput, sanitizeTraceAttributes } from "../trace-sanitization.js";
|
|
64
64
|
import { VALID_OPERATION_ERROR_STATUSES } from "../types.js";
|
|
65
|
+
import { providerStateBackendLogEvent, } from "./state-backend-report.js";
|
|
65
66
|
import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
|
|
66
67
|
import { collectStaticDiagnosticSensitiveValues, createDiagnosticEnvObserver, } from "./sensitive-values.js";
|
|
67
68
|
import { resolveServerTraceContextOptions } from "./trace-output.js";
|
|
@@ -340,14 +341,6 @@ function bindResolverSignalWithoutRuntime(resolver, defaultSignal) {
|
|
|
340
341
|
inheritResolverTelemetryBinding(resolver, boundResolver);
|
|
341
342
|
return boundResolver;
|
|
342
343
|
}
|
|
343
|
-
function getProviderStealthBaseUrl(provider) {
|
|
344
|
-
const baseUrl = getProviderBaseUrl(provider);
|
|
345
|
-
if (baseUrl) {
|
|
346
|
-
return baseUrl;
|
|
347
|
-
}
|
|
348
|
-
const firstHost = provider.allowedHosts?.[0];
|
|
349
|
-
return firstHost ? `https://${firstHost}` : undefined;
|
|
350
|
-
}
|
|
351
344
|
function getProviderStealthProfile(provider) {
|
|
352
345
|
return provider.stealth ? getStealthProfile(provider.stealth) : undefined;
|
|
353
346
|
}
|
|
@@ -849,8 +842,9 @@ function zodDetails(error, redact) {
|
|
|
849
842
|
// (credential_unavailable) usually means a caller credential problem, an
|
|
850
843
|
// internal stateful-routing deadline is APIFuse-owned despite its timeout
|
|
851
844
|
// category, and the built-in upstream failure families keep their upstream
|
|
852
|
-
// attribution even
|
|
853
|
-
//
|
|
845
|
+
// attribution even under an explicit non-upstream category. An unset category
|
|
846
|
+
// needs no override: a registered upstream code already carries its category
|
|
847
|
+
// (SDK_UPSTREAM_ERROR_CODE_REGISTRY), and the source follows from it.
|
|
854
848
|
function publicErrorSource(error, category) {
|
|
855
849
|
if (error instanceof StatefulRoutingDeadlineError)
|
|
856
850
|
return "apifuse";
|
|
@@ -1120,6 +1114,18 @@ function providerObservabilityDetails(error, declaredErrorCode) {
|
|
|
1120
1114
|
...(error.upstreamStatus ? { upstreamStatus: error.upstreamStatus } : {}),
|
|
1121
1115
|
};
|
|
1122
1116
|
}
|
|
1117
|
+
// The category a `ProviderError` (validation errors included) gets when its
|
|
1118
|
+
// author set none, ahead of the branch's own default: an operation-declared
|
|
1119
|
+
// rejection status (409/410/422) → upstream_rejected, then the registered
|
|
1120
|
+
// upstream code's category (UPSTREAM_SCHEMA_ERROR → upstream_schema_drift,
|
|
1121
|
+
// NOT_FOUND → upstream_http, ...). `undefined` leaves the branch default.
|
|
1122
|
+
function derivedErrorCategory(error, declaredStatus) {
|
|
1123
|
+
if (isEmittableErrorStatus(declaredStatus) &&
|
|
1124
|
+
categoryForStatus(declaredStatus) === "upstream_rejected") {
|
|
1125
|
+
return "upstream_rejected";
|
|
1126
|
+
}
|
|
1127
|
+
return registeredUpstreamErrorCategory(providerErrorCode(error));
|
|
1128
|
+
}
|
|
1123
1129
|
function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
|
|
1124
1130
|
const effectiveDeclaration = sdkOwnsErrorResolution(error) ? undefined : declaredErrorCode;
|
|
1125
1131
|
const providerDetails = providerObservabilityDetails(error, effectiveDeclaration);
|
|
@@ -1131,12 +1137,10 @@ function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
|
|
|
1131
1137
|
return {
|
|
1132
1138
|
category: providerCategory
|
|
1133
1139
|
? providerCategory
|
|
1134
|
-
:
|
|
1135
|
-
|
|
1136
|
-
? "upstream_rejected"
|
|
1137
|
-
: isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
|
|
1140
|
+
: (derivedErrorCategory(error, declaredStatus) ??
|
|
1141
|
+
(isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
|
|
1138
1142
|
? "provider_error"
|
|
1139
|
-
: "input_validation",
|
|
1143
|
+
: "input_validation")),
|
|
1140
1144
|
taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
1141
1145
|
retryable: isProviderError(error)
|
|
1142
1146
|
? (providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false)
|
|
@@ -1151,18 +1155,12 @@ function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
|
|
|
1151
1155
|
};
|
|
1152
1156
|
}
|
|
1153
1157
|
if (isProviderError(error)) {
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
// status (409/410/422) classify as upstream_rejected unless the
|
|
1157
|
-
// author set an explicit category.
|
|
1158
|
-
const declaredStatus = effectiveDeclaration?.status;
|
|
1159
|
-
const rejectionDefault = providerErrorCode(error) === "UPSTREAM_REJECTED" ||
|
|
1160
|
-
(isEmittableErrorStatus(declaredStatus) &&
|
|
1161
|
-
categoryForStatus(declaredStatus) === "upstream_rejected")
|
|
1162
|
-
? "upstream_rejected"
|
|
1163
|
-
: "provider_error";
|
|
1158
|
+
// Retryability does not follow the derived category: it stays
|
|
1159
|
+
// instance ?? declared ?? false below.
|
|
1164
1160
|
return {
|
|
1165
|
-
category: providerErrorOption(error, "category") ??
|
|
1161
|
+
category: providerErrorOption(error, "category") ??
|
|
1162
|
+
derivedErrorCategory(error, effectiveDeclaration?.status) ??
|
|
1163
|
+
"provider_error",
|
|
1166
1164
|
taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
1167
1165
|
retryable: providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false,
|
|
1168
1166
|
};
|
|
@@ -2560,7 +2558,13 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
|
|
|
2560
2558
|
});
|
|
2561
2559
|
}
|
|
2562
2560
|
app.notFound((c) => {
|
|
2563
|
-
|
|
2561
|
+
// An unknown route is not an upstream answer: the explicit category keeps
|
|
2562
|
+
// the registered not_found code from attributing it to an upstream.
|
|
2563
|
+
const error = new ProviderError("Not found", {
|
|
2564
|
+
code: "not_found",
|
|
2565
|
+
category: "provider_error",
|
|
2566
|
+
retryable: false,
|
|
2567
|
+
});
|
|
2564
2568
|
const observabilityDetails = errorObservabilityDetails(error);
|
|
2565
2569
|
return responseWithErrorObservability(c.json(toErrorResponse(error, undefined, observabilityDetails, undefined, errorEnvelopeLocalization({
|
|
2566
2570
|
catalogs: localeCatalogs,
|
|
@@ -2968,6 +2972,16 @@ export async function serve(provider, options = {}) {
|
|
|
2968
2972
|
// One structured line per process boot, before the listener binds: the fleet
|
|
2969
2973
|
// audit (ADR-0011 migration) counts these.
|
|
2970
2974
|
logger(engineModeLogEvent(provider, engineSelection.resolution, !isUnavailableProviderEngine(engineSelection.engine)));
|
|
2975
|
+
// Same contract for the stores: one line per process boot naming the state
|
|
2976
|
+
// and cache backends this pod resolved, so "which Redis is this provider's
|
|
2977
|
+
// state actually on" is a log grep instead of an inference from the
|
|
2978
|
+
// Deployment spec (apifuse#2302).
|
|
2979
|
+
logger(providerStateBackendLogEvent({
|
|
2980
|
+
providerId: provider.id,
|
|
2981
|
+
sdkVersion: SDK_VERSION,
|
|
2982
|
+
injectedState: options.state !== undefined,
|
|
2983
|
+
allowMemoryFallback: options.allowMemoryStateFallback === true,
|
|
2984
|
+
}));
|
|
2971
2985
|
const servers = [];
|
|
2972
2986
|
try {
|
|
2973
2987
|
servers.push(bunRuntime.serve({
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a provider store resolved to at boot.
|
|
3
|
+
*
|
|
4
|
+
* `endpoint` is deliberately host and port only: the URL can carry a password
|
|
5
|
+
* (`redis://:secret@host:6379`), and the diagnostic inventory already treats
|
|
6
|
+
* those credential components — and only those — as sensitive
|
|
7
|
+
* (`collectDiagnosticSensitiveValues`, `urlCredentialComponents(url, {
|
|
8
|
+
* includePathAndQuery: false })`). A URL that does not parse contributes no
|
|
9
|
+
* endpoint at all rather than a redacted-looking fragment of itself.
|
|
10
|
+
*/
|
|
11
|
+
export type ProviderStoreBackendReport = {
|
|
12
|
+
/**
|
|
13
|
+
* `redis` when a URL resolved, `memory` for the in-process fallback,
|
|
14
|
+
* `unsupported` when state has no backend and every call fails closed,
|
|
15
|
+
* `injected` when the host handed the server a store object.
|
|
16
|
+
*/
|
|
17
|
+
readonly backend: "redis" | "memory" | "unsupported" | "injected";
|
|
18
|
+
/** Env name that supplied the Redis URL. Present only for `redis`. */
|
|
19
|
+
readonly envName?: string;
|
|
20
|
+
/**
|
|
21
|
+
* `host:port` of the resolved Redis. Never includes credentials, path or
|
|
22
|
+
* query. Absent when the URL does not parse, or when its query carries an
|
|
23
|
+
* ioredis option that moves the endpoint (`path`, `port`, `host`, `family`)
|
|
24
|
+
* so the authority is not where the client connects — see
|
|
25
|
+
* `state_redis_url_ambiguous_endpoint`.
|
|
26
|
+
*/
|
|
27
|
+
readonly endpoint?: string;
|
|
28
|
+
/** URL scheme, e.g. `redis` or `rediss`. Present only for `redis`. */
|
|
29
|
+
readonly scheme?: string;
|
|
30
|
+
/** True when the URL came from a shared fallback env rather than the store's own. */
|
|
31
|
+
readonly fallback?: boolean;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* One line per process boot naming the stores the provider resolved. Without
|
|
35
|
+
* it, a provider silently riding the cache Redis for its state is
|
|
36
|
+
* indistinguishable from one on a durable instance, from outside the pod and
|
|
37
|
+
* from the pod's own logs (apifuse#2302, the reason apifuse#2144 could only be
|
|
38
|
+
* verified by inference from the Deployment spec).
|
|
39
|
+
*/
|
|
40
|
+
export type ProviderStateBackendLogEvent = {
|
|
41
|
+
/** `warn` when a warning was raised, otherwise `info`. */
|
|
42
|
+
readonly level: "info" | "warn";
|
|
43
|
+
readonly event: "provider_state_backend";
|
|
44
|
+
readonly providerId: string;
|
|
45
|
+
readonly state: ProviderStoreBackendReport;
|
|
46
|
+
readonly cache: ProviderStoreBackendReport;
|
|
47
|
+
readonly sdkVersion: string;
|
|
48
|
+
/**
|
|
49
|
+
* Non-fatal findings, e.g. `state_redis_url_fallback` (the state store is
|
|
50
|
+
* sharing an instance provisioned for something else),
|
|
51
|
+
* `state_redis_url_unparsed`, or `state_redis_url_ambiguous_endpoint` (the
|
|
52
|
+
* URL's query carries an ioredis option that moves the endpoint, so the
|
|
53
|
+
* authority is not reported as the endpoint). Absent when the resolution
|
|
54
|
+
* was unambiguous.
|
|
55
|
+
*/
|
|
56
|
+
readonly warnings?: readonly string[];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Build the boot event from the same env precedence the stores themselves use
|
|
60
|
+
* (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
|
|
61
|
+
* so the line can never describe a backend other than the one in use.
|
|
62
|
+
*/
|
|
63
|
+
export declare function providerStateBackendLogEvent(input: {
|
|
64
|
+
readonly providerId: string;
|
|
65
|
+
readonly sdkVersion: string;
|
|
66
|
+
/** The host passed an explicit state store, so no env resolution happened. */
|
|
67
|
+
readonly injectedState: boolean;
|
|
68
|
+
/** Mirrors `createProviderRuntimeStateFromEnv({ allowMemoryFallback })`. */
|
|
69
|
+
readonly allowMemoryFallback: boolean;
|
|
70
|
+
}): ProviderStateBackendLogEvent;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { providerCacheRedisResolutionFromEnv, providerStateRedisResolutionFromEnv, } from "../config/loader.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether ioredis' own URL parsing would move the endpoint away from the
|
|
4
|
+
* authority this function can see. `createProviderRedisClient` hands the raw
|
|
5
|
+
* URL to `new Redis(url, …)`, and `parseURL` ends with
|
|
6
|
+
* `defaults(result, queryOptions)` — lodash `defaults`, so a query key only
|
|
7
|
+
* applies where the authority supplied nothing:
|
|
8
|
+
*
|
|
9
|
+
* - `path` is never derived from the authority of a `redis://` URL (that
|
|
10
|
+
* pathname is the db index), so `?path=` always applies — and
|
|
11
|
+
* `StandaloneConnector` connects to `options.path` INSTEAD of host/port.
|
|
12
|
+
* The client is then on a Unix socket the authority says nothing about.
|
|
13
|
+
* - `port` applies only when the URL carries no explicit port.
|
|
14
|
+
* - `host` never applies here: we return early unless `parsed.hostname` is
|
|
15
|
+
* set, so `result.host` is always already populated.
|
|
16
|
+
* - `family` selects an address family; it does not move `host:port`.
|
|
17
|
+
*
|
|
18
|
+
* Verified against the pinned ioredis 5.11.1. In those cases the endpoint is
|
|
19
|
+
* WITHHELD and the ambiguity named, rather than reported as the authority — a
|
|
20
|
+
* diagnostic whose whole job is to answer "which Redis is this provider on"
|
|
21
|
+
* must not answer it wrongly. The query is never echoed: it can carry a
|
|
22
|
+
* password, which is why `endpoint` is host-and-port only to begin with.
|
|
23
|
+
*/
|
|
24
|
+
function queryMovesEndpoint(parsed) {
|
|
25
|
+
if (parsed.searchParams.has("path"))
|
|
26
|
+
return true;
|
|
27
|
+
return parsed.searchParams.has("port") && parsed.port === "";
|
|
28
|
+
}
|
|
29
|
+
function redisEndpoint(url) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(url);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
// No authority means this is not the `redis://host:port` shape at all (e.g.
|
|
38
|
+
// a bare `host:port`, which parses as a scheme). Report nothing rather than
|
|
39
|
+
// a fragment of a string whose parts we have not identified.
|
|
40
|
+
if (!parsed.hostname)
|
|
41
|
+
return {};
|
|
42
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
43
|
+
if (queryMovesEndpoint(parsed))
|
|
44
|
+
return { scheme, ambiguous: true };
|
|
45
|
+
return {
|
|
46
|
+
scheme,
|
|
47
|
+
endpoint: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function redisReport(resolution) {
|
|
51
|
+
const { endpoint, scheme, ambiguous } = redisEndpoint(resolution.url);
|
|
52
|
+
return {
|
|
53
|
+
parsed: endpoint !== undefined || ambiguous === true,
|
|
54
|
+
ambiguous: ambiguous === true,
|
|
55
|
+
report: {
|
|
56
|
+
backend: "redis",
|
|
57
|
+
envName: resolution.envName,
|
|
58
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
59
|
+
...(scheme === undefined ? {} : { scheme }),
|
|
60
|
+
...(resolution.fallback ? { fallback: true } : {}),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the boot event from the same env precedence the stores themselves use
|
|
66
|
+
* (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
|
|
67
|
+
* so the line can never describe a backend other than the one in use.
|
|
68
|
+
*/
|
|
69
|
+
export function providerStateBackendLogEvent(input) {
|
|
70
|
+
const warnings = [];
|
|
71
|
+
let state;
|
|
72
|
+
if (input.injectedState) {
|
|
73
|
+
state = { backend: "injected" };
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const resolution = providerStateRedisResolutionFromEnv();
|
|
77
|
+
if (resolution) {
|
|
78
|
+
const { report, parsed, ambiguous } = redisReport(resolution);
|
|
79
|
+
state = report;
|
|
80
|
+
if (!parsed)
|
|
81
|
+
warnings.push("state_redis_url_unparsed");
|
|
82
|
+
if (ambiguous)
|
|
83
|
+
warnings.push("state_redis_url_ambiguous_endpoint");
|
|
84
|
+
if (resolution.fallback)
|
|
85
|
+
warnings.push("state_redis_url_fallback");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
state = { backend: input.allowMemoryFallback ? "memory" : "unsupported" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const cacheResolution = providerCacheRedisResolutionFromEnv();
|
|
92
|
+
let cache;
|
|
93
|
+
if (cacheResolution) {
|
|
94
|
+
const { report, parsed, ambiguous } = redisReport(cacheResolution);
|
|
95
|
+
cache = report;
|
|
96
|
+
if (!parsed)
|
|
97
|
+
warnings.push("cache_redis_url_unparsed");
|
|
98
|
+
if (ambiguous)
|
|
99
|
+
warnings.push("cache_redis_url_ambiguous_endpoint");
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
cache = { backend: "memory" };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
level: warnings.length > 0 ? "warn" : "info",
|
|
106
|
+
event: "provider_state_backend",
|
|
107
|
+
providerId: input.providerId,
|
|
108
|
+
state,
|
|
109
|
+
cache,
|
|
110
|
+
sdkVersion: input.sdkVersion,
|
|
111
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
112
|
+
};
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { AuthMode, ProviderDefinition } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The connection the local CLIs stand in for the gateway with.
|
|
5
|
+
*
|
|
6
|
+
* At invocation time the gateway resolves the credential a provider needs and
|
|
7
|
+
* attaches it to the request envelope as
|
|
8
|
+
* `connection: { id, mode, secrets, metadata }`; `serve` then builds
|
|
9
|
+
* `ctx.credential` from `connection.secrets` and nothing else. For
|
|
10
|
+
* `auth.mode: "platform-managed"` the secrets are the platform-owned key the
|
|
11
|
+
* caller never sees — which is why a probe reaches one only by going back
|
|
12
|
+
* through the gateway (`ctx.gateway.execute(...)` in a health case's
|
|
13
|
+
* `prepareInput`), and why nothing that runs outside the gateway had any way
|
|
14
|
+
* to supply it.
|
|
15
|
+
*
|
|
16
|
+
* `apifuse record` hard-coded `credential: { mode: "none" }`, so
|
|
17
|
+
* `ctx.credential.get(...)` returned `undefined` for every platform-managed
|
|
18
|
+
* provider and the run died on that provider's own `MISSING_SECRET`. The same
|
|
19
|
+
* hole made `submit-check --smoke` POST an envelope with no `connection` at
|
|
20
|
+
* all. A large share of the fleet was unrecordable and unsmokeable, and it
|
|
21
|
+
* read as "no live upstream success" rather than as a missing capability.
|
|
22
|
+
*
|
|
23
|
+
* This module is the local resolver for that connection. It does not invent a
|
|
24
|
+
* second credential path: it produces the exact shape the gateway injects, so
|
|
25
|
+
* the provider code under test takes the production branch.
|
|
26
|
+
*/
|
|
27
|
+
export interface LocalConnection {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly mode: AuthMode;
|
|
30
|
+
readonly secrets: Record<string, string>;
|
|
31
|
+
readonly metadata: Record<string, never>;
|
|
32
|
+
/**
|
|
33
|
+
* Empty, exactly as the gateway leaves it when it injects a platform
|
|
34
|
+
* credential. `OperationConnectionSchema` requires the field, so omitting
|
|
35
|
+
* it would make every smoke envelope fail request validation before the
|
|
36
|
+
* provider ran.
|
|
37
|
+
*/
|
|
38
|
+
readonly externalRef: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* JSON object of `connection.secrets`, keyed exactly as the provider reads
|
|
43
|
+
* them through `ctx.credential.get(...)`.
|
|
44
|
+
*
|
|
45
|
+
* A single JSON value rather than one variable per key on purpose:
|
|
46
|
+
* platform-managed providers must not declare `credential.keys` (the SDK lint
|
|
47
|
+
* rejects it), so the key names are known only to the provider's own source.
|
|
48
|
+
* There is no derivation from an env name that would be right for every
|
|
49
|
+
* provider, and guessing one would be a second, lossy contract next to the
|
|
50
|
+
* gateway's.
|
|
51
|
+
*/
|
|
52
|
+
export const LOCAL_CONNECTION_SECRETS_ENV = "APIFUSE__LOCAL_CONNECTION__SECRETS";
|
|
53
|
+
|
|
54
|
+
/** Stable local connection id, mirroring the CLIs' other `local-*` scopes. */
|
|
55
|
+
export const LOCAL_CONNECTION_ID = "local-connection";
|
|
56
|
+
|
|
57
|
+
/** Auth modes whose providers read a credential the gateway supplies. */
|
|
58
|
+
export function providerRequiresConnection(provider: {
|
|
59
|
+
readonly auth?: { readonly mode?: AuthMode } | undefined;
|
|
60
|
+
}): boolean {
|
|
61
|
+
const mode = provider.auth?.mode;
|
|
62
|
+
return mode !== undefined && mode !== "none";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CredentialOverrideParse =
|
|
66
|
+
| { readonly ok: true; readonly secrets: Record<string, string> }
|
|
67
|
+
| { readonly ok: false; readonly reason: string };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parses repeated `--credential key=value` arguments.
|
|
71
|
+
*
|
|
72
|
+
* Splits on the first `=` only: a base64 or URL-shaped secret routinely
|
|
73
|
+
* contains more.
|
|
74
|
+
*/
|
|
75
|
+
export function parseCredentialOverrides(values: readonly string[]): CredentialOverrideParse {
|
|
76
|
+
const secrets: Record<string, string> = {};
|
|
77
|
+
for (const [index, value] of values.entries()) {
|
|
78
|
+
// The offending argument is never echoed. `--credential =secret` and
|
|
79
|
+
// `--credential secret` are both malformed *and* carry the credential, so
|
|
80
|
+
// quoting the input to be helpful would print it to stderr — before any
|
|
81
|
+
// redaction context exists, so no caller could scrub it.
|
|
82
|
+
const separator = value.indexOf("=");
|
|
83
|
+
if (separator <= 0) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: `--credential #${index + 1} is not of the form key=value; its value is not echoed here because it may be the credential.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const key = value.slice(0, separator).trim();
|
|
90
|
+
if (key.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: `--credential #${index + 1} has an empty key; its value is not echoed here because it may be the credential.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
secrets[key] = value.slice(separator + 1);
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, secrets };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Reads {@link LOCAL_CONNECTION_SECRETS_ENV} into a secrets map. */
|
|
102
|
+
export function parseEnvironmentSecrets(raw: string | undefined): CredentialOverrideParse {
|
|
103
|
+
if (raw === undefined || raw.trim().length === 0) {
|
|
104
|
+
return { ok: true, secrets: {} };
|
|
105
|
+
}
|
|
106
|
+
let parsed: unknown;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(raw);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: `${LOCAL_CONNECTION_SECRETS_ENV} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: `${LOCAL_CONNECTION_SECRETS_ENV} must be a JSON object of credential key to string value.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const secrets: Record<string, string> = {};
|
|
122
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
123
|
+
if (typeof value !== "string") {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: `${LOCAL_CONNECTION_SECRETS_ENV}.${key} must be a string (received ${typeof value}); the value is not echoed here.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
secrets[key] = value;
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, secrets };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ResolveLocalConnectionOptions {
|
|
135
|
+
/** Repeated `--credential key=value` arguments, in order. */
|
|
136
|
+
readonly overrides?: readonly string[];
|
|
137
|
+
/** Environment to read {@link LOCAL_CONNECTION_SECRETS_ENV} from. */
|
|
138
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type LocalConnectionResolution =
|
|
142
|
+
| { readonly ok: true; readonly connection: LocalConnection | undefined }
|
|
143
|
+
| { readonly ok: false; readonly reason: string };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolves the connection to attach for one local invocation.
|
|
147
|
+
*
|
|
148
|
+
* Three outcomes, and the middle one is the point of the change:
|
|
149
|
+
*
|
|
150
|
+
* - the provider needs no credential (`auth.mode` absent or `"none"`) →
|
|
151
|
+
* no connection, exactly as before;
|
|
152
|
+
* - the provider needs one and none is configured → `ok: false` with a
|
|
153
|
+
* message naming both ways to supply it. The caller surfaces that instead
|
|
154
|
+
* of letting the provider fail deep inside a handler on a `MISSING_SECRET`
|
|
155
|
+
* that reads like an upstream problem;
|
|
156
|
+
* - the provider needs one and it is configured → the gateway's envelope
|
|
157
|
+
* shape.
|
|
158
|
+
*
|
|
159
|
+
* CLI overrides win over the environment so a one-off run does not need the
|
|
160
|
+
* shell variable unset.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveLocalConnection(
|
|
163
|
+
provider: Pick<ProviderDefinition, "id"> & { auth?: { mode?: AuthMode } },
|
|
164
|
+
options: ResolveLocalConnectionOptions = {},
|
|
165
|
+
): LocalConnectionResolution {
|
|
166
|
+
const fromEnv = parseEnvironmentSecrets(
|
|
167
|
+
(options.env ?? process.env)[LOCAL_CONNECTION_SECRETS_ENV],
|
|
168
|
+
);
|
|
169
|
+
if (!fromEnv.ok) return fromEnv;
|
|
170
|
+
|
|
171
|
+
const fromArgs = parseCredentialOverrides(options.overrides ?? []);
|
|
172
|
+
if (!fromArgs.ok) return fromArgs;
|
|
173
|
+
|
|
174
|
+
const secrets = { ...fromEnv.secrets, ...fromArgs.secrets };
|
|
175
|
+
const mode = provider.auth?.mode;
|
|
176
|
+
|
|
177
|
+
if (!providerRequiresConnection(provider)) {
|
|
178
|
+
if (Object.keys(secrets).length > 0) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: `Provider "${provider.id}" declares auth.mode ${JSON.stringify(mode ?? "none")}, which takes no credential. Remove the supplied credential values.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, connection: undefined };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (Object.keys(secrets).length === 0) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: missingLocalConnectionMessage(provider.id, mode ?? "none"),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
connection: {
|
|
197
|
+
id: LOCAL_CONNECTION_ID,
|
|
198
|
+
// `mode` is narrowed by providerRequiresConnection above.
|
|
199
|
+
mode: mode ?? "none",
|
|
200
|
+
secrets,
|
|
201
|
+
metadata: {},
|
|
202
|
+
externalRef: "",
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The message a caller prints when a needed credential is not configured. */
|
|
208
|
+
export function missingLocalConnectionMessage(providerId: string, mode: AuthMode): string {
|
|
209
|
+
const platformManaged = mode === "platform-managed";
|
|
210
|
+
return [
|
|
211
|
+
`Provider "${providerId}" declares auth.mode "${mode}", so the gateway injects a credential on every invocation and this command has none.`,
|
|
212
|
+
platformManaged
|
|
213
|
+
? "The value is the platform-owned key an operator holds; it is not a provider secret and is not derivable from the checkout."
|
|
214
|
+
: "Supply the same credential the gateway would resolve for a connection.",
|
|
215
|
+
"Supply it with one of:",
|
|
216
|
+
" --credential <key>=<value> (repeatable)",
|
|
217
|
+
` ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}'`,
|
|
218
|
+
"The keys are the ones the provider passes to ctx.credential.get(...).",
|
|
219
|
+
"Pass --no-credential to run without one anyway (the provider will fail the way it does in production when the credential is unavailable).",
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|