@apifuse/provider-sdk 2.2.0-beta.25 → 2.2.0-beta.26
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 +7 -6
- package/CHANGELOG.md +5 -1
- package/README.md +3 -3
- package/bin/apifuse-check.ts +62 -3
- package/bin/apifuse-pack-check.ts +8 -2
- package/bin/apifuse-pack-smoke.ts +43 -2
- package/bin/apifuse-pack-types.ts +58 -0
- package/dist/auth.js +29 -0
- package/dist/cli/templates/provider/README.md.tpl +4 -4
- package/dist/contract-serialization.d.ts +20 -1
- package/dist/contract-serialization.js +583 -8
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +9 -5
- package/dist/declaration-validation.d.ts +23 -0
- package/dist/declaration-validation.js +159 -0
- package/dist/define.d.ts +1 -1
- package/dist/define.js +13 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/lint.js +85 -3
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/resolver-vendors/bindings.d.ts +42 -2
- package/dist/runtime/resolver-vendors/bindings.js +31 -6
- package/dist/runtime/resolver-vendors/browser.d.ts +3 -7
- package/dist/runtime/resolver-vendors/browser.js +7 -22
- package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
- package/dist/runtime/resolver-vendors/hosts.js +33 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +23 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +264 -0
- package/dist/runtime/resolver-vendors/types.d.ts +44 -3
- package/dist/runtime/resolver-vendors/types.js +10 -0
- package/dist/runtime/resolver.d.ts +17 -2
- package/dist/runtime/resolver.js +237 -15
- package/dist/runtime/stealth.d.ts +26 -4
- package/dist/runtime/stealth.js +224 -114
- package/dist/schema.d.ts +63 -0
- package/dist/schema.js +808 -8
- package/dist/server/serve.js +8 -0
- package/dist/stealth/profiles.js +16 -7
- package/dist/types.d.ts +34 -1
- package/package.json +2 -2
- package/src/auth.ts +40 -0
- package/src/cli/templates/provider/README.md.tpl +4 -4
- package/src/contract-serialization.ts +857 -8
- package/src/contract.ts +16 -5
- package/src/declaration-validation.ts +202 -0
- package/src/define.ts +23 -2
- package/src/index.ts +12 -0
- package/src/lint.ts +98 -3
- package/src/provider.ts +10 -0
- package/src/runtime/resolver-vendors/bindings.ts +40 -15
- package/src/runtime/resolver-vendors/browser.ts +9 -31
- package/src/runtime/resolver-vendors/hosts.ts +38 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +366 -0
- package/src/runtime/resolver-vendors/types.ts +54 -0
- package/src/runtime/resolver.ts +304 -24
- package/src/runtime/stealth.ts +317 -136
- package/src/schema.ts +1060 -9
- package/src/server/serve.ts +8 -0
- package/src/stealth/profiles.ts +17 -7
- package/src/types.ts +36 -3
package/src/contract.ts
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
type JsonValue,
|
|
8
8
|
toJsonValue,
|
|
9
9
|
} from "./contract-json.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
describeSchema,
|
|
12
|
+
OutputTextTrustProjectionError,
|
|
13
|
+
serializeSmsMatcher,
|
|
14
|
+
} from "./contract-serialization.js";
|
|
11
15
|
import {
|
|
12
16
|
PROVIDER_CONTRACT_SCHEMA_VERSION,
|
|
13
17
|
type ProviderContractOperation,
|
|
@@ -30,6 +34,7 @@ export {
|
|
|
30
34
|
type ProviderContractOperation,
|
|
31
35
|
type ProviderContractSnapshot,
|
|
32
36
|
};
|
|
37
|
+
export { OutputTextTrustProjectionError };
|
|
33
38
|
|
|
34
39
|
export function extractProviderContract(provider: ProviderDefinition): ProviderContractSnapshot {
|
|
35
40
|
const auth = extractAuth(provider.auth);
|
|
@@ -94,7 +99,7 @@ function extractOperation(
|
|
|
94
99
|
const relatedOperations = toJsonValue(operation.relatedOperations);
|
|
95
100
|
const toolRouter = toJsonValue(operation.toolRouter);
|
|
96
101
|
const observability = toJsonValue(operation.observability);
|
|
97
|
-
const transport = extractTransport(operation.transport);
|
|
102
|
+
const transport = extractTransport(operation.transport, operationId);
|
|
98
103
|
const fixtures = toJsonValue(operation.fixtures);
|
|
99
104
|
const upstream = toJsonValue(operation.upstream);
|
|
100
105
|
const hints = toJsonValue(operation.hints);
|
|
@@ -104,7 +109,7 @@ function extractOperation(
|
|
|
104
109
|
return {
|
|
105
110
|
id: operationId,
|
|
106
111
|
inputSchema: describeSchema(operation.input),
|
|
107
|
-
outputSchema: describeSchema(operation.output),
|
|
112
|
+
outputSchema: describeSchema(operation.output, { operationId, outputTextTrust: true }),
|
|
108
113
|
...(descriptionKey === undefined ? {} : { descriptionKey }),
|
|
109
114
|
...(docs === undefined ? {} : { docs }),
|
|
110
115
|
...(whenToUseKeys === undefined ? {} : { whenToUseKeys }),
|
|
@@ -141,7 +146,10 @@ function extractAuth(value: ProviderDefinition["auth"]): JsonValue | undefined {
|
|
|
141
146
|
});
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
function extractTransport(
|
|
149
|
+
function extractTransport(
|
|
150
|
+
value: OperationTransport | undefined,
|
|
151
|
+
operationId: string,
|
|
152
|
+
): JsonValue | undefined {
|
|
145
153
|
if (!value) return undefined;
|
|
146
154
|
if (value.kind !== "sse") return toJsonValue(value);
|
|
147
155
|
return compactObject({
|
|
@@ -149,7 +157,10 @@ function extractTransport(value: OperationTransport | undefined): JsonValue | un
|
|
|
149
157
|
events: Object.fromEntries(
|
|
150
158
|
Object.entries(value.events)
|
|
151
159
|
.sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
|
|
152
|
-
.map(([eventName, schema]) => [
|
|
160
|
+
.map(([eventName, schema]) => [
|
|
161
|
+
eventName,
|
|
162
|
+
describeSchema(schema, { eventName, operationId, outputTextTrust: true }),
|
|
163
|
+
]),
|
|
153
164
|
),
|
|
154
165
|
});
|
|
155
166
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describeSchema } from "./contract-serialization.js";
|
|
2
|
+
import { ProviderError } from "./errors.js";
|
|
3
|
+
import type {
|
|
4
|
+
HealthJourneyDefinition,
|
|
5
|
+
ProviderDefinition,
|
|
6
|
+
ProviderProxyPolicy,
|
|
7
|
+
ProviderProxyProvider,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
export const DECLARATION_INVALID_CODE = "DECLARATION_INVALID";
|
|
11
|
+
|
|
12
|
+
export const DECLARATION_RULE_IDS = {
|
|
13
|
+
challengeShape: "credentials-challenge-shape",
|
|
14
|
+
journeyExecutable: "health-journey-executable",
|
|
15
|
+
schemaSerializable: "operation-schema-serializable",
|
|
16
|
+
proxyExplicitPolicy: "proxy-explicit-policy",
|
|
17
|
+
proxyVendorExclusive: "proxy-vendor-fields-exclusive",
|
|
18
|
+
proxyNoMixedVendors: "proxy-no-mixed-vendors",
|
|
19
|
+
proxySmartproxyGeo: "proxy-smartproxy-country-only",
|
|
20
|
+
operationUpstreamProxy: "operation-upstream-proxy-unsupported",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type DeclarationRuleId =
|
|
24
|
+
(typeof DECLARATION_RULE_IDS)[keyof typeof DECLARATION_RULE_IDS];
|
|
25
|
+
|
|
26
|
+
export type DeclarationViolation = {
|
|
27
|
+
ruleId: DeclarationRuleId;
|
|
28
|
+
path: string;
|
|
29
|
+
message: string;
|
|
30
|
+
fix: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function declarationInvalidError(
|
|
34
|
+
violations: readonly DeclarationViolation[],
|
|
35
|
+
): ProviderError {
|
|
36
|
+
const summary = violations
|
|
37
|
+
.map((violation) => `${violation.path} [${violation.ruleId}]: ${violation.message}`)
|
|
38
|
+
.join("\n");
|
|
39
|
+
return new ProviderError(
|
|
40
|
+
`Provider declaration is invalid (${violations.length} violation${violations.length === 1 ? "" : "s"}).${summary ? `\n${summary}` : ""}`,
|
|
41
|
+
{
|
|
42
|
+
code: DECLARATION_INVALID_CODE,
|
|
43
|
+
details: { violations: [...violations] },
|
|
44
|
+
fix: "Apply every violation's fix hint, then validate the declaration again.",
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Enforces declaration rules whose runtime behavior would otherwise fail open. */
|
|
50
|
+
export function validateFailClosedDeclaration(provider: ProviderDefinition): void {
|
|
51
|
+
const violations: DeclarationViolation[] = [];
|
|
52
|
+
validateHealthDeclaration(provider, violations);
|
|
53
|
+
validateSchemaDeclaration(provider, violations);
|
|
54
|
+
validateProxyDeclaration(provider, violations);
|
|
55
|
+
validateOperationDeclaration(provider, violations);
|
|
56
|
+
if (violations.length > 0) throw declarationInvalidError(violations);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateHealthDeclaration(
|
|
60
|
+
provider: ProviderDefinition,
|
|
61
|
+
violations: DeclarationViolation[],
|
|
62
|
+
): void {
|
|
63
|
+
for (const [index, journey] of (provider.healthJourneys ?? []).entries()) {
|
|
64
|
+
if (!journey || typeof journey !== "object") continue;
|
|
65
|
+
if (typeof journey.run !== "function") {
|
|
66
|
+
const journeyPath = healthJourneyPath(journey, index);
|
|
67
|
+
violations.push({
|
|
68
|
+
ruleId: DECLARATION_RULE_IDS.journeyExecutable,
|
|
69
|
+
path: `${journeyPath}.run`,
|
|
70
|
+
message: "coversOperations cannot provide health coverage without executable run logic.",
|
|
71
|
+
fix: `Add an async run(ctx) implementation to ${journeyPath}.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// NOTE: healthCheck.cases[].enabled is intentionally NOT validated here.
|
|
77
|
+
// self-test.ts reports a gated case as status "skipped" with skipReason
|
|
78
|
+
// "disabled", so the skip is visible in results rather than silent — it is a
|
|
79
|
+
// supported conditional-execution feature, not a class-1 silent no-op.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function healthJourneyPath(journey: HealthJourneyDefinition, index: number): string {
|
|
83
|
+
return typeof journey.id === "string" && journey.id.length > 0
|
|
84
|
+
? `healthJourneys.${journey.id}`
|
|
85
|
+
: `healthJourneys[${index}]`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateSchemaDeclaration(
|
|
89
|
+
provider: ProviderDefinition,
|
|
90
|
+
violations: DeclarationViolation[],
|
|
91
|
+
): void {
|
|
92
|
+
for (const [operationId, operation] of Object.entries(provider.operations ?? {})) {
|
|
93
|
+
const schemaEntries: Array<[string, unknown]> = [
|
|
94
|
+
[`operations.${operationId}.input`, operation.input],
|
|
95
|
+
[`operations.${operationId}.output`, operation.output],
|
|
96
|
+
];
|
|
97
|
+
// SSE event schemas reach contract extraction the same way input/output do,
|
|
98
|
+
// so a transform-bearing event schema would abort extraction at runtime while
|
|
99
|
+
// passing declaration checks. Validate them under the same rule.
|
|
100
|
+
const transport = operation.transport;
|
|
101
|
+
if (transport?.kind === "sse") {
|
|
102
|
+
for (const [eventName, eventSchema] of Object.entries(transport.events ?? {})) {
|
|
103
|
+
schemaEntries.push([
|
|
104
|
+
`operations.${operationId}.transport.events.${eventName}`,
|
|
105
|
+
eventSchema,
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const [path, schema] of schemaEntries) {
|
|
110
|
+
try {
|
|
111
|
+
describeSchema(schema as Parameters<typeof describeSchema>[0]);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
114
|
+
violations.push({
|
|
115
|
+
ruleId: DECLARATION_RULE_IDS.schemaSerializable,
|
|
116
|
+
path,
|
|
117
|
+
message: `schema conversion to JSON Schema failed: ${reason}`,
|
|
118
|
+
fix: `Replace unsupported constructs in ${path} so z.toJSONSchema() succeeds.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const MANAGED_PROXY_VENDORS = new Set<ProviderProxyProvider>(["smartproxy", "nodemaven"]);
|
|
126
|
+
const STATIC_PROXY_VENDORS = new Set<ProviderProxyProvider>(["custom", "decodo"]);
|
|
127
|
+
|
|
128
|
+
function validateProxyDeclaration(
|
|
129
|
+
provider: ProviderDefinition,
|
|
130
|
+
violations: DeclarationViolation[],
|
|
131
|
+
): void {
|
|
132
|
+
if (provider.proxy === true) {
|
|
133
|
+
violations.push({
|
|
134
|
+
ruleId: DECLARATION_RULE_IDS.proxyExplicitPolicy,
|
|
135
|
+
path: "proxy",
|
|
136
|
+
message: "proxy: true does not require resolvable proxy egress.",
|
|
137
|
+
fix: 'Replace proxy: true with an explicit policy such as proxy: { mode: "required", providers: ["smartproxy"] }.',
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!provider.proxy || typeof provider.proxy !== "object") return;
|
|
142
|
+
|
|
143
|
+
const policy = provider.proxy as ProviderProxyPolicy;
|
|
144
|
+
const hasProvider = policy.provider !== undefined;
|
|
145
|
+
const hasProviders = policy.providers !== undefined;
|
|
146
|
+
if (hasProvider && hasProviders) {
|
|
147
|
+
violations.push({
|
|
148
|
+
ruleId: DECLARATION_RULE_IDS.proxyVendorExclusive,
|
|
149
|
+
path: "proxy",
|
|
150
|
+
message: "provider and providers are ambiguous when declared together.",
|
|
151
|
+
fix: "Keep either proxy.provider or proxy.providers, and remove the other field.",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const vendors = declaredProxyVendors(policy);
|
|
156
|
+
if (
|
|
157
|
+
vendors.some((vendor) => MANAGED_PROXY_VENDORS.has(vendor)) &&
|
|
158
|
+
vendors.some((vendor) => STATIC_PROXY_VENDORS.has(vendor))
|
|
159
|
+
) {
|
|
160
|
+
violations.push({
|
|
161
|
+
ruleId: DECLARATION_RULE_IDS.proxyNoMixedVendors,
|
|
162
|
+
path: hasProviders ? "proxy.providers" : "proxy.provider",
|
|
163
|
+
message: "managed and deprecated static proxy vendors cannot share a chain.",
|
|
164
|
+
fix: "Use only smartproxy/nodemaven vendors, or only deprecated static markers, in one policy.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (vendors.includes("smartproxy")) {
|
|
169
|
+
for (const field of ["subdivision", "city"] as const) {
|
|
170
|
+
if (policy.geo?.[field] === undefined) continue;
|
|
171
|
+
const path = `proxy.geo.${field}`;
|
|
172
|
+
violations.push({
|
|
173
|
+
ruleId: DECLARATION_RULE_IDS.proxySmartproxyGeo,
|
|
174
|
+
path,
|
|
175
|
+
message: `smartproxy cannot honor ${field}-level geo targeting.`,
|
|
176
|
+
fix: `Remove ${path} or use a vendor chain that can honor it.`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function declaredProxyVendors(policy: ProviderProxyPolicy): ProviderProxyProvider[] {
|
|
183
|
+
const vendors = [...(policy.providers ?? [])];
|
|
184
|
+
if (policy.provider !== undefined) vendors.push(policy.provider);
|
|
185
|
+
return vendors;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateOperationDeclaration(
|
|
189
|
+
provider: ProviderDefinition,
|
|
190
|
+
violations: DeclarationViolation[],
|
|
191
|
+
): void {
|
|
192
|
+
for (const [operationId, operation] of Object.entries(provider.operations ?? {})) {
|
|
193
|
+
if (!operation.upstream?.proxy) continue;
|
|
194
|
+
const path = `operations.${operationId}.upstream.proxy`;
|
|
195
|
+
violations.push({
|
|
196
|
+
ruleId: DECLARATION_RULE_IDS.operationUpstreamProxy,
|
|
197
|
+
path,
|
|
198
|
+
message: "operation-level proxy policy is not wired into operation execution.",
|
|
199
|
+
fix: `Remove ${path} and declare the effective policy at provider.proxy.`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/define.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ms from "ms";
|
|
2
2
|
|
|
3
|
+
import { validateFailClosedDeclaration } from "./declaration-validation.js";
|
|
3
4
|
import { SDK_RUNTIME_OWNED_ERROR_CODES } from "./error-resolution.js";
|
|
4
5
|
import { ProviderError, ValidationError } from "./errors.js";
|
|
5
6
|
import {
|
|
@@ -152,6 +153,8 @@ export const VALID_PROVIDER_CHALLENGE_KINDS = exhaustiveLiteralArray<ProviderCha
|
|
|
152
153
|
"hcaptcha",
|
|
153
154
|
"cloudflare_interstitial",
|
|
154
155
|
"aws_waf",
|
|
156
|
+
"akamai_sec_cpt",
|
|
157
|
+
"akamai_sensor",
|
|
155
158
|
] as const);
|
|
156
159
|
const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
|
|
157
160
|
const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
@@ -759,7 +762,12 @@ function validateProviderResolver(config: { id: string; resolver?: ProviderResol
|
|
|
759
762
|
fix: `Set resolver for provider "${config.id}" to { vendors: ["2captcha"], kinds: ["turnstile"] }.`,
|
|
760
763
|
});
|
|
761
764
|
}
|
|
762
|
-
rejectUnknownFields(
|
|
765
|
+
rejectUnknownFields(
|
|
766
|
+
resolver,
|
|
767
|
+
new Set(["vendors", "kinds", "clientProfile"]),
|
|
768
|
+
"resolver",
|
|
769
|
+
config.id,
|
|
770
|
+
);
|
|
763
771
|
validateResolverLiteralArray(
|
|
764
772
|
resolver.vendors,
|
|
765
773
|
"resolver.vendors",
|
|
@@ -772,6 +780,17 @@ function validateProviderResolver(config: { id: string; resolver?: ProviderResol
|
|
|
772
780
|
VALID_PROVIDER_CHALLENGE_KINDS,
|
|
773
781
|
config.id,
|
|
774
782
|
);
|
|
783
|
+
if (
|
|
784
|
+
resolver.clientProfile !== undefined &&
|
|
785
|
+
(typeof resolver.clientProfile !== "string" || !resolver.clientProfile.trim())
|
|
786
|
+
) {
|
|
787
|
+
throw new ValidationError(
|
|
788
|
+
`Provider "${config.id}" has invalid resolver.clientProfile: must be a non-empty string.`,
|
|
789
|
+
{
|
|
790
|
+
fix: `Set resolver.clientProfile for provider "${config.id}" to a transport-owned profile name.`,
|
|
791
|
+
},
|
|
792
|
+
);
|
|
793
|
+
}
|
|
775
794
|
}
|
|
776
795
|
|
|
777
796
|
function validateResolverLiteralArray<TValue extends string>(
|
|
@@ -2511,7 +2530,7 @@ export function defineProvider<
|
|
|
2511
2530
|
`Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
|
|
2512
2531
|
{ fix: 'Set runtime: "browser" or remove the browser config' },
|
|
2513
2532
|
);
|
|
2514
|
-
|
|
2533
|
+
const provider: ProviderDefinition & { operations: OperationMapConfig<TOperations> } = {
|
|
2515
2534
|
id: config.id,
|
|
2516
2535
|
version: config.version,
|
|
2517
2536
|
runtime: config.runtime,
|
|
@@ -2540,4 +2559,6 @@ export function defineProvider<
|
|
|
2540
2559
|
healthProbe: config.healthProbe ?? config.healthMonitor,
|
|
2541
2560
|
healthJourneys: config.healthJourneys,
|
|
2542
2561
|
};
|
|
2562
|
+
validateFailClosedDeclaration(provider);
|
|
2563
|
+
return provider;
|
|
2543
2564
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
extractProviderContract,
|
|
21
21
|
type JsonPrimitive,
|
|
22
22
|
type JsonValue,
|
|
23
|
+
OutputTextTrustProjectionError,
|
|
23
24
|
PROVIDER_CONTRACT_SCHEMA_VERSION,
|
|
24
25
|
type ProviderContractOperation,
|
|
25
26
|
type ProviderContractSnapshot,
|
|
@@ -113,6 +114,7 @@ export {
|
|
|
113
114
|
invalidateResolverSolution,
|
|
114
115
|
type ResolverRuntimeOptions,
|
|
115
116
|
} from "./runtime/resolver.js";
|
|
117
|
+
export type { ResolverVendorTransport } from "./runtime/resolver-vendors/types.js";
|
|
116
118
|
export {
|
|
117
119
|
assertRequiredSecretsPresent,
|
|
118
120
|
listMissingRequiredSecrets,
|
|
@@ -155,20 +157,30 @@ export {
|
|
|
155
157
|
type TraceContext,
|
|
156
158
|
} from "./runtime/trace.js";
|
|
157
159
|
export {
|
|
160
|
+
APIFUSE_TEXT_TRUST_META_KEY,
|
|
158
161
|
APIFUSE_DESCRIPTION_KEY_META_KEY,
|
|
159
162
|
APIFUSE_REDACTION_MARKER,
|
|
160
163
|
APIFUSE_SENSITIVE_KIND_META_KEY,
|
|
161
164
|
APIFUSE_SENSITIVE_META_KEY,
|
|
165
|
+
AUTO_TRUSTED_ZOD_STRING_FORMATS,
|
|
166
|
+
collectOutputTextTrust,
|
|
162
167
|
collectSensitivePaths,
|
|
163
168
|
describeKey,
|
|
164
169
|
field,
|
|
165
170
|
fields,
|
|
171
|
+
findUnclassifiedOutputTextPaths,
|
|
166
172
|
isSensitiveSchema,
|
|
173
|
+
OutputTextTrustCollectionError,
|
|
174
|
+
OutputTextTrustSchemaError,
|
|
167
175
|
redactPayload,
|
|
176
|
+
type OutputTextTrustMap,
|
|
168
177
|
type SensitiveFieldKind,
|
|
169
178
|
type SensitiveFieldOptions,
|
|
170
179
|
type SensitivePath,
|
|
171
180
|
sensitive,
|
|
181
|
+
textTrust,
|
|
182
|
+
type TextTrust,
|
|
183
|
+
type TextTrustMetadata,
|
|
172
184
|
z,
|
|
173
185
|
} from "./schema.js";
|
|
174
186
|
export {
|
package/src/lint.ts
CHANGED
|
@@ -27,9 +27,97 @@ type ProviderAuthLike = {
|
|
|
27
27
|
exchange?: unknown;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
// Operations that perform an auth-lifecycle action belong on the single
|
|
31
|
+
// `auth.flow` interface, never on a provider operation:
|
|
32
|
+
// - entry (login / signin / authenticate) => auth.flow.start/continue
|
|
33
|
+
// - exit (logout / signout / disconnect) => auth.flow.abort
|
|
34
|
+
//
|
|
35
|
+
// Matching works on `-`/`_` separated segments rather than a raw substring or a
|
|
36
|
+
// leading anchor, so `shop-logout`, `shop_logout` and `user-sign-out-everywhere`
|
|
37
|
+
// are all recognised: a domain prefix does not make the operation any less of an
|
|
38
|
+
// auth-lifecycle action, and operation ids may use either separator.
|
|
39
|
+
//
|
|
40
|
+
// Vocabulary is split into two tiers because auth words collide with ordinary
|
|
41
|
+
// domain verbs. Measured against the live fleet plus synthetic domain ids:
|
|
42
|
+
// - `authorize-payment`, `revoke-invitation`, `unlink-record`,
|
|
43
|
+
// `disconnect-device` are domain actions that never touch the connection
|
|
44
|
+
// credential, so these verbs are NOT matched as segments;
|
|
45
|
+
// - the same verbs as a complete operation id (`authorize`, `revoke`) do
|
|
46
|
+
// refer to the credential itself, so they are matched only in that form.
|
|
47
|
+
// `exchange`, `callback`, `connect`, `session`, `token`, `credential`,
|
|
48
|
+
// `password` and `otp` stay out entirely for the same reason.
|
|
49
|
+
const AUTH_LIFECYCLE_SEGMENT_WORDS = new Set([
|
|
50
|
+
"login",
|
|
51
|
+
"logout",
|
|
52
|
+
"signin",
|
|
53
|
+
"signout",
|
|
54
|
+
"signup",
|
|
55
|
+
"authenticate",
|
|
56
|
+
"reauth",
|
|
57
|
+
"auth",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Ambiguous as a prefix, unambiguous when they are the whole operation id.
|
|
61
|
+
const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set([
|
|
62
|
+
"authorize",
|
|
63
|
+
"revoke",
|
|
64
|
+
"unlink",
|
|
65
|
+
"disconnect",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// A verb stem followed by a direction word across two segments: `sign-out`,
|
|
69
|
+
// `user_sign_up_flow`, and the spelled-out `log-in` / `shop-log-out` forms
|
|
70
|
+
// (their fused equivalents `login`/`logout` live in the segment set above).
|
|
71
|
+
// `sign` pairs match anywhere; `log` pairs match only at the END of the id,
|
|
72
|
+
// because mid-id `log` is the noun in domain phrases measured against real
|
|
73
|
+
// fleets (`audit-log-in-range`, `change-log-out-of-band` are reads of a log,
|
|
74
|
+
// while `shop-log-out` is a logout).
|
|
75
|
+
const AUTH_DIRECTION_PAIRS: ReadonlyMap<
|
|
76
|
+
string,
|
|
77
|
+
{ directions: ReadonlySet<string>; endOnly: boolean }
|
|
78
|
+
> = new Map([
|
|
79
|
+
["sign", { directions: new Set(["in", "out", "up"]), endOnly: false }],
|
|
80
|
+
["log", { directions: new Set(["in", "out"]), endOnly: true }],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
// Legacy anchored form kept for token-plumbing words whose bare use is only
|
|
84
|
+
// auth-related when it leads the operation id (`exchange-code`, `refresh`).
|
|
30
85
|
const AUTH_OPERATION_ID_PATTERN =
|
|
31
86
|
/^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
|
|
32
87
|
|
|
88
|
+
function isAuthLifecycleOperationId(operationId: string, authMode: string): boolean {
|
|
89
|
+
const segments = operationId.toLowerCase().split(/[-_]+/).filter(Boolean);
|
|
90
|
+
|
|
91
|
+
if (segments.some((segment) => AUTH_LIFECYCLE_SEGMENT_WORDS.has(segment))) return true;
|
|
92
|
+
|
|
93
|
+
// A verb stem + direction spread across two segments (`sign-out`,
|
|
94
|
+
// `sign_up`, `shop-log-out`); see AUTH_DIRECTION_PAIRS for positioning.
|
|
95
|
+
if (
|
|
96
|
+
segments.some((segment, index) => {
|
|
97
|
+
const pair = AUTH_DIRECTION_PAIRS.get(segment);
|
|
98
|
+
if (pair === undefined || index + 1 >= segments.length) return false;
|
|
99
|
+
if (!pair.directions.has(segments[index + 1] as string)) return false;
|
|
100
|
+
return pair.endOnly ? index + 2 === segments.length : true;
|
|
101
|
+
})
|
|
102
|
+
) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (segments.length === 1 && AUTH_LIFECYCLE_WHOLE_ID_WORDS.has(segments[0] as string)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The legacy anchored pattern keeps its original scope. It matches ordinary
|
|
111
|
+
// domain ids such as `exchange-rates` and `refresh-catalog`, so extending it
|
|
112
|
+
// to `oauth2_proxied` would spread that behavior to providers it never
|
|
113
|
+
// applied to; proxied providers are covered by the segment tiers above.
|
|
114
|
+
if (authMode === "credentials" || authMode === "oauth2") {
|
|
115
|
+
return AUTH_OPERATION_ID_PATTERN.test(operationId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
33
121
|
type ProviderContractMetaLike = {
|
|
34
122
|
publicSchemaFieldNames?: "normalized";
|
|
35
123
|
};
|
|
@@ -1265,14 +1353,21 @@ export function lintProvider(
|
|
|
1265
1353
|
|
|
1266
1354
|
if (provider.operations) {
|
|
1267
1355
|
const authMode = provider.auth?.mode;
|
|
1268
|
-
|
|
1356
|
+
// Every authenticated mode owns an auth.flow; `oauth2_proxied` was
|
|
1357
|
+
// previously exempt, which let auth-lifecycle operations ship on
|
|
1358
|
+
// proxied providers unchecked.
|
|
1359
|
+
if (
|
|
1360
|
+
authMode === "credentials" ||
|
|
1361
|
+
authMode === "oauth2" ||
|
|
1362
|
+
authMode === "oauth2_proxied"
|
|
1363
|
+
) {
|
|
1269
1364
|
for (const operationKey of Object.keys(provider.operations)) {
|
|
1270
|
-
if (
|
|
1365
|
+
if (isAuthLifecycleOperationId(operationKey, authMode)) {
|
|
1271
1366
|
diagnostics.push({
|
|
1272
1367
|
rule: "auth-operation-unsupported",
|
|
1273
1368
|
level: "error",
|
|
1274
1369
|
field: `operations.${operationKey}`,
|
|
1275
|
-
message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}"
|
|
1370
|
+
message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" performs an auth-lifecycle action (login, logout, token exchange or similar). Authenticated providers must expose the whole credential lifecycle through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential, and an operation that mutates the session outside that interface leaves the stored connection stale. Move sign-in logic into auth.flow.start/continue and sign-out/disconnect logic into auth.flow.abort (served by POST /auth/disconnect) instead of a provider operation.`,
|
|
1276
1371
|
});
|
|
1277
1372
|
}
|
|
1278
1373
|
}
|
package/src/provider.ts
CHANGED
|
@@ -58,20 +58,30 @@ export {
|
|
|
58
58
|
PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
|
|
59
59
|
} from "./runtime/choice.js";
|
|
60
60
|
export {
|
|
61
|
+
APIFUSE_TEXT_TRUST_META_KEY,
|
|
61
62
|
APIFUSE_DESCRIPTION_KEY_META_KEY,
|
|
62
63
|
APIFUSE_REDACTION_MARKER,
|
|
63
64
|
APIFUSE_SENSITIVE_KIND_META_KEY,
|
|
64
65
|
APIFUSE_SENSITIVE_META_KEY,
|
|
66
|
+
AUTO_TRUSTED_ZOD_STRING_FORMATS,
|
|
67
|
+
collectOutputTextTrust,
|
|
65
68
|
collectSensitivePaths,
|
|
66
69
|
describeKey,
|
|
67
70
|
field,
|
|
68
71
|
fields,
|
|
72
|
+
findUnclassifiedOutputTextPaths,
|
|
69
73
|
isSensitiveSchema,
|
|
74
|
+
OutputTextTrustCollectionError,
|
|
75
|
+
OutputTextTrustSchemaError,
|
|
70
76
|
redactPayload,
|
|
77
|
+
type OutputTextTrustMap,
|
|
71
78
|
type SensitiveFieldKind,
|
|
72
79
|
type SensitiveFieldOptions,
|
|
73
80
|
type SensitivePath,
|
|
74
81
|
sensitive,
|
|
82
|
+
textTrust,
|
|
83
|
+
type TextTrust,
|
|
84
|
+
type TextTrustMetadata,
|
|
75
85
|
z,
|
|
76
86
|
} from "./schema.js";
|
|
77
87
|
export type {
|
|
@@ -1,30 +1,55 @@
|
|
|
1
1
|
import type { ProviderChallenge, ProviderChallengeKind } from "../../types.js";
|
|
2
2
|
import type { ResolverIssuingIdentity } from "./types.js";
|
|
3
3
|
|
|
4
|
+
type ResolverChallengeBinding = {
|
|
5
|
+
readonly cacheable: boolean;
|
|
6
|
+
readonly identityBinding: "none" | "identity_scoped" | "portable";
|
|
7
|
+
readonly directCacheable: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// An IP-bound artifact minted without any recorded egress identity is unsafe to
|
|
11
|
+
// share. The Akamai kinds therefore reject direct caching, while Cloudflare
|
|
12
|
+
// keeps its pre-existing direct-cache behavior pending measurement.
|
|
4
13
|
export const RESOLVER_CHALLENGE_BINDINGS = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
turnstile: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
15
|
+
recaptcha_v2: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
16
|
+
recaptcha_v3: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
17
|
+
hcaptcha: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
18
|
+
cloudflare_interstitial: {
|
|
19
|
+
cacheable: true,
|
|
20
|
+
identityBinding: "identity_scoped",
|
|
21
|
+
directCacheable: true,
|
|
22
|
+
},
|
|
23
|
+
aws_waf: { cacheable: true, identityBinding: "portable", directCacheable: true },
|
|
24
|
+
akamai_sec_cpt: {
|
|
25
|
+
cacheable: true,
|
|
26
|
+
identityBinding: "identity_scoped",
|
|
27
|
+
directCacheable: false,
|
|
28
|
+
},
|
|
29
|
+
akamai_sensor: {
|
|
30
|
+
cacheable: true,
|
|
31
|
+
identityBinding: "identity_scoped",
|
|
32
|
+
directCacheable: false,
|
|
33
|
+
},
|
|
34
|
+
} as const satisfies Readonly<Record<ProviderChallengeKind, ResolverChallengeBinding>>;
|
|
35
|
+
|
|
36
|
+
export function resolverChallengeIsCacheable(challenge: ProviderChallenge): boolean {
|
|
37
|
+
return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].cacheable;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolverChallengeAllowsDirectCache(challenge: ProviderChallenge): boolean {
|
|
41
|
+
return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].directCacheable;
|
|
42
|
+
}
|
|
10
43
|
|
|
11
44
|
export function resolverChallengeIsIdentityScoped(challenge: ProviderChallenge): boolean {
|
|
12
|
-
return
|
|
13
|
-
RESOLVER_CHALLENGE_BINDINGS[challenge.kind as keyof typeof RESOLVER_CHALLENGE_BINDINGS] ===
|
|
14
|
-
"identity_scoped"
|
|
15
|
-
);
|
|
45
|
+
return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "identity_scoped";
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
export function resolverChallengeIssuingIdentity(
|
|
19
49
|
challenge: ProviderChallenge,
|
|
20
50
|
identity: ResolverIssuingIdentity,
|
|
21
51
|
): ResolverIssuingIdentity {
|
|
22
|
-
|
|
23
|
-
RESOLVER_CHALLENGE_BINDINGS as Partial<
|
|
24
|
-
Readonly<Record<ProviderChallengeKind, "identity_scoped" | "portable">>
|
|
25
|
-
>
|
|
26
|
-
)[challenge.kind];
|
|
27
|
-
if (binding === "portable") {
|
|
52
|
+
if (RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "portable") {
|
|
28
53
|
return { userAgent: identity.userAgent };
|
|
29
54
|
}
|
|
30
55
|
return identity;
|