@yiaany/ghostapi 0.1.9 → 0.2.0
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/CHANGELOG.md +21 -0
- package/CONTRIBUTING.md +132 -119
- package/LICENSE +21 -21
- package/PROVENANCE.md +13 -0
- package/README.md +78 -84
- package/SECURITY.md +56 -53
- package/dist/actions/gateway.js +320 -72
- package/dist/actions/gateway.js.map +1 -1
- package/dist/actions/index.d.ts +2 -2
- package/dist/actions/index.js +1 -1
- package/dist/actions/index.js.map +1 -1
- package/dist/agents/agentPrompt.js +7 -5
- package/dist/agents/agentPrompt.js.map +1 -1
- package/dist/ai/aiClient.js +5 -5
- package/dist/ai/aiClient.js.map +1 -1
- package/dist/ai/aiGenerator.js +66 -26
- package/dist/ai/aiGenerator.js.map +1 -1
- package/dist/ai/genericInference.js +151 -36
- package/dist/ai/genericInference.js.map +1 -1
- package/dist/ai/prompts.js +55 -49
- package/dist/ai/prompts.js.map +1 -1
- package/dist/approvals/approvalInbox.js +649 -133
- package/dist/approvals/approvalInbox.js.map +1 -1
- package/dist/approvals/index.d.ts +2 -2
- package/dist/approvals/index.js +1 -1
- package/dist/approvals/index.js.map +1 -1
- package/dist/behavior/behaviorStore.js +34 -14
- package/dist/behavior/behaviorStore.js.map +1 -1
- package/dist/cache/index.js +14 -5
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/index.js +226 -93
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/openUrl.js +17 -3
- package/dist/cli/openUrl.js.map +1 -1
- package/dist/cli/parser.js +79 -18
- package/dist/cli/parser.js.map +1 -1
- package/dist/config/dataPaths.js +4 -2
- package/dist/config/dataPaths.js.map +1 -1
- package/dist/config/localConfig.js +5 -2
- package/dist/config/localConfig.js.map +1 -1
- package/dist/config/serverConfig.js +11 -5
- package/dist/config/serverConfig.js.map +1 -1
- package/dist/contracts/contract.js +311 -83
- package/dist/contracts/contract.js.map +1 -1
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +1 -1
- package/dist/contracts/index.js.map +1 -1
- package/dist/credentials/credentialBroker.js +391 -79
- package/dist/credentials/credentialBroker.js.map +1 -1
- package/dist/credentials/index.d.ts +2 -2
- package/dist/credentials/index.js +1 -1
- package/dist/credentials/index.js.map +1 -1
- package/dist/dashboard/app.js +823 -685
- package/dist/dashboard/dashboard.js.map +1 -1
- package/dist/dashboard/index.html +704 -267
- package/dist/dashboard/styles.css +1040 -349
- package/dist/egress/capabilities.js +55 -25
- package/dist/egress/capabilities.js.map +1 -1
- package/dist/egress/linuxBootstrap.js +47 -11
- package/dist/egress/linuxBootstrap.js.map +1 -1
- package/dist/egress/run.js +170 -31
- package/dist/egress/run.js.map +1 -1
- package/dist/errors/errorEngine.js +15 -3
- package/dist/errors/errorEngine.js.map +1 -1
- package/dist/errors/providerErrors.js.map +1 -1
- package/dist/evals/evals.js +304 -78
- package/dist/evals/evals.js.map +1 -1
- package/dist/evals/index.d.ts +2 -2
- package/dist/evals/index.js +1 -1
- package/dist/evals/index.js.map +1 -1
- package/dist/evidence/index.d.ts +2 -2
- package/dist/evidence/index.js +1 -1
- package/dist/evidence/index.js.map +1 -1
- package/dist/evidence/report.d.ts +1 -1
- package/dist/evidence/report.js +263 -79
- package/dist/evidence/report.js.map +1 -1
- package/dist/fault/faultLab.js +24 -9
- package/dist/fault/faultLab.js.map +1 -1
- package/dist/index.d.ts +42 -42
- package/dist/index.js +21 -21
- package/dist/index.js.map +1 -1
- package/dist/inventory/analysis.d.ts +1 -1
- package/dist/inventory/analysis.js +174 -50
- package/dist/inventory/analysis.js.map +1 -1
- package/dist/inventory/index.d.ts +4 -4
- package/dist/inventory/index.js +3 -3
- package/dist/inventory/index.js.map +1 -1
- package/dist/inventory/inventoryController.js +362 -115
- package/dist/inventory/inventoryController.js.map +1 -1
- package/dist/inventory/types.js +680 -140
- package/dist/inventory/types.js.map +1 -1
- package/dist/ledger/actionLedger.js +355 -78
- package/dist/ledger/actionLedger.js.map +1 -1
- package/dist/ledger/index.d.ts +2 -2
- package/dist/ledger/index.js +1 -1
- package/dist/ledger/index.js.map +1 -1
- package/dist/mcp/server.js +26 -11
- package/dist/mcp/server.js.map +1 -1
- package/dist/policy/index.d.ts +2 -2
- package/dist/policy/index.js +1 -1
- package/dist/policy/index.js.map +1 -1
- package/dist/policy/policy.js +112 -32
- package/dist/policy/policy.js.map +1 -1
- package/dist/productTelemetry/index.js +71 -20
- package/dist/productTelemetry/index.js.map +1 -1
- package/dist/providers/conformance.js +10 -5
- package/dist/providers/conformance.js.map +1 -1
- package/dist/providers/discord.js +2 -2
- package/dist/providers/generic.js +3 -3
- package/dist/providers/github.js +2 -2
- package/dist/providers/index.d.ts +3 -3
- package/dist/providers/index.js +2 -2
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openai.js +3 -3
- package/dist/providers/packs/resendPack.js +51 -25
- package/dist/providers/packs/resendPack.js.map +1 -1
- package/dist/providers/packs/stripePack.js +590 -121
- package/dist/providers/packs/stripePack.js.map +1 -1
- package/dist/providers/registry.js +4 -4
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/runtime.js +7 -6
- package/dist/providers/runtime.js.map +1 -1
- package/dist/providers/stripeWebhook.js +39 -10
- package/dist/providers/stripeWebhook.js.map +1 -1
- package/dist/providers/twilio.js +2 -2
- package/dist/proxy/cacheKey.js +9 -2
- package/dist/proxy/cacheKey.js.map +1 -1
- package/dist/proxy/providerDetector.js +22 -9
- package/dist/proxy/providerDetector.js.map +1 -1
- package/dist/proxy/proxyHandler.js +57 -22
- package/dist/proxy/proxyHandler.js.map +1 -1
- package/dist/proxy/requestNormalizer.js +4 -2
- package/dist/proxy/requestNormalizer.js.map +1 -1
- package/dist/reliability/costGovernance.js +211 -49
- package/dist/reliability/costGovernance.js.map +1 -1
- package/dist/reliability/index.d.ts +8 -8
- package/dist/reliability/index.js +4 -4
- package/dist/reliability/index.js.map +1 -1
- package/dist/reliability/reconciliation.js +274 -65
- package/dist/reliability/reconciliation.js.map +1 -1
- package/dist/reliability/runtimeHealth.js +127 -35
- package/dist/reliability/runtimeHealth.js.map +1 -1
- package/dist/reliability/slo.js +257 -50
- package/dist/reliability/slo.js.map +1 -1
- package/dist/report/safetyReport.d.ts +6 -0
- package/dist/report/safetyReport.js +56 -12
- package/dist/report/safetyReport.js.map +1 -1
- package/dist/rules/aiRules.js +5 -5
- package/dist/rules/aiRules.js.map +1 -1
- package/dist/safety/index.d.ts +2 -2
- package/dist/safety/index.js +1 -1
- package/dist/safety/index.js.map +1 -1
- package/dist/safety/safetyController.js +675 -165
- package/dist/safety/safetyController.js.map +1 -1
- package/dist/scenarios/scenarioBundle.js +204 -71
- package/dist/scenarios/scenarioBundle.js.map +1 -1
- package/dist/scenarios/scenarioStore.js +88 -29
- package/dist/scenarios/scenarioStore.js.map +1 -1
- package/dist/security/headerSanitizer.js +52 -10
- package/dist/security/headerSanitizer.js.map +1 -1
- package/dist/security/secrets.js +10 -5
- package/dist/security/secrets.js.map +1 -1
- package/dist/server/accessControl.js +37 -10
- package/dist/server/accessControl.js.map +1 -1
- package/dist/server/createServer.js +1 -1
- package/dist/server/createServer.js.map +1 -1
- package/dist/server/eventsStore.js +15 -5
- package/dist/server/eventsStore.js.map +1 -1
- package/dist/server/routes.js +61 -19
- package/dist/server/routes.js.map +1 -1
- package/dist/server/sse.js +1 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/setup/sdkDetector.js +7 -2
- package/dist/setup/sdkDetector.js.map +1 -1
- package/dist/setup/setupGenerator.js +136 -48
- package/dist/setup/setupGenerator.js.map +1 -1
- package/dist/state/stateExtractor.js +3 -1
- package/dist/state/stateExtractor.js.map +1 -1
- package/dist/state/stateResolver.js +10 -7
- package/dist/state/stateResolver.js.map +1 -1
- package/dist/state/stateStore.js +13 -4
- package/dist/state/stateStore.js.map +1 -1
- package/dist/storage/fileStore.js +12 -4
- package/dist/storage/fileStore.js.map +1 -1
- package/dist/teamControl/controlPlane.js +846 -174
- package/dist/teamControl/controlPlane.js.map +1 -1
- package/dist/teamControl/deployment.js +19 -5
- package/dist/teamControl/deployment.js.map +1 -1
- package/dist/teamControl/index.d.ts +4 -4
- package/dist/teamControl/index.js +2 -2
- package/dist/teamControl/index.js.map +1 -1
- package/dist/tests/testGenerator.js +15 -7
- package/dist/tests/testGenerator.js.map +1 -1
- package/dist/trust/index.d.ts +2 -2
- package/dist/trust/index.js +1 -1
- package/dist/trust/index.js.map +1 -1
- package/dist/trust/trustLadder.js +309 -63
- package/dist/trust/trustLadder.js.map +1 -1
- package/dist/worlds/index.d.ts +2 -2
- package/dist/worlds/index.js +1 -1
- package/dist/worlds/index.js.map +1 -1
- package/dist/worlds/worlds.js +364 -65
- package/dist/worlds/worlds.js.map +1 -1
- package/docs/assets/dashboard.png +0 -0
- package/docs/ci.md +49 -49
- package/docs/github-actions.md +84 -84
- package/docs/mcp.md +58 -52
- package/docs/policy.md +63 -61
- package/docs/providers/authoring-packs.md +180 -175
- package/docs/providers/stripe-core-pack.md +84 -82
- package/docs/releases/README.md +13 -0
- package/docs/security/action-gateway-threat-model.md +34 -34
- package/docs/security/action-ledger-incident-replay-threat-model.md +42 -42
- package/docs/security/approval-inbox-threat-model.md +35 -35
- package/docs/security/credential-broker-threat-model.md +52 -52
- package/docs/security/egress-threat-model.md +126 -126
- package/docs/security/inventory-threat-model.md +104 -104
- package/docs/security/kill-switch-budgets-threat-model.md +37 -37
- package/docs/security/reliability-threat-model.md +111 -111
- package/docs/security/trust-ladder-threat-model.md +33 -33
- package/docs/telemetry.md +14 -0
- package/docs/usage.md +680 -571
- package/examples/README.md +10 -10
- package/examples/agent-instructions/README.md +39 -39
- package/examples/ci-smoke/README.md +21 -21
- package/examples/ci-smoke/ghostapi.policy.yaml +22 -22
- package/examples/ci-smoke/package.json +9 -9
- package/examples/ci-smoke/production-egress.mjs +4 -2
- package/examples/ci-smoke/safe.mjs +21 -17
- package/examples/evals/README.md +24 -24
- package/examples/evals/retry-after.eval.json +71 -52
- package/examples/generic-rest/README.md +27 -27
- package/examples/github-fetch/README.md +38 -35
- package/examples/openai-streaming/README.md +16 -16
- package/examples/openai-streaming/streaming-tool-call.mjs +44 -31
- package/examples/policy/ghostapi.policy.yaml +27 -27
- package/examples/record-replay/README.md +19 -19
- package/examples/record-replay/replay-requests.json +22 -22
- package/examples/record-replay/stripe-checkout.har.json +30 -30
- package/examples/resend-node/README.md +22 -22
- package/examples/stripe-node/README.md +35 -35
- package/examples/stripe-node/checkout-flow.mjs +87 -61
- package/examples/twilio-node/README.md +21 -21
- package/examples/worlds/README.md +19 -19
- package/examples/worlds/subscription-recovery.mjs +21 -12
- package/package.json +28 -6
- package/dist/landing/assets/index-BXR41fAD.css +0 -1
- package/dist/landing/assets/index-CxWsopzP.css +0 -1
- package/dist/landing/assets/index-D8_ggMRE.js +0 -55
- package/dist/landing/assets/index-DZlpyhqd.js +0 -55
- package/dist/landing/assets/index-Dk-Xezs3.js +0 -55
- package/dist/landing/index.html +0 -15
- package/dist/landing/landing.d.ts +0 -3
- package/dist/landing/landing.js +0 -12
- package/dist/landing/landing.js.map +0 -1
- package/docs/commercial/README.md +0 -29
- package/docs/commercial/data-inventory.md +0 -36
- package/docs/commercial/manual-invoicing.md +0 -44
- package/docs/commercial/metrics.md +0 -51
- package/docs/commercial/pricing.md +0 -103
- package/docs/commercial/privacy-policy-draft.md +0 -41
- package/docs/commercial/terms-topics-for-counsel.md +0 -51
- package/docs/design-partners/README.md +0 -52
- package/docs/design-partners/demo-narratives.md +0 -33
- package/docs/design-partners/design-partner-offer.md +0 -39
- package/docs/design-partners/discovery-questions.md +0 -28
- package/docs/design-partners/feedback-capture-template.md +0 -48
- package/docs/design-partners/icp-one-pager.md +0 -43
- package/docs/design-partners/onboarding-checklist.md +0 -33
- package/docs/design-partners/pilot-success-criteria.md +0 -27
- package/docs/design-partners/pricing-interview-script.md +0 -18
- package/docs/design-partners/telemetry-plan.md +0 -48
- package/docs/development/baseline.md +0 -114
- package/docs/development/onboarding-smoke.md +0 -37
- package/docs/development/verification-0.1.8.md +0 -25
- package/docs/enterprise-product-roadmap-ru.md +0 -1879
- package/docs/fundraising/README.md +0 -87
- package/docs/fundraising/data-room-checklist.md +0 -72
- package/docs/fundraising/demo-script.md +0 -52
- package/docs/fundraising/design-partners-50.md +0 -74
- package/docs/fundraising/launch-posts.md +0 -37
- package/docs/fundraising/metrics-and-evidence.md +0 -30
- package/docs/fundraising/roadmap-12-month.md +0 -25
- package/docs/fundraising/technical-due-diligence-index.md +0 -37
- package/docs/fundraising/yc-application.md +0 -25
- package/docs/hosted-pilot.md +0 -111
- package/docs/operations/disaster-recovery-runbook.md +0 -81
- package/docs/operations/kill-switch-runbook.md +0 -32
- package/docs/release-checklist.md +0 -83
- package/docs/release-migration-and-rollback.md +0 -63
- package/docs/release-readiness.md +0 -39
- package/docs/team-control-plane.md +0 -72
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
4
4
|
import { getDataPaths } from "../config/dataPaths.js";
|
|
5
5
|
import { getProviderManifests } from "../providers/registry.js";
|
|
6
6
|
import { atomicWriteJson } from "../storage/fileStore.js";
|
|
7
7
|
import { sanitizeSecretString } from "../security/secrets.js";
|
|
8
|
-
import { prepareScenarioRecording } from "../scenarios/scenarioBundle.js";
|
|
8
|
+
import { prepareScenarioRecording, } from "../scenarios/scenarioBundle.js";
|
|
9
9
|
const MAX_INPUT_BYTES = 1024 * 1024;
|
|
10
10
|
const MAX_CONTRACT_BYTES = 512 * 1024;
|
|
11
11
|
const MAX_PATHS = 200;
|
|
12
12
|
const MAX_OPERATIONS = 400;
|
|
13
13
|
const MAX_SCHEMA_DEPTH = 20;
|
|
14
14
|
const MAX_PROPERTIES = 100;
|
|
15
|
-
const HTTP_METHODS = new Set([
|
|
15
|
+
const HTTP_METHODS = new Set([
|
|
16
|
+
"GET",
|
|
17
|
+
"POST",
|
|
18
|
+
"PUT",
|
|
19
|
+
"PATCH",
|
|
20
|
+
"DELETE",
|
|
21
|
+
"HEAD",
|
|
22
|
+
"OPTIONS",
|
|
23
|
+
]);
|
|
16
24
|
export class ContractError extends Error {
|
|
17
25
|
constructor(message) {
|
|
18
26
|
super(message);
|
|
@@ -24,7 +32,8 @@ export function importOpenApiContract(input, options = {}) {
|
|
|
24
32
|
const document = objectAt(input, "OpenAPI document must be an object.");
|
|
25
33
|
assertExactKeys(document, ["openapi", "info", "paths", "servers"], "OpenAPI document");
|
|
26
34
|
assertNoUnsupportedOpenApiFeatures(document, "OpenAPI document");
|
|
27
|
-
if (typeof document.openapi !== "string" ||
|
|
35
|
+
if (typeof document.openapi !== "string" ||
|
|
36
|
+
!/^3\.0\.\d+(?:[-+].*)?$/.test(document.openapi)) {
|
|
28
37
|
throw new ContractError("Only OpenAPI 3.0.x is supported. Convert Swagger/OpenAPI 2.x or OpenAPI 3.1 before import.");
|
|
29
38
|
}
|
|
30
39
|
if (document.servers !== undefined)
|
|
@@ -52,18 +61,27 @@ export function importOpenApiContract(input, options = {}) {
|
|
|
52
61
|
}
|
|
53
62
|
if (operations.length === 0)
|
|
54
63
|
throw new ContractError("OpenAPI document does not contain any supported operations.");
|
|
55
|
-
const infoTitle = document.info === undefined
|
|
64
|
+
const infoTitle = document.info === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: objectAt(document.info, "OpenAPI info must be an object.").title;
|
|
56
67
|
return validateContract({
|
|
57
68
|
schemaVersion: 1,
|
|
58
69
|
kind: "ghostapi.contract",
|
|
59
|
-
metadata: {
|
|
70
|
+
metadata: {
|
|
71
|
+
title: normalizeTitle(options.title ?? infoTitle ?? "OpenAPI import"),
|
|
72
|
+
source: "openapi",
|
|
73
|
+
importedAt: options.importedAt ?? new Date().toISOString(),
|
|
74
|
+
},
|
|
60
75
|
operations,
|
|
61
|
-
providerCapabilities: currentProviderCapabilities()
|
|
76
|
+
providerCapabilities: currentProviderCapabilities(),
|
|
62
77
|
});
|
|
63
78
|
}
|
|
64
79
|
export function importHarContract(input, options) {
|
|
65
80
|
const bundle = prepareScenarioRecording(input, options);
|
|
66
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
bundle,
|
|
83
|
+
contract: contractFromScenarioBundle(bundle, options.title, options.importedAt),
|
|
84
|
+
};
|
|
67
85
|
}
|
|
68
86
|
export function contractFromScenarioBundle(bundle, title = bundle.metadata.title, importedAt = bundle.metadata.recordedAt) {
|
|
69
87
|
const operations = new Map();
|
|
@@ -76,11 +94,12 @@ export function contractFromScenarioBundle(bundle, title = bundle.metadata.title
|
|
|
76
94
|
method: interaction.request.method,
|
|
77
95
|
path: pathWithoutQuery(interaction.request.path),
|
|
78
96
|
request: inferSchema(interaction.request.body),
|
|
79
|
-
responses: { [String(interaction.response.status)]: responseSchema }
|
|
97
|
+
responses: { [String(interaction.response.status)]: responseSchema },
|
|
80
98
|
});
|
|
81
99
|
continue;
|
|
82
100
|
}
|
|
83
|
-
existing.responses[String(interaction.response.status)] =
|
|
101
|
+
existing.responses[String(interaction.response.status)] =
|
|
102
|
+
mergeSchemas(existing.responses[String(interaction.response.status)], responseSchema) ?? null;
|
|
84
103
|
existing.request = mergeSchemas(existing.request, inferSchema(interaction.request.body));
|
|
85
104
|
}
|
|
86
105
|
return validateContract({
|
|
@@ -88,7 +107,7 @@ export function contractFromScenarioBundle(bundle, title = bundle.metadata.title
|
|
|
88
107
|
kind: "ghostapi.contract",
|
|
89
108
|
metadata: { title: normalizeTitle(title), source: "har", importedAt },
|
|
90
109
|
operations: [...operations.values()].sort(compareOperations),
|
|
91
|
-
providerCapabilities: currentProviderCapabilities()
|
|
110
|
+
providerCapabilities: currentProviderCapabilities(),
|
|
92
111
|
});
|
|
93
112
|
}
|
|
94
113
|
export async function importOpenApiContractFromFile(path, options = {}, projectRoot = process.cwd()) {
|
|
@@ -99,7 +118,9 @@ export async function importHarContractFromFile(path, options, projectRoot = pro
|
|
|
99
118
|
}
|
|
100
119
|
export async function writeContract(contract, outPath, projectRoot = process.cwd()) {
|
|
101
120
|
const valid = validateContract(contract);
|
|
102
|
-
const target = outPath === undefined
|
|
121
|
+
const target = outPath === undefined
|
|
122
|
+
? join(getDataPaths().contracts, `${slugify(valid.metadata.title)}.contract.json`)
|
|
123
|
+
: await resolveOutputPath(outPath, projectRoot);
|
|
103
124
|
const serialized = JSON.stringify(valid);
|
|
104
125
|
if (Buffer.byteLength(serialized, "utf8") > MAX_CONTRACT_BYTES)
|
|
105
126
|
throw new ContractError(`Contract exceeds ${MAX_CONTRACT_BYTES} bytes.`);
|
|
@@ -120,9 +141,13 @@ export function validateContract(input) {
|
|
|
120
141
|
throw new ContractError("Contract metadata source must be openapi or har.");
|
|
121
142
|
if (!isTimestamp(metadata.importedAt))
|
|
122
143
|
throw new ContractError("Contract metadata importedAt must be an ISO timestamp.");
|
|
123
|
-
if (!Array.isArray(root.operations) ||
|
|
144
|
+
if (!Array.isArray(root.operations) ||
|
|
145
|
+
root.operations.length === 0 ||
|
|
146
|
+
root.operations.length > MAX_OPERATIONS)
|
|
124
147
|
throw new ContractError(`Contract operations must contain 1-${MAX_OPERATIONS} entries.`);
|
|
125
|
-
const operations = root.operations
|
|
148
|
+
const operations = root.operations
|
|
149
|
+
.map((operation) => validateOperation(operation))
|
|
150
|
+
.sort(compareOperations);
|
|
126
151
|
const keys = new Set();
|
|
127
152
|
for (const operation of operations) {
|
|
128
153
|
const key = `${operation.method} ${operation.path}`;
|
|
@@ -130,23 +155,36 @@ export function validateContract(input) {
|
|
|
130
155
|
throw new ContractError(`Contract operation is duplicated: ${key}`);
|
|
131
156
|
keys.add(key);
|
|
132
157
|
}
|
|
133
|
-
if (!Array.isArray(root.providerCapabilities) ||
|
|
158
|
+
if (!Array.isArray(root.providerCapabilities) ||
|
|
159
|
+
root.providerCapabilities.length > 20)
|
|
134
160
|
throw new ContractError("Contract provider capabilities are invalid.");
|
|
135
|
-
const providerCapabilities = root.providerCapabilities
|
|
161
|
+
const providerCapabilities = root.providerCapabilities
|
|
162
|
+
.map(validateCapability)
|
|
163
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
136
164
|
return {
|
|
137
165
|
schemaVersion: 1,
|
|
138
166
|
kind: "ghostapi.contract",
|
|
139
|
-
metadata: {
|
|
167
|
+
metadata: {
|
|
168
|
+
title: normalizeTitle(metadata.title),
|
|
169
|
+
source: metadata.source,
|
|
170
|
+
importedAt: metadata.importedAt,
|
|
171
|
+
},
|
|
140
172
|
operations,
|
|
141
|
-
providerCapabilities
|
|
173
|
+
providerCapabilities,
|
|
142
174
|
};
|
|
143
175
|
}
|
|
144
176
|
export function diffContracts(baselineInput, candidateInput) {
|
|
145
177
|
const baseline = validateContract(baselineInput);
|
|
146
178
|
const candidate = validateContract(candidateInput);
|
|
147
179
|
const findings = [];
|
|
148
|
-
const baselineOperations = new Map(baseline.operations.map((operation) => [
|
|
149
|
-
|
|
180
|
+
const baselineOperations = new Map(baseline.operations.map((operation) => [
|
|
181
|
+
operationKey(operation),
|
|
182
|
+
operation,
|
|
183
|
+
]));
|
|
184
|
+
const candidateOperations = new Map(candidate.operations.map((operation) => [
|
|
185
|
+
operationKey(operation),
|
|
186
|
+
operation,
|
|
187
|
+
]));
|
|
150
188
|
for (const key of [...baselineOperations.keys()].sort()) {
|
|
151
189
|
if (!candidateOperations.has(key))
|
|
152
190
|
findings.push(finding("endpoint-removed", key, "breaking", `Endpoint removed: ${key}. Existing callers can no longer rely on this operation.`));
|
|
@@ -155,17 +193,25 @@ export function diffContracts(baselineInput, candidateInput) {
|
|
|
155
193
|
if (!baselineOperations.has(key))
|
|
156
194
|
findings.push(finding("endpoint-added", key, "non-breaking", `Endpoint added: ${key}.`));
|
|
157
195
|
}
|
|
158
|
-
for (const key of [...baselineOperations.keys()]
|
|
196
|
+
for (const key of [...baselineOperations.keys()]
|
|
197
|
+
.filter((entry) => candidateOperations.has(entry))
|
|
198
|
+
.sort()) {
|
|
159
199
|
const before = baselineOperations.get(key);
|
|
160
200
|
const after = candidateOperations.get(key);
|
|
161
201
|
diffSchema(before.request, after.request, `${key} request`, "request", findings);
|
|
162
202
|
const beforeStatuses = new Set(Object.keys(before.responses));
|
|
163
203
|
const afterStatuses = new Set(Object.keys(after.responses));
|
|
164
|
-
for (const status of [...beforeStatuses]
|
|
204
|
+
for (const status of [...beforeStatuses]
|
|
205
|
+
.filter((value) => !afterStatuses.has(value))
|
|
206
|
+
.sort())
|
|
165
207
|
findings.push(finding("response-status-removed", `${key} ${status}`, "breaking", `Response status ${status} removed from ${key}.`));
|
|
166
|
-
for (const status of [...afterStatuses]
|
|
208
|
+
for (const status of [...afterStatuses]
|
|
209
|
+
.filter((value) => !beforeStatuses.has(value))
|
|
210
|
+
.sort())
|
|
167
211
|
findings.push(finding("response-status-added", `${key} ${status}`, "uncertain", `Response status ${status} added to ${key}; client handling is unknown.`));
|
|
168
|
-
for (const status of [...beforeStatuses]
|
|
212
|
+
for (const status of [...beforeStatuses]
|
|
213
|
+
.filter((value) => afterStatuses.has(value))
|
|
214
|
+
.sort())
|
|
169
215
|
diffSchema(before.responses[status] ?? undefined, after.responses[status] ?? undefined, `${key} response ${status}`, "response", findings);
|
|
170
216
|
}
|
|
171
217
|
diffProviderCapabilities(baseline.providerCapabilities, candidate.providerCapabilities, findings);
|
|
@@ -178,9 +224,11 @@ export function diffContracts(baselineInput, candidateInput) {
|
|
|
178
224
|
findings: sorted,
|
|
179
225
|
summary: {
|
|
180
226
|
breaking: sorted.filter((entry) => entry.severity === "breaking").length,
|
|
181
|
-
nonBreaking: sorted.filter((entry) => entry.severity === "non-breaking")
|
|
182
|
-
|
|
183
|
-
|
|
227
|
+
nonBreaking: sorted.filter((entry) => entry.severity === "non-breaking")
|
|
228
|
+
.length,
|
|
229
|
+
uncertain: sorted.filter((entry) => entry.severity === "uncertain")
|
|
230
|
+
.length,
|
|
231
|
+
},
|
|
184
232
|
};
|
|
185
233
|
}
|
|
186
234
|
export function formatContractDiff(diff) {
|
|
@@ -189,18 +237,29 @@ export function formatContractDiff(diff) {
|
|
|
189
237
|
`Baseline: ${diff.baselineHash}`,
|
|
190
238
|
`Candidate: ${diff.candidateHash}`,
|
|
191
239
|
`Findings: ${diff.summary.breaking} breaking, ${diff.summary.nonBreaking} non-breaking, ${diff.summary.uncertain} uncertain`,
|
|
192
|
-
...diff.findings.map((entry) => ` ${entry.severity.toUpperCase()} ${entry.message}`)
|
|
240
|
+
...diff.findings.map((entry) => ` ${entry.severity.toUpperCase()} ${entry.message}`),
|
|
193
241
|
].join("\n");
|
|
194
242
|
}
|
|
195
243
|
export function contractHash(contract) {
|
|
196
|
-
return createHash("sha256")
|
|
244
|
+
return createHash("sha256")
|
|
245
|
+
.update(stableStringify(validateContract(contract)), "utf8")
|
|
246
|
+
.digest("hex");
|
|
197
247
|
}
|
|
198
248
|
function parseOpenApiOperation(path, method, value) {
|
|
199
249
|
const operation = objectAt(value, `OpenAPI ${method} ${path} must be an object.`);
|
|
200
250
|
assertNoUnsupportedOpenApiFeatures(operation, `OpenAPI ${method} ${path}`);
|
|
201
|
-
const allowed = [
|
|
251
|
+
const allowed = [
|
|
252
|
+
"summary",
|
|
253
|
+
"description",
|
|
254
|
+
"operationId",
|
|
255
|
+
"tags",
|
|
256
|
+
"requestBody",
|
|
257
|
+
"responses",
|
|
258
|
+
];
|
|
202
259
|
assertExactKeys(operation, allowed, `OpenAPI ${method} ${path}`);
|
|
203
|
-
const request = operation.requestBody === undefined
|
|
260
|
+
const request = operation.requestBody === undefined
|
|
261
|
+
? undefined
|
|
262
|
+
: parseRequestBody(operation.requestBody, `OpenAPI ${method} ${path} requestBody`);
|
|
204
263
|
const responsesRecord = objectAt(operation.responses, `OpenAPI ${method} ${path} must declare responses.`);
|
|
205
264
|
const responseEntries = Object.entries(responsesRecord);
|
|
206
265
|
if (responseEntries.length === 0 || responseEntries.length > 20)
|
|
@@ -211,7 +270,12 @@ function parseOpenApiOperation(path, method, value) {
|
|
|
211
270
|
throw new ContractError(`OpenAPI ${method} ${path} has unsupported response status: ${status}.`);
|
|
212
271
|
responses[status] = parseResponse(response, `OpenAPI ${method} ${path} response ${status}`);
|
|
213
272
|
}
|
|
214
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
method,
|
|
275
|
+
path,
|
|
276
|
+
...(request === undefined ? {} : { request }),
|
|
277
|
+
responses,
|
|
278
|
+
};
|
|
215
279
|
}
|
|
216
280
|
function parseRequestBody(value, label) {
|
|
217
281
|
const body = objectAt(value, `${label} must be an object.`);
|
|
@@ -225,39 +289,73 @@ function parseResponse(value, label) {
|
|
|
225
289
|
assertExactKeys(response, ["description", "content", "headers"], label);
|
|
226
290
|
if (response.headers !== undefined)
|
|
227
291
|
throw new ContractError(`${label} headers are unsupported; import JSON response schemas only.`);
|
|
228
|
-
return response.content === undefined
|
|
292
|
+
return response.content === undefined
|
|
293
|
+
? null
|
|
294
|
+
: (parseContent(response.content, label) ?? null);
|
|
229
295
|
}
|
|
230
296
|
function parseContent(value, label) {
|
|
231
297
|
const content = objectAt(value, `${label} content must be an object.`);
|
|
232
298
|
const entries = Object.entries(content);
|
|
233
|
-
if (entries.length !== 1 ||
|
|
299
|
+
if (entries.length !== 1 ||
|
|
300
|
+
entries[0][0].toLowerCase() !== "application/json")
|
|
234
301
|
throw new ContractError(`${label} supports exactly one application/json content entry.`);
|
|
235
302
|
const media = objectAt(entries[0][1], `${label} application/json media type must be an object.`);
|
|
236
303
|
assertNoUnsupportedOpenApiFeatures(media, `${label} application/json media type`);
|
|
237
304
|
assertExactKeys(media, ["schema"], `${label} application/json media type`);
|
|
238
|
-
return media.schema === undefined
|
|
305
|
+
return media.schema === undefined
|
|
306
|
+
? undefined
|
|
307
|
+
: parseOpenApiSchema(media.schema, `${label} schema`);
|
|
239
308
|
}
|
|
240
309
|
function parseOpenApiSchema(value, label, depth = 0) {
|
|
241
310
|
if (depth > MAX_SCHEMA_DEPTH)
|
|
242
311
|
throw new ContractError(`${label} exceeds schema recursion depth ${MAX_SCHEMA_DEPTH}.`);
|
|
243
312
|
const schema = objectAt(value, `${label} must be an object.`);
|
|
244
313
|
assertNoUnsupportedOpenApiFeatures(schema, label);
|
|
245
|
-
assertExactKeys(schema, [
|
|
314
|
+
assertExactKeys(schema, [
|
|
315
|
+
"type",
|
|
316
|
+
"enum",
|
|
317
|
+
"required",
|
|
318
|
+
"properties",
|
|
319
|
+
"items",
|
|
320
|
+
"nullable",
|
|
321
|
+
"description",
|
|
322
|
+
"format",
|
|
323
|
+
"title",
|
|
324
|
+
"example",
|
|
325
|
+
"default",
|
|
326
|
+
"minLength",
|
|
327
|
+
"maxLength",
|
|
328
|
+
"minimum",
|
|
329
|
+
"maximum",
|
|
330
|
+
"minItems",
|
|
331
|
+
"maxItems",
|
|
332
|
+
], label);
|
|
246
333
|
if (schema.nullable === true)
|
|
247
334
|
throw new ContractError(`${label} nullable is unsupported; use an explicit oneOf outside this supported subset.`);
|
|
248
335
|
const result = {};
|
|
249
336
|
if (schema.type !== undefined) {
|
|
250
|
-
if (schema.type !== "string" &&
|
|
337
|
+
if (schema.type !== "string" &&
|
|
338
|
+
schema.type !== "number" &&
|
|
339
|
+
schema.type !== "integer" &&
|
|
340
|
+
schema.type !== "boolean" &&
|
|
341
|
+
schema.type !== "array" &&
|
|
342
|
+
schema.type !== "object")
|
|
251
343
|
throw new ContractError(`${label} has unsupported schema type.`);
|
|
252
344
|
result.type = schema.type;
|
|
253
345
|
}
|
|
254
346
|
if (schema.enum !== undefined) {
|
|
255
|
-
if (!Array.isArray(schema.enum) ||
|
|
347
|
+
if (!Array.isArray(schema.enum) ||
|
|
348
|
+
schema.enum.length === 0 ||
|
|
349
|
+
schema.enum.length > 100 ||
|
|
350
|
+
schema.enum.some((entry) => !["string", "number", "boolean"].includes(typeof entry) &&
|
|
351
|
+
entry !== null))
|
|
256
352
|
throw new ContractError(`${label} enum must contain 1-100 scalar JSON values.`);
|
|
257
353
|
result.enum = [...schema.enum];
|
|
258
354
|
}
|
|
259
355
|
if (schema.required !== undefined) {
|
|
260
|
-
if (!Array.isArray(schema.required) ||
|
|
356
|
+
if (!Array.isArray(schema.required) ||
|
|
357
|
+
schema.required.some((entry) => typeof entry !== "string" || !isFieldName(entry)) ||
|
|
358
|
+
new Set(schema.required).size !== schema.required.length)
|
|
261
359
|
throw new ContractError(`${label} required must be unique field names.`);
|
|
262
360
|
result.required = [...schema.required].sort();
|
|
263
361
|
}
|
|
@@ -266,9 +364,15 @@ function parseOpenApiSchema(value, label, depth = 0) {
|
|
|
266
364
|
throw new ContractError(`${label} properties requires type object.`);
|
|
267
365
|
const properties = objectAt(schema.properties, `${label} properties must be an object.`);
|
|
268
366
|
const entries = Object.entries(properties);
|
|
269
|
-
if (entries.length > MAX_PROPERTIES ||
|
|
367
|
+
if (entries.length > MAX_PROPERTIES ||
|
|
368
|
+
entries.some(([name]) => !isFieldName(name)))
|
|
270
369
|
throw new ContractError(`${label} properties exceed supported limits.`);
|
|
271
|
-
result.properties = Object.fromEntries(entries
|
|
370
|
+
result.properties = Object.fromEntries(entries
|
|
371
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
372
|
+
.map(([name, child]) => [
|
|
373
|
+
name,
|
|
374
|
+
parseOpenApiSchema(child, `${label}.properties.${name}`, depth + 1),
|
|
375
|
+
]));
|
|
272
376
|
}
|
|
273
377
|
if (schema.items !== undefined) {
|
|
274
378
|
if (result.type !== "array")
|
|
@@ -282,7 +386,21 @@ function parseOpenApiSchema(value, label, depth = 0) {
|
|
|
282
386
|
return result;
|
|
283
387
|
}
|
|
284
388
|
function assertNoUnsupportedOpenApiFeatures(value, label) {
|
|
285
|
-
for (const key of [
|
|
389
|
+
for (const key of [
|
|
390
|
+
"$ref",
|
|
391
|
+
"$dynamicRef",
|
|
392
|
+
"$recursiveRef",
|
|
393
|
+
"allOf",
|
|
394
|
+
"anyOf",
|
|
395
|
+
"oneOf",
|
|
396
|
+
"not",
|
|
397
|
+
"discriminator",
|
|
398
|
+
"callbacks",
|
|
399
|
+
"links",
|
|
400
|
+
"security",
|
|
401
|
+
"externalDocs",
|
|
402
|
+
"webhooks",
|
|
403
|
+
]) {
|
|
286
404
|
if (value[key] !== undefined)
|
|
287
405
|
throw new ContractError(`${label} uses unsupported OpenAPI feature ${key}. External or recursive references are never fetched or resolved.`);
|
|
288
406
|
}
|
|
@@ -293,7 +411,11 @@ function inferSchema(value, depth = 0) {
|
|
|
293
411
|
if (value === null)
|
|
294
412
|
return { type: "null" };
|
|
295
413
|
if (Array.isArray(value)) {
|
|
296
|
-
const items = value.length === 0
|
|
414
|
+
const items = value.length === 0
|
|
415
|
+
? undefined
|
|
416
|
+
: value
|
|
417
|
+
.slice(1)
|
|
418
|
+
.reduce((current, entry) => mergeSchemas(current, inferSchema(entry, depth + 1)) ?? {}, inferSchema(value[0], depth + 1));
|
|
297
419
|
return { type: "array", ...(items === undefined ? {} : { items }) };
|
|
298
420
|
}
|
|
299
421
|
if (typeof value === "string")
|
|
@@ -305,7 +427,13 @@ function inferSchema(value, depth = 0) {
|
|
|
305
427
|
const entries = Object.entries(objectAt(value, "HAR body must be a JSON value."));
|
|
306
428
|
if (entries.length > MAX_PROPERTIES)
|
|
307
429
|
throw new ContractError(`HAR body object exceeds ${MAX_PROPERTIES} properties.`);
|
|
308
|
-
return {
|
|
430
|
+
return {
|
|
431
|
+
type: "object",
|
|
432
|
+
required: entries.map(([key]) => key).sort(),
|
|
433
|
+
properties: Object.fromEntries(entries
|
|
434
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
435
|
+
.map(([key, entry]) => [key, inferSchema(entry, depth + 1)])),
|
|
436
|
+
};
|
|
309
437
|
}
|
|
310
438
|
function mergeSchemas(left, right) {
|
|
311
439
|
if (left === undefined || left === null)
|
|
@@ -314,7 +442,9 @@ function mergeSchemas(left, right) {
|
|
|
314
442
|
return left;
|
|
315
443
|
if (left.type !== right.type)
|
|
316
444
|
return {};
|
|
317
|
-
const result = {
|
|
445
|
+
const result = {
|
|
446
|
+
...(left.type === undefined ? {} : { type: left.type }),
|
|
447
|
+
};
|
|
318
448
|
if (left.enum !== undefined && right.enum !== undefined) {
|
|
319
449
|
const intersection = left.enum.filter((entry) => right.enum.some((candidate) => stableStringify(candidate) === stableStringify(entry)));
|
|
320
450
|
if (intersection.length > 0)
|
|
@@ -323,16 +453,27 @@ function mergeSchemas(left, right) {
|
|
|
323
453
|
if (left.type === "object" && right.type === "object") {
|
|
324
454
|
const leftProperties = left.properties ?? {};
|
|
325
455
|
const rightProperties = right.properties ?? {};
|
|
326
|
-
const common = Object.keys(leftProperties)
|
|
327
|
-
|
|
328
|
-
|
|
456
|
+
const common = Object.keys(leftProperties)
|
|
457
|
+
.filter((key) => Object.hasOwn(rightProperties, key))
|
|
458
|
+
.sort();
|
|
459
|
+
result.properties = Object.fromEntries(common.map((key) => [
|
|
460
|
+
key,
|
|
461
|
+
mergeSchemas(leftProperties[key], rightProperties[key]) ?? {},
|
|
462
|
+
]));
|
|
463
|
+
result.required = (left.required ?? [])
|
|
464
|
+
.filter((key) => (right.required ?? []).includes(key) &&
|
|
465
|
+
Object.hasOwn(result.properties ?? {}, key))
|
|
466
|
+
.sort();
|
|
329
467
|
}
|
|
330
468
|
if (left.type === "array")
|
|
331
469
|
result.items = mergeSchemas(left.items, right.items);
|
|
332
470
|
return result;
|
|
333
471
|
}
|
|
334
472
|
function diffSchema(before, after, location, context, findings) {
|
|
335
|
-
if (before === undefined ||
|
|
473
|
+
if (before === undefined ||
|
|
474
|
+
before === null ||
|
|
475
|
+
after === undefined ||
|
|
476
|
+
after === null) {
|
|
336
477
|
if (before !== after)
|
|
337
478
|
findings.push(finding("schema-presence-changed", location, "uncertain", `${location} schema presence changed; compatibility cannot be proven.`));
|
|
338
479
|
return;
|
|
@@ -344,19 +485,29 @@ function diffSchema(before, after, location, context, findings) {
|
|
|
344
485
|
diffEnum(before.enum, after.enum, location, context, findings);
|
|
345
486
|
const beforeRequired = new Set(before.required ?? []);
|
|
346
487
|
const afterRequired = new Set(after.required ?? []);
|
|
347
|
-
for (const field of [...afterRequired]
|
|
488
|
+
for (const field of [...afterRequired]
|
|
489
|
+
.filter((field) => !beforeRequired.has(field))
|
|
490
|
+
.sort()) {
|
|
348
491
|
findings.push(finding("required-field-added", `${location}.${field}`, context === "request" ? "breaking" : "uncertain", `${location} required field added: ${field}.${context === "response" ? " Client response tolerance is unknown." : ""}`));
|
|
349
492
|
}
|
|
350
|
-
for (const field of [...beforeRequired]
|
|
493
|
+
for (const field of [...beforeRequired]
|
|
494
|
+
.filter((field) => !afterRequired.has(field))
|
|
495
|
+
.sort()) {
|
|
351
496
|
findings.push(finding("required-field-removed", `${location}.${field}`, context === "response" ? "breaking" : "non-breaking", `${location} required field removed: ${field}.${context === "response" ? " Existing clients may depend on it." : ""}`));
|
|
352
497
|
}
|
|
353
498
|
const beforeProperties = before.properties ?? {};
|
|
354
499
|
const afterProperties = after.properties ?? {};
|
|
355
|
-
for (const field of Object.keys(beforeProperties)
|
|
500
|
+
for (const field of Object.keys(beforeProperties)
|
|
501
|
+
.filter((field) => !Object.hasOwn(afterProperties, field))
|
|
502
|
+
.sort())
|
|
356
503
|
findings.push(finding("property-removed", `${location}.${field}`, context === "response" ? "breaking" : "uncertain", `${location} property removed: ${field}.${context === "request" ? " Acceptance of this field is not modeled by the supported subset." : ""}`));
|
|
357
|
-
for (const field of Object.keys(afterProperties)
|
|
504
|
+
for (const field of Object.keys(afterProperties)
|
|
505
|
+
.filter((field) => !Object.hasOwn(beforeProperties, field))
|
|
506
|
+
.sort())
|
|
358
507
|
findings.push(finding("property-added", `${location}.${field}`, context === "request" ? "non-breaking" : "uncertain", `${location} property added: ${field}.${context === "response" ? " Client response tolerance is unknown." : ""}`));
|
|
359
|
-
for (const field of Object.keys(beforeProperties)
|
|
508
|
+
for (const field of Object.keys(beforeProperties)
|
|
509
|
+
.filter((field) => Object.hasOwn(afterProperties, field))
|
|
510
|
+
.sort())
|
|
360
511
|
diffSchema(beforeProperties[field], afterProperties[field], `${location}.${field}`, context, findings);
|
|
361
512
|
if (before.items !== undefined || after.items !== undefined)
|
|
362
513
|
diffSchema(before.items, after.items, `${location}[]`, context, findings);
|
|
@@ -369,9 +520,13 @@ function diffEnum(before, after, location, context, findings) {
|
|
|
369
520
|
}
|
|
370
521
|
const beforeValues = new Set(before.map(stableStringify));
|
|
371
522
|
const afterValues = new Set(after.map(stableStringify));
|
|
372
|
-
for (const value of [...beforeValues]
|
|
523
|
+
for (const value of [...beforeValues]
|
|
524
|
+
.filter((value) => !afterValues.has(value))
|
|
525
|
+
.sort())
|
|
373
526
|
findings.push(finding("enum-value-removed", `${location}.${value}`, "breaking", `${location} enum value removed: ${value}.`));
|
|
374
|
-
for (const value of [...afterValues]
|
|
527
|
+
for (const value of [...afterValues]
|
|
528
|
+
.filter((value) => !beforeValues.has(value))
|
|
529
|
+
.sort())
|
|
375
530
|
findings.push(finding("enum-value-added", `${location}.${value}`, context === "request" ? "non-breaking" : "uncertain", `${location} enum value added: ${value}.${context === "response" ? " Client handling is unknown." : ""}`));
|
|
376
531
|
}
|
|
377
532
|
function diffProviderCapabilities(before, after, findings) {
|
|
@@ -385,12 +540,19 @@ function diffProviderCapabilities(before, after, findings) {
|
|
|
385
540
|
if (!baseline.has(name))
|
|
386
541
|
findings.push(finding("provider-pack-added", name, "non-breaking", `Provider pack capability snapshot added: ${name}.`));
|
|
387
542
|
}
|
|
388
|
-
for (const name of [...baseline.keys()]
|
|
543
|
+
for (const name of [...baseline.keys()]
|
|
544
|
+
.filter((name) => candidate.has(name))
|
|
545
|
+
.sort()) {
|
|
389
546
|
const left = baseline.get(name);
|
|
390
547
|
const right = candidate.get(name);
|
|
391
548
|
if (left.packVersion !== right.packVersion)
|
|
392
549
|
findings.push(finding("provider-pack-version-changed", name, "uncertain", `Provider pack ${name} version changed from ${left.packVersion ?? "legacy"} to ${right.packVersion ?? "legacy"}; semantic compatibility is not inferred from version text.`));
|
|
393
|
-
for (const capability of [
|
|
550
|
+
for (const capability of [
|
|
551
|
+
...new Set([
|
|
552
|
+
...Object.keys(left.capabilities),
|
|
553
|
+
...Object.keys(right.capabilities),
|
|
554
|
+
]),
|
|
555
|
+
].sort()) {
|
|
394
556
|
const previous = left.capabilities[capability] ?? false;
|
|
395
557
|
const current = right.capabilities[capability] ?? false;
|
|
396
558
|
if (previous === current)
|
|
@@ -402,7 +564,9 @@ function diffProviderCapabilities(before, after, findings) {
|
|
|
402
564
|
function validateOperation(value) {
|
|
403
565
|
const operation = objectAt(value, "Contract operation is invalid.");
|
|
404
566
|
assertExactKeys(operation, ["method", "path", "request", "responses"], "Contract operation");
|
|
405
|
-
const method = typeof operation.method === "string" && HTTP_METHODS.has(operation.method)
|
|
567
|
+
const method = typeof operation.method === "string" && HTTP_METHODS.has(operation.method)
|
|
568
|
+
? operation.method
|
|
569
|
+
: null;
|
|
406
570
|
if (method === null)
|
|
407
571
|
throw new ContractError("Contract operation method is invalid.");
|
|
408
572
|
validateOpenApiPath(operation.path);
|
|
@@ -413,12 +577,23 @@ function validateOperation(value) {
|
|
|
413
577
|
return {
|
|
414
578
|
method,
|
|
415
579
|
path: operation.path,
|
|
416
|
-
...(operation.request === undefined
|
|
417
|
-
|
|
580
|
+
...(operation.request === undefined
|
|
581
|
+
? {}
|
|
582
|
+
: {
|
|
583
|
+
request: validateSchema(operation.request, "Contract request schema"),
|
|
584
|
+
}),
|
|
585
|
+
responses: Object.fromEntries(responseEntries
|
|
586
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
587
|
+
.map(([status, schema]) => {
|
|
418
588
|
if (!/^(?:[1-5][0-9]{2}|default)$/.test(status))
|
|
419
589
|
throw new ContractError("Contract response status is invalid.");
|
|
420
|
-
return [
|
|
421
|
-
|
|
590
|
+
return [
|
|
591
|
+
status,
|
|
592
|
+
schema === null
|
|
593
|
+
? null
|
|
594
|
+
: validateSchema(schema, `Contract response ${status} schema`),
|
|
595
|
+
];
|
|
596
|
+
})),
|
|
422
597
|
};
|
|
423
598
|
}
|
|
424
599
|
function validateSchema(value, label, depth = 0) {
|
|
@@ -428,17 +603,31 @@ function validateSchema(value, label, depth = 0) {
|
|
|
428
603
|
assertExactKeys(schema, ["type", "enum", "required", "properties", "items"], label);
|
|
429
604
|
const result = {};
|
|
430
605
|
if (schema.type !== undefined) {
|
|
431
|
-
if (![
|
|
606
|
+
if (![
|
|
607
|
+
"string",
|
|
608
|
+
"number",
|
|
609
|
+
"integer",
|
|
610
|
+
"boolean",
|
|
611
|
+
"array",
|
|
612
|
+
"object",
|
|
613
|
+
"null",
|
|
614
|
+
].includes(schema.type))
|
|
432
615
|
throw new ContractError(`${label} type is invalid.`);
|
|
433
616
|
result.type = schema.type;
|
|
434
617
|
}
|
|
435
618
|
if (schema.enum !== undefined) {
|
|
436
|
-
if (!Array.isArray(schema.enum) ||
|
|
619
|
+
if (!Array.isArray(schema.enum) ||
|
|
620
|
+
schema.enum.length === 0 ||
|
|
621
|
+
schema.enum.length > 100 ||
|
|
622
|
+
schema.enum.some((entry) => !["string", "number", "boolean"].includes(typeof entry) &&
|
|
623
|
+
entry !== null))
|
|
437
624
|
throw new ContractError(`${label} enum is invalid.`);
|
|
438
625
|
result.enum = [...schema.enum];
|
|
439
626
|
}
|
|
440
627
|
if (schema.required !== undefined) {
|
|
441
|
-
if (!Array.isArray(schema.required) ||
|
|
628
|
+
if (!Array.isArray(schema.required) ||
|
|
629
|
+
schema.required.some((entry) => typeof entry !== "string" || !isFieldName(entry)) ||
|
|
630
|
+
new Set(schema.required).size !== schema.required.length)
|
|
442
631
|
throw new ContractError(`${label} required is invalid.`);
|
|
443
632
|
result.required = [...schema.required].sort();
|
|
444
633
|
}
|
|
@@ -447,9 +636,15 @@ function validateSchema(value, label, depth = 0) {
|
|
|
447
636
|
throw new ContractError(`${label} properties requires type object.`);
|
|
448
637
|
const properties = objectAt(schema.properties, `${label} properties are invalid.`);
|
|
449
638
|
const entries = Object.entries(properties);
|
|
450
|
-
if (entries.length > MAX_PROPERTIES ||
|
|
639
|
+
if (entries.length > MAX_PROPERTIES ||
|
|
640
|
+
entries.some(([key]) => !isFieldName(key)))
|
|
451
641
|
throw new ContractError(`${label} properties are invalid.`);
|
|
452
|
-
result.properties = Object.fromEntries(entries
|
|
642
|
+
result.properties = Object.fromEntries(entries
|
|
643
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
644
|
+
.map(([key, entry]) => [
|
|
645
|
+
key,
|
|
646
|
+
validateSchema(entry, `${label}.${key}`, depth + 1),
|
|
647
|
+
]));
|
|
453
648
|
}
|
|
454
649
|
if (schema.items !== undefined) {
|
|
455
650
|
if (result.type !== "array")
|
|
@@ -465,20 +660,37 @@ function validateSchema(value, label, depth = 0) {
|
|
|
465
660
|
function validateCapability(value) {
|
|
466
661
|
const capability = objectAt(value, "Contract provider capability is invalid.");
|
|
467
662
|
assertExactKeys(capability, ["name", "packVersion", "capabilities"], "Contract provider capability");
|
|
468
|
-
if (typeof capability.name !== "string" ||
|
|
663
|
+
if (typeof capability.name !== "string" ||
|
|
664
|
+
!/^[a-z][a-z0-9-]{0,63}$/.test(capability.name))
|
|
469
665
|
throw new ContractError("Contract provider capability name is invalid.");
|
|
470
|
-
if (capability.packVersion !== null &&
|
|
666
|
+
if (capability.packVersion !== null &&
|
|
667
|
+
typeof capability.packVersion !== "string")
|
|
471
668
|
throw new ContractError("Contract provider pack version is invalid.");
|
|
472
669
|
const flags = objectAt(capability.capabilities, "Contract provider capabilities are invalid.");
|
|
473
|
-
if (Object.keys(flags).length > 30 ||
|
|
670
|
+
if (Object.keys(flags).length > 30 ||
|
|
671
|
+
Object.values(flags).some((value) => typeof value !== "boolean"))
|
|
474
672
|
throw new ContractError("Contract provider capabilities are invalid.");
|
|
475
|
-
return {
|
|
673
|
+
return {
|
|
674
|
+
name: capability.name,
|
|
675
|
+
packVersion: capability.packVersion,
|
|
676
|
+
capabilities: Object.fromEntries(Object.entries(flags).sort(([left], [right]) => left.localeCompare(right))),
|
|
677
|
+
};
|
|
476
678
|
}
|
|
477
679
|
function currentProviderCapabilities() {
|
|
478
|
-
return getProviderManifests()
|
|
680
|
+
return getProviderManifests()
|
|
681
|
+
.map((manifest) => ({
|
|
682
|
+
name: manifest.name,
|
|
683
|
+
packVersion: manifest.packVersion,
|
|
684
|
+
capabilities: Object.fromEntries(Object.entries(manifest.capabilities).sort(([left], [right]) => left.localeCompare(right))),
|
|
685
|
+
}))
|
|
686
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
479
687
|
}
|
|
480
688
|
function validateOpenApiPath(value) {
|
|
481
|
-
if (typeof value !== "string" ||
|
|
689
|
+
if (typeof value !== "string" ||
|
|
690
|
+
!value.startsWith("/") ||
|
|
691
|
+
value.startsWith("//") ||
|
|
692
|
+
value.length > 1_000 ||
|
|
693
|
+
/[\r\n?#{}]/.test(value))
|
|
482
694
|
throw new ContractError("OpenAPI path must be a bounded relative path without query, fragment, path parameters, or line breaks.");
|
|
483
695
|
}
|
|
484
696
|
function pathWithoutQuery(path) {
|
|
@@ -494,7 +706,9 @@ function finding(kind, subject, severity, message) {
|
|
|
494
706
|
return { id: `${kind}:${subject}`, severity, message };
|
|
495
707
|
}
|
|
496
708
|
function normalizeTitle(value) {
|
|
497
|
-
if (typeof value !== "string" ||
|
|
709
|
+
if (typeof value !== "string" ||
|
|
710
|
+
value.trim() === "" ||
|
|
711
|
+
value.trim().length > 120)
|
|
498
712
|
throw new ContractError("Contract title must be 1-120 characters.");
|
|
499
713
|
const title = value.trim();
|
|
500
714
|
if (sanitizeSecretString(title) !== title)
|
|
@@ -505,7 +719,9 @@ function isFieldName(value) {
|
|
|
505
719
|
return value.length > 0 && value.length <= 128 && !/[\r\n]/.test(value);
|
|
506
720
|
}
|
|
507
721
|
function isTimestamp(value) {
|
|
508
|
-
return typeof value === "string" &&
|
|
722
|
+
return (typeof value === "string" &&
|
|
723
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
|
|
724
|
+
Number.isFinite(Date.parse(value)));
|
|
509
725
|
}
|
|
510
726
|
function objectAt(value, message) {
|
|
511
727
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
@@ -543,7 +759,8 @@ async function readBoundedJson(path, projectRoot, label, limit = MAX_INPUT_BYTES
|
|
|
543
759
|
}
|
|
544
760
|
}
|
|
545
761
|
function isArchive(source) {
|
|
546
|
-
return (source[0] === 0x50 && source[1] === 0x4b) ||
|
|
762
|
+
return ((source[0] === 0x50 && source[1] === 0x4b) ||
|
|
763
|
+
(source[0] === 0x1f && source[1] === 0x8b));
|
|
547
764
|
}
|
|
548
765
|
async function resolveExistingPath(path, projectRoot, label) {
|
|
549
766
|
const target = isAbsolute(path) ? resolve(path) : resolve(projectRoot, path);
|
|
@@ -551,7 +768,7 @@ async function resolveExistingPath(path, projectRoot, label) {
|
|
|
551
768
|
const info = await lstat(target);
|
|
552
769
|
if (!info.isFile() || info.isSymbolicLink())
|
|
553
770
|
throw new ContractError(`${label} must be a regular non-symlink file.`);
|
|
554
|
-
if (!await isRealPathInsideAllowedRoots(target, projectRoot))
|
|
771
|
+
if (!(await isRealPathInsideAllowedRoots(target, projectRoot)))
|
|
555
772
|
throw new ContractError(`${label} resolves outside the project root or GHOSTAPI_DATA_DIR through a symlink.`);
|
|
556
773
|
return target;
|
|
557
774
|
}
|
|
@@ -562,7 +779,7 @@ async function resolveOutputPath(path, projectRoot) {
|
|
|
562
779
|
const parent = await lstat(dirname(target));
|
|
563
780
|
if (!parent.isDirectory() || parent.isSymbolicLink())
|
|
564
781
|
throw new ContractError("Contract output parent must be a real directory, not a symlink.");
|
|
565
|
-
if (!await isRealPathInsideAllowedRoots(dirname(target), projectRoot))
|
|
782
|
+
if (!(await isRealPathInsideAllowedRoots(dirname(target), projectRoot)))
|
|
566
783
|
throw new ContractError("Contract output parent resolves outside the project root or GHOSTAPI_DATA_DIR through a symlink.");
|
|
567
784
|
const existing = await lstat(target).catch((error) => isErrorCode(error, "ENOENT") ? null : Promise.reject(error));
|
|
568
785
|
if (existing?.isSymbolicLink())
|
|
@@ -577,16 +794,23 @@ function assertPathInsideAllowedRoots(projectRoot, target, label) {
|
|
|
577
794
|
}
|
|
578
795
|
function isInside(root, target) {
|
|
579
796
|
const relativePath = relative(resolve(root), target);
|
|
580
|
-
return relativePath !== ".." &&
|
|
797
|
+
return (relativePath !== ".." &&
|
|
798
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
799
|
+
!isAbsolute(relativePath));
|
|
581
800
|
}
|
|
582
801
|
async function isRealPathInsideAllowedRoots(target, projectRoot) {
|
|
583
802
|
const realTarget = await realpath(target);
|
|
584
803
|
const realProjectRoot = await realpath(projectRoot);
|
|
585
804
|
const dataRoot = await realpath(getDataPaths().root).catch(() => null);
|
|
586
|
-
return isInside(realProjectRoot, realTarget) ||
|
|
805
|
+
return (isInside(realProjectRoot, realTarget) ||
|
|
806
|
+
(dataRoot !== null && isInside(dataRoot, realTarget)));
|
|
587
807
|
}
|
|
588
808
|
function slugify(value) {
|
|
589
|
-
return value
|
|
809
|
+
return (value
|
|
810
|
+
.toLowerCase()
|
|
811
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
812
|
+
.replace(/^-|-$/g, "")
|
|
813
|
+
.slice(0, 80) || "contract");
|
|
590
814
|
}
|
|
591
815
|
function stableStringify(value) {
|
|
592
816
|
if (value === undefined)
|
|
@@ -595,7 +819,11 @@ function stableStringify(value) {
|
|
|
595
819
|
return JSON.stringify(value);
|
|
596
820
|
if (Array.isArray(value))
|
|
597
821
|
return `[${value.map(stableStringify).join(",")}]`;
|
|
598
|
-
return `{${Object.entries(value)
|
|
822
|
+
return `{${Object.entries(value)
|
|
823
|
+
.filter(([, entry]) => entry !== undefined)
|
|
824
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
825
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`)
|
|
826
|
+
.join(",")}}`;
|
|
599
827
|
}
|
|
600
828
|
function isErrorCode(error, code) {
|
|
601
829
|
return error instanceof Error && "code" in error && error.code === code;
|