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