@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,8 +1,8 @@
|
|
|
1
1
|
import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, isAbsolute, relative, resolve, sep, join } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep, join, } from "node:path";
|
|
3
3
|
import { getDataPaths } from "../config/dataPaths.js";
|
|
4
4
|
import { atomicWriteJson } from "../storage/fileStore.js";
|
|
5
|
-
import { isSecretFieldName, sanitizeSecretString, sanitizeSecrets } from "../security/secrets.js";
|
|
5
|
+
import { isSecretFieldName, sanitizeSecretString, sanitizeSecrets, } from "../security/secrets.js";
|
|
6
6
|
import { isSafeRelativeLocation } from "../security/headerSanitizer.js";
|
|
7
7
|
const MAX_CAPTURE_BYTES = 1024 * 1024;
|
|
8
8
|
const MAX_BUNDLE_BYTES = 512 * 1024;
|
|
@@ -11,10 +11,34 @@ const MAX_VARIABLES = 200;
|
|
|
11
11
|
const MAX_VALUE_DEPTH = 30;
|
|
12
12
|
const MAX_VALUE_KEYS = 200;
|
|
13
13
|
const VARIABLE_PATTERN = /\{\{ghostapi\.var\.([a-z][a-z0-9_]*)\}\}/g;
|
|
14
|
-
const SENSITIVE_QUERY_KEYS = new Set([
|
|
14
|
+
const SENSITIVE_QUERY_KEYS = new Set([
|
|
15
|
+
"api_key",
|
|
16
|
+
"apikey",
|
|
17
|
+
"access_token",
|
|
18
|
+
"refresh_token",
|
|
19
|
+
"token",
|
|
20
|
+
"secret",
|
|
21
|
+
"key",
|
|
22
|
+
"client_secret",
|
|
23
|
+
"password",
|
|
24
|
+
"authorization",
|
|
25
|
+
"cookie",
|
|
26
|
+
]);
|
|
15
27
|
const SAFE_REQUEST_HEADERS = new Set(["accept", "content-type"]);
|
|
16
|
-
const SAFE_RESPONSE_HEADERS = new Set([
|
|
17
|
-
|
|
28
|
+
const SAFE_RESPONSE_HEADERS = new Set([
|
|
29
|
+
"content-type",
|
|
30
|
+
"retry-after",
|
|
31
|
+
"location",
|
|
32
|
+
]);
|
|
33
|
+
const METHODS = new Set([
|
|
34
|
+
"GET",
|
|
35
|
+
"POST",
|
|
36
|
+
"PUT",
|
|
37
|
+
"PATCH",
|
|
38
|
+
"DELETE",
|
|
39
|
+
"HEAD",
|
|
40
|
+
"OPTIONS",
|
|
41
|
+
]);
|
|
18
42
|
export class ScenarioBundleError extends Error {
|
|
19
43
|
constructor(message) {
|
|
20
44
|
super(message);
|
|
@@ -47,7 +71,7 @@ export class ScenarioReplayer {
|
|
|
47
71
|
index: this.cursor,
|
|
48
72
|
status: interaction.response.status,
|
|
49
73
|
headers: resolveHeaders(interaction.response.headers, this.bundle.variables, this.bindings),
|
|
50
|
-
body: resolveVariables(interaction.response.body, this.bundle.variables, this.bindings)
|
|
74
|
+
body: resolveVariables(interaction.response.body, this.bundle.variables, this.bindings),
|
|
51
75
|
};
|
|
52
76
|
}
|
|
53
77
|
get remaining() {
|
|
@@ -67,7 +91,7 @@ export function prepareScenarioRecording(input, options) {
|
|
|
67
91
|
pii: { emails: true, phones: true, addresses: true, ...options.pii },
|
|
68
92
|
categories: new Set(),
|
|
69
93
|
replacements: 0,
|
|
70
|
-
variables: new Map()
|
|
94
|
+
variables: new Map(),
|
|
71
95
|
};
|
|
72
96
|
const hosts = new Set();
|
|
73
97
|
const interactions = rawInteractions.map((raw) => {
|
|
@@ -78,28 +102,32 @@ export function prepareScenarioRecording(input, options) {
|
|
|
78
102
|
method: raw.request.method,
|
|
79
103
|
path: sanitizePath(url, state),
|
|
80
104
|
headers: sanitizeHeaders(raw.request.headers, SAFE_REQUEST_HEADERS, state, false),
|
|
81
|
-
body: sanitizeBody(raw.request.body, raw.request.binary || raw.request.multipart, state)
|
|
105
|
+
body: sanitizeBody(raw.request.body, raw.request.binary || raw.request.multipart, state),
|
|
82
106
|
},
|
|
83
107
|
response: {
|
|
84
108
|
status: raw.response.status,
|
|
85
109
|
headers: sanitizeHeaders(raw.response.headers, SAFE_RESPONSE_HEADERS, state, true),
|
|
86
|
-
body: sanitizeBody(raw.response.body, raw.response.binary || raw.response.multipart, state)
|
|
87
|
-
}
|
|
110
|
+
body: sanitizeBody(raw.response.body, raw.response.binary || raw.response.multipart, state),
|
|
111
|
+
},
|
|
88
112
|
};
|
|
89
113
|
});
|
|
90
114
|
const title = normalizeTitle(options.title ?? "Sandbox recording");
|
|
91
115
|
const summary = {
|
|
92
116
|
categories: [...state.categories].sort(),
|
|
93
117
|
replacements: state.replacements,
|
|
94
|
-
requiresApproval: state.categories.size > 0
|
|
118
|
+
requiresApproval: state.categories.size > 0,
|
|
95
119
|
};
|
|
96
120
|
const bundle = {
|
|
97
121
|
schemaVersion: 1,
|
|
98
122
|
kind: "ghostapi.scenario-bundle",
|
|
99
|
-
metadata: {
|
|
123
|
+
metadata: {
|
|
124
|
+
title,
|
|
125
|
+
recordedAt: options.recordedAt ?? new Date().toISOString(),
|
|
126
|
+
sandboxHosts: [...hosts].sort(),
|
|
127
|
+
},
|
|
100
128
|
sanitization: summary,
|
|
101
129
|
variables: [...state.variables.values()],
|
|
102
|
-
interactions
|
|
130
|
+
interactions,
|
|
103
131
|
};
|
|
104
132
|
return validateScenarioBundle(bundle);
|
|
105
133
|
}
|
|
@@ -130,12 +158,19 @@ export function formatScenarioSanitizationSummary(summary) {
|
|
|
130
158
|
"GhostAPI recording sanitization summary",
|
|
131
159
|
`Categories: ${categories}`,
|
|
132
160
|
`Replacements: ${summary.replacements}`,
|
|
133
|
-
`Approval required: ${summary.requiresApproval ? "yes" : "no"}
|
|
161
|
+
`Approval required: ${summary.requiresApproval ? "yes" : "no"}`,
|
|
134
162
|
].join("\n");
|
|
135
163
|
}
|
|
136
164
|
export function validateScenarioBundle(input) {
|
|
137
165
|
const root = readObject(migrateScenarioBundle(input), "Scenario bundle must be an object.");
|
|
138
|
-
assertExactKeys(root, [
|
|
166
|
+
assertExactKeys(root, [
|
|
167
|
+
"schemaVersion",
|
|
168
|
+
"kind",
|
|
169
|
+
"metadata",
|
|
170
|
+
"sanitization",
|
|
171
|
+
"variables",
|
|
172
|
+
"interactions",
|
|
173
|
+
], "Scenario bundle");
|
|
139
174
|
if (root.schemaVersion !== 1)
|
|
140
175
|
throw new ScenarioBundleError("Unsupported scenario bundle schema version.");
|
|
141
176
|
if (root.kind !== "ghostapi.scenario-bundle")
|
|
@@ -149,7 +184,9 @@ export function validateScenarioBundle(input) {
|
|
|
149
184
|
const sanitization = readObject(root.sanitization, "Scenario bundle sanitization summary is invalid.");
|
|
150
185
|
assertExactKeys(sanitization, ["categories", "replacements", "requiresApproval"], "Scenario bundle sanitization summary");
|
|
151
186
|
const categories = readStringArray(sanitization.categories, "Scenario bundle sanitization categories are invalid.");
|
|
152
|
-
if (!Number.isInteger(sanitization.replacements) ||
|
|
187
|
+
if (!Number.isInteger(sanitization.replacements) ||
|
|
188
|
+
sanitization.replacements < 0 ||
|
|
189
|
+
typeof sanitization.requiresApproval !== "boolean") {
|
|
153
190
|
throw new ScenarioBundleError("Scenario bundle sanitization summary is invalid.");
|
|
154
191
|
}
|
|
155
192
|
if (!Array.isArray(root.variables) || root.variables.length > MAX_VARIABLES)
|
|
@@ -158,7 +195,11 @@ export function validateScenarioBundle(input) {
|
|
|
158
195
|
const variables = root.variables.map((entry) => {
|
|
159
196
|
const variable = readObject(entry, "Scenario bundle variable is invalid.");
|
|
160
197
|
assertExactKeys(variable, ["name", "value"], "Scenario bundle variable");
|
|
161
|
-
if (typeof variable.name !== "string" ||
|
|
198
|
+
if (typeof variable.name !== "string" ||
|
|
199
|
+
!/^[a-z][a-z0-9_]{0,63}$/.test(variable.name) ||
|
|
200
|
+
typeof variable.value !== "string" ||
|
|
201
|
+
variable.value.length > 2_000 ||
|
|
202
|
+
sanitizeSecretString(variable.value) !== variable.value) {
|
|
162
203
|
throw new ScenarioBundleError("Scenario bundle variable is invalid.");
|
|
163
204
|
}
|
|
164
205
|
if (names.has(variable.name))
|
|
@@ -166,17 +207,27 @@ export function validateScenarioBundle(input) {
|
|
|
166
207
|
names.add(variable.name);
|
|
167
208
|
return { name: variable.name, value: variable.value };
|
|
168
209
|
});
|
|
169
|
-
if (!Array.isArray(root.interactions) ||
|
|
210
|
+
if (!Array.isArray(root.interactions) ||
|
|
211
|
+
root.interactions.length === 0 ||
|
|
212
|
+
root.interactions.length > MAX_INTERACTIONS) {
|
|
170
213
|
throw new ScenarioBundleError(`Scenario bundle interactions must contain 1-${MAX_INTERACTIONS} entries.`);
|
|
171
214
|
}
|
|
172
215
|
const interactions = root.interactions.map((entry) => normalizeBundleInteraction(entry, names));
|
|
173
216
|
return {
|
|
174
217
|
schemaVersion: 1,
|
|
175
218
|
kind: "ghostapi.scenario-bundle",
|
|
176
|
-
metadata: {
|
|
177
|
-
|
|
219
|
+
metadata: {
|
|
220
|
+
title,
|
|
221
|
+
recordedAt: metadata.recordedAt,
|
|
222
|
+
sandboxHosts,
|
|
223
|
+
},
|
|
224
|
+
sanitization: {
|
|
225
|
+
categories: [...new Set(categories)].sort(),
|
|
226
|
+
replacements: sanitization.replacements,
|
|
227
|
+
requiresApproval: sanitization.requiresApproval,
|
|
228
|
+
},
|
|
178
229
|
variables,
|
|
179
|
-
interactions
|
|
230
|
+
interactions,
|
|
180
231
|
};
|
|
181
232
|
}
|
|
182
233
|
export function migrateScenarioBundle(input) {
|
|
@@ -185,15 +236,31 @@ export function migrateScenarioBundle(input) {
|
|
|
185
236
|
return source;
|
|
186
237
|
if (source.schemaVersion !== 0)
|
|
187
238
|
throw new ScenarioBundleError("Unsupported scenario bundle schema version; no migration is registered.");
|
|
188
|
-
assertExactKeys(source, [
|
|
239
|
+
assertExactKeys(source, [
|
|
240
|
+
"schemaVersion",
|
|
241
|
+
"kind",
|
|
242
|
+
"title",
|
|
243
|
+
"recordedAt",
|
|
244
|
+
"sandboxHosts",
|
|
245
|
+
"variables",
|
|
246
|
+
"interactions",
|
|
247
|
+
], "Scenario bundle v0");
|
|
189
248
|
return {
|
|
190
249
|
schemaVersion: 1,
|
|
191
250
|
kind: source.kind,
|
|
192
|
-
metadata: {
|
|
251
|
+
metadata: {
|
|
252
|
+
title: source.title,
|
|
253
|
+
recordedAt: source.recordedAt,
|
|
254
|
+
sandboxHosts: source.sandboxHosts,
|
|
255
|
+
},
|
|
193
256
|
// A migrated legacy bundle has no verifiable sanitizer receipt, so force operator review.
|
|
194
|
-
sanitization: {
|
|
257
|
+
sanitization: {
|
|
258
|
+
categories: ["legacy-bundle"],
|
|
259
|
+
replacements: 0,
|
|
260
|
+
requiresApproval: true,
|
|
261
|
+
},
|
|
195
262
|
variables: source.variables,
|
|
196
|
-
interactions: source.interactions
|
|
263
|
+
interactions: source.interactions,
|
|
197
264
|
};
|
|
198
265
|
}
|
|
199
266
|
function normalizeBundleInteraction(input, variableNames) {
|
|
@@ -215,11 +282,17 @@ function normalizeBundleRequest(input, variableNames) {
|
|
|
215
282
|
function normalizeBundleResponse(input, variableNames) {
|
|
216
283
|
const response = readObject(input, "Scenario bundle response is invalid.");
|
|
217
284
|
assertExactKeys(response, ["status", "headers", "body"], "Scenario bundle response");
|
|
218
|
-
if (!Number.isInteger(response.status) ||
|
|
285
|
+
if (!Number.isInteger(response.status) ||
|
|
286
|
+
response.status < 100 ||
|
|
287
|
+
response.status > 599)
|
|
219
288
|
throw new ScenarioBundleError("Scenario bundle response status must be 100-599.");
|
|
220
289
|
const headers = validateHeaders(response.headers, SAFE_RESPONSE_HEADERS, variableNames, true);
|
|
221
290
|
validateValue(response.body, variableNames);
|
|
222
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
status: response.status,
|
|
293
|
+
headers,
|
|
294
|
+
body: structuredClone(response.body),
|
|
295
|
+
};
|
|
223
296
|
}
|
|
224
297
|
function parseCapture(input) {
|
|
225
298
|
const capture = readObject(input, "Recording input must be an object.");
|
|
@@ -243,15 +316,15 @@ function parseDirectInteraction(input) {
|
|
|
243
316
|
headers: requestHeaders,
|
|
244
317
|
body: request.body ?? null,
|
|
245
318
|
binary: request.bodyEncoding === "base64",
|
|
246
|
-
multipart: isMultipart(requestHeaders)
|
|
319
|
+
multipart: isMultipart(requestHeaders),
|
|
247
320
|
},
|
|
248
321
|
response: {
|
|
249
322
|
status: readStatus(response.status),
|
|
250
323
|
headers: responseHeaders,
|
|
251
324
|
body: response.body ?? null,
|
|
252
325
|
binary: response.bodyEncoding === "base64",
|
|
253
|
-
multipart: isMultipart(responseHeaders)
|
|
254
|
-
}
|
|
326
|
+
multipart: isMultipart(responseHeaders),
|
|
327
|
+
},
|
|
255
328
|
};
|
|
256
329
|
}
|
|
257
330
|
function parseHarInteraction(input) {
|
|
@@ -260,8 +333,12 @@ function parseHarInteraction(input) {
|
|
|
260
333
|
const response = readObject(entry.response, "HAR response is invalid.");
|
|
261
334
|
const requestHeaders = readHeaderInput(request.headers);
|
|
262
335
|
const responseHeaders = readHeaderInput(response.headers);
|
|
263
|
-
const postData = request.postData === undefined
|
|
264
|
-
|
|
336
|
+
const postData = request.postData === undefined
|
|
337
|
+
? null
|
|
338
|
+
: readObject(request.postData, "HAR request postData is invalid.");
|
|
339
|
+
const content = response.content === undefined
|
|
340
|
+
? null
|
|
341
|
+
: readObject(response.content, "HAR response content is invalid.");
|
|
265
342
|
return {
|
|
266
343
|
request: {
|
|
267
344
|
method: normalizeMethod(request.method),
|
|
@@ -269,15 +346,15 @@ function parseHarInteraction(input) {
|
|
|
269
346
|
headers: requestHeaders,
|
|
270
347
|
body: parseCaptureText(postData?.text, postData?.mimeType),
|
|
271
348
|
binary: postData?.encoding === "base64",
|
|
272
|
-
multipart: isMultipart(requestHeaders) || isMultipartMime(postData?.mimeType)
|
|
349
|
+
multipart: isMultipart(requestHeaders) || isMultipartMime(postData?.mimeType),
|
|
273
350
|
},
|
|
274
351
|
response: {
|
|
275
352
|
status: readStatus(response.status),
|
|
276
353
|
headers: responseHeaders,
|
|
277
354
|
body: parseCaptureText(content?.text, content?.mimeType),
|
|
278
355
|
binary: content?.encoding === "base64",
|
|
279
|
-
multipart: isMultipart(responseHeaders) || isMultipartMime(content?.mimeType)
|
|
280
|
-
}
|
|
356
|
+
multipart: isMultipart(responseHeaders) || isMultipartMime(content?.mimeType),
|
|
357
|
+
},
|
|
281
358
|
};
|
|
282
359
|
}
|
|
283
360
|
function parseCaptureText(value, mimeType) {
|
|
@@ -317,7 +394,11 @@ function sanitizeHeaders(headers, allowed, state, response) {
|
|
|
317
394
|
for (const [name, value] of Object.entries(headers)) {
|
|
318
395
|
const lower = name.toLowerCase();
|
|
319
396
|
if (isSecretFieldName(lower)) {
|
|
320
|
-
mark(state, lower.includes("cookie")
|
|
397
|
+
mark(state, lower.includes("cookie")
|
|
398
|
+
? "cookie"
|
|
399
|
+
: lower.includes("authorization")
|
|
400
|
+
? "authorization"
|
|
401
|
+
: "secret-header");
|
|
321
402
|
continue;
|
|
322
403
|
}
|
|
323
404
|
if (!allowed.has(lower))
|
|
@@ -355,7 +436,9 @@ function sanitizeValue(value, state, key, depth = 0) {
|
|
|
355
436
|
mark(state, "known-api-key");
|
|
356
437
|
return [];
|
|
357
438
|
}
|
|
358
|
-
return [
|
|
439
|
+
return [
|
|
440
|
+
[childKey, sanitizeValue(childValue, state, childKey, depth + 1)],
|
|
441
|
+
];
|
|
359
442
|
}));
|
|
360
443
|
}
|
|
361
444
|
if (typeof value !== "string")
|
|
@@ -364,18 +447,22 @@ function sanitizeValue(value, state, key, depth = 0) {
|
|
|
364
447
|
mark(state, "known-api-key");
|
|
365
448
|
return "***";
|
|
366
449
|
}
|
|
367
|
-
if (state.pii.addresses &&
|
|
450
|
+
if (state.pii.addresses &&
|
|
451
|
+
key !== undefined &&
|
|
452
|
+
/(?:address|line1|line2|city|state|postal|zip)/i.test(key)) {
|
|
368
453
|
mark(state, "address");
|
|
369
454
|
return "[redacted:address]";
|
|
370
455
|
}
|
|
371
456
|
let result = sanitizeSecretString(value);
|
|
372
457
|
if (result !== value)
|
|
373
458
|
mark(state, "known-api-key");
|
|
374
|
-
if (state.pii.emails &&
|
|
459
|
+
if (state.pii.emails &&
|
|
460
|
+
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(result)) {
|
|
375
461
|
result = result.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted:email]");
|
|
376
462
|
mark(state, "email");
|
|
377
463
|
}
|
|
378
|
-
if (state.pii.phones &&
|
|
464
|
+
if (state.pii.phones &&
|
|
465
|
+
/(?:\+\d[\d(). -]{7,}\d|\b\d{3}[ .-]\d{3}[ .-]\d{4}\b)/.test(result)) {
|
|
379
466
|
result = result.replace(/(?:\+\d[\d(). -]{7,}\d|\b\d{3}[ .-]\d{3}[ .-]\d{4}\b)/g, "[redacted:phone]");
|
|
380
467
|
mark(state, "phone");
|
|
381
468
|
}
|
|
@@ -385,8 +472,12 @@ function sanitizeValue(value, state, key, depth = 0) {
|
|
|
385
472
|
}
|
|
386
473
|
function replaceUnstableIdentifiers(value, state, key) {
|
|
387
474
|
return value.replace(/\b(?:cus|pi|in|sub|evt|re|ch|pm|price|prod|cs|ord|user|msg|email)_[A-Za-z0-9]+\b|\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, (match) => {
|
|
388
|
-
const prefix = match.includes("_")
|
|
389
|
-
|
|
475
|
+
const prefix = match.includes("_")
|
|
476
|
+
? match.split("_", 1)[0].toLowerCase()
|
|
477
|
+
: "id";
|
|
478
|
+
const nameHint = key !== undefined && /id$/i.test(key)
|
|
479
|
+
? key.replace(/[^a-z0-9]+/gi, "_").toLowerCase()
|
|
480
|
+
: `${prefix}_id`;
|
|
390
481
|
return variableReference(getVariable(state, match, nameHint, `${prefix}_recorded_${state.variables.size + 1}`));
|
|
391
482
|
});
|
|
392
483
|
}
|
|
@@ -396,7 +487,10 @@ function getVariable(state, raw, hint, value) {
|
|
|
396
487
|
return existing;
|
|
397
488
|
if (state.variables.size >= MAX_VARIABLES)
|
|
398
489
|
throw new ScenarioBundleError(`Recording exceeds ${MAX_VARIABLES} stateful variables.`);
|
|
399
|
-
const base = hint
|
|
490
|
+
const base = hint
|
|
491
|
+
.replace(/[^a-z0-9]+/gi, "_")
|
|
492
|
+
.replace(/^_+|_+$/g, "")
|
|
493
|
+
.toLowerCase() || "value";
|
|
400
494
|
let name = base;
|
|
401
495
|
let suffix = 1;
|
|
402
496
|
const names = new Set([...state.variables.values()].map((entry) => entry.name));
|
|
@@ -433,13 +527,21 @@ function parseSandboxUrl(value, allowedHosts, headers) {
|
|
|
433
527
|
function looksLikeSandboxHost(host, headers) {
|
|
434
528
|
if (host === "api.stripe.com")
|
|
435
529
|
return /\b(?:sk|rk)_test_[A-Za-z0-9_-]+/i.test(headers.authorization ?? "");
|
|
436
|
-
return host === "localhost" ||
|
|
530
|
+
return (host === "localhost" ||
|
|
531
|
+
host.endsWith(".localhost") ||
|
|
532
|
+
/(?:sandbox|test)/i.test(host));
|
|
437
533
|
}
|
|
438
534
|
function normalizeAllowedSandboxHosts(hosts) {
|
|
439
535
|
if (hosts.length === 0)
|
|
440
536
|
throw new ScenarioBundleError("Recording requires at least one explicit --allow-sandbox-host.");
|
|
441
|
-
const normalized = hosts
|
|
442
|
-
|
|
537
|
+
const normalized = hosts
|
|
538
|
+
.map((host) => host.trim().toLowerCase())
|
|
539
|
+
.filter(Boolean);
|
|
540
|
+
if (normalized.length !== hosts.length ||
|
|
541
|
+
normalized.some((host) => !/^[a-z0-9.-]+$/.test(host) ||
|
|
542
|
+
host.includes("..") ||
|
|
543
|
+
host.startsWith(".") ||
|
|
544
|
+
host.endsWith("."))) {
|
|
443
545
|
throw new ScenarioBundleError("Allowed sandbox hosts must be plain host names without paths, ports, or wildcards.");
|
|
444
546
|
}
|
|
445
547
|
return new Set(normalized);
|
|
@@ -449,7 +551,7 @@ function normalizeReplayRequest(input) {
|
|
|
449
551
|
method: normalizeMethod(input.method),
|
|
450
552
|
path: normalizeRelativePath(input.path),
|
|
451
553
|
headers: normalizeReplayHeaders(input.headers),
|
|
452
|
-
body: sanitizeSecrets(input.body ?? null)
|
|
554
|
+
body: sanitizeSecrets(input.body ?? null),
|
|
453
555
|
};
|
|
454
556
|
}
|
|
455
557
|
function normalizeReplayHeaders(headers) {
|
|
@@ -476,14 +578,22 @@ function compareRequest(expected, actual, bindings, variables) {
|
|
|
476
578
|
return mismatches;
|
|
477
579
|
}
|
|
478
580
|
function resolveHeaders(headers, variables, bindings) {
|
|
479
|
-
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [
|
|
581
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [
|
|
582
|
+
key,
|
|
583
|
+
resolveTemplate(value, variables, bindings),
|
|
584
|
+
]));
|
|
480
585
|
}
|
|
481
586
|
function resolveVariables(value, variables, bindings) {
|
|
482
587
|
if (Array.isArray(value))
|
|
483
588
|
return value.map((entry) => resolveVariables(entry, variables, bindings));
|
|
484
589
|
if (value !== null && typeof value === "object")
|
|
485
|
-
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
486
|
-
|
|
590
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
591
|
+
key,
|
|
592
|
+
resolveVariables(entry, variables, bindings),
|
|
593
|
+
]));
|
|
594
|
+
return typeof value === "string"
|
|
595
|
+
? resolveTemplate(value, variables, bindings)
|
|
596
|
+
: value;
|
|
487
597
|
}
|
|
488
598
|
function resolveTemplate(value, variables, bindings) {
|
|
489
599
|
const dictionary = new Map(variables.map((entry) => [entry.name, entry.value]));
|
|
@@ -496,14 +606,22 @@ function resolveTemplate(value, variables, bindings) {
|
|
|
496
606
|
function matchValue(expected, actual, bindings, variables) {
|
|
497
607
|
if (typeof expected === "string" && typeof actual === "string")
|
|
498
608
|
return matchTemplate(expected, actual, bindings, variables);
|
|
499
|
-
if (expected === null ||
|
|
609
|
+
if (expected === null ||
|
|
610
|
+
actual === null ||
|
|
611
|
+
typeof expected !== "object" ||
|
|
612
|
+
typeof actual !== "object")
|
|
500
613
|
return expected === actual;
|
|
501
614
|
if (Array.isArray(expected) || Array.isArray(actual)) {
|
|
502
|
-
return Array.isArray(expected) &&
|
|
615
|
+
return (Array.isArray(expected) &&
|
|
616
|
+
Array.isArray(actual) &&
|
|
617
|
+
expected.length === actual.length &&
|
|
618
|
+
expected.every((entry, index) => matchValue(entry, actual[index], bindings, variables)));
|
|
503
619
|
}
|
|
504
620
|
const expectedEntries = Object.entries(expected);
|
|
505
621
|
const actualRecord = actual;
|
|
506
|
-
return expectedEntries.length === Object.keys(actualRecord).length &&
|
|
622
|
+
return (expectedEntries.length === Object.keys(actualRecord).length &&
|
|
623
|
+
expectedEntries.every(([key, entry]) => Object.hasOwn(actualRecord, key) &&
|
|
624
|
+
matchValue(entry, actualRecord[key], bindings, variables)));
|
|
507
625
|
}
|
|
508
626
|
function matchTemplate(expected, actual, bindings, variables) {
|
|
509
627
|
const known = new Set(variables.map((entry) => entry.name));
|
|
@@ -546,7 +664,10 @@ function validateHeaders(input, allowed, variableNames, response) {
|
|
|
546
664
|
const normalized = {};
|
|
547
665
|
for (const [name, value] of Object.entries(headers)) {
|
|
548
666
|
const lower = name.toLowerCase();
|
|
549
|
-
if (!allowed.has(lower) ||
|
|
667
|
+
if (!allowed.has(lower) ||
|
|
668
|
+
typeof value !== "string" ||
|
|
669
|
+
value.length > 2_000 ||
|
|
670
|
+
isSecretFieldName(lower))
|
|
550
671
|
throw new ScenarioBundleError(`Scenario bundle header is not allowed: ${name}`);
|
|
551
672
|
if (response && lower === "location" && !isSafeRelativeLocation(value))
|
|
552
673
|
throw new ScenarioBundleError("Scenario bundle redirect location must be relative.");
|
|
@@ -615,7 +736,7 @@ async function resolveExistingPath(path, projectRoot, label) {
|
|
|
615
736
|
const info = await lstat(target);
|
|
616
737
|
if (!info.isFile() || info.isSymbolicLink())
|
|
617
738
|
throw new ScenarioBundleError(`${label} must be a regular non-symlink file.`);
|
|
618
|
-
if (!await isRealPathInsideAllowedRoots(target, projectRoot))
|
|
739
|
+
if (!(await isRealPathInsideAllowedRoots(target, projectRoot)))
|
|
619
740
|
throw new ScenarioBundleError(`${label} resolves outside the project root or GHOSTAPI_DATA_DIR through a symlink.`);
|
|
620
741
|
return target;
|
|
621
742
|
}
|
|
@@ -626,7 +747,7 @@ async function resolveOutputPath(path, projectRoot) {
|
|
|
626
747
|
const parent = await lstat(dirname(target));
|
|
627
748
|
if (!parent.isDirectory() || parent.isSymbolicLink())
|
|
628
749
|
throw new ScenarioBundleError("Scenario bundle output parent must be a real directory, not a symlink.");
|
|
629
|
-
if (!await isRealPathInsideAllowedRoots(dirname(target), projectRoot))
|
|
750
|
+
if (!(await isRealPathInsideAllowedRoots(dirname(target), projectRoot)))
|
|
630
751
|
throw new ScenarioBundleError("Scenario bundle output parent resolves outside the project root or GHOSTAPI_DATA_DIR through a symlink.");
|
|
631
752
|
const existing = await lstat(target).catch((error) => isErrorCode(error, "ENOENT") ? null : Promise.reject(error));
|
|
632
753
|
if (existing?.isSymbolicLink())
|
|
@@ -641,13 +762,16 @@ function assertPathInsideAllowedRoots(projectRoot, target, label) {
|
|
|
641
762
|
}
|
|
642
763
|
function isInside(root, target) {
|
|
643
764
|
const relativePath = relative(resolve(root), target);
|
|
644
|
-
return relativePath !== ".." &&
|
|
765
|
+
return (relativePath !== ".." &&
|
|
766
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
767
|
+
!isAbsolute(relativePath));
|
|
645
768
|
}
|
|
646
769
|
async function isRealPathInsideAllowedRoots(target, projectRoot) {
|
|
647
770
|
const realTarget = await realpath(target);
|
|
648
771
|
const realProjectRoot = await realpath(projectRoot);
|
|
649
772
|
const dataRoot = await realpath(getDataPaths().root).catch(() => null);
|
|
650
|
-
return isInside(realProjectRoot, realTarget) ||
|
|
773
|
+
return (isInside(realProjectRoot, realTarget) ||
|
|
774
|
+
(dataRoot !== null && isInside(dataRoot, realTarget)));
|
|
651
775
|
}
|
|
652
776
|
function readObject(value, message) {
|
|
653
777
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
@@ -688,12 +812,19 @@ function readUrl(value) {
|
|
|
688
812
|
return value;
|
|
689
813
|
}
|
|
690
814
|
function readStatus(value) {
|
|
691
|
-
if (!Number.isInteger(value) ||
|
|
815
|
+
if (!Number.isInteger(value) ||
|
|
816
|
+
value < 100 ||
|
|
817
|
+
value > 599)
|
|
692
818
|
throw new ScenarioBundleError("Capture response status must be 100-599.");
|
|
693
819
|
return value;
|
|
694
820
|
}
|
|
695
821
|
function normalizeRelativePath(value) {
|
|
696
|
-
if (typeof value !== "string" ||
|
|
822
|
+
if (typeof value !== "string" ||
|
|
823
|
+
value.length === 0 ||
|
|
824
|
+
value.length > 8_000 ||
|
|
825
|
+
!value.startsWith("/") ||
|
|
826
|
+
value.startsWith("//") ||
|
|
827
|
+
/[\r\n]/.test(value)) {
|
|
697
828
|
throw new ScenarioBundleError("Scenario request path must be a relative path.");
|
|
698
829
|
}
|
|
699
830
|
return value;
|
|
@@ -705,7 +836,8 @@ function readHostArray(value) {
|
|
|
705
836
|
return [...new Set(hosts)].sort();
|
|
706
837
|
}
|
|
707
838
|
function readStringArray(value, message) {
|
|
708
|
-
if (!Array.isArray(value) ||
|
|
839
|
+
if (!Array.isArray(value) ||
|
|
840
|
+
value.some((entry) => typeof entry !== "string" || entry.length > 256))
|
|
709
841
|
throw new ScenarioBundleError(message);
|
|
710
842
|
return value;
|
|
711
843
|
}
|
|
@@ -716,25 +848,26 @@ function isMultipartMime(value) {
|
|
|
716
848
|
return typeof value === "string" && /^multipart\//i.test(value);
|
|
717
849
|
}
|
|
718
850
|
function isTimestamp(value) {
|
|
719
|
-
return typeof value === "string" &&
|
|
851
|
+
return (typeof value === "string" &&
|
|
852
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
|
|
853
|
+
Number.isFinite(Date.parse(value)));
|
|
720
854
|
}
|
|
721
855
|
function deterministicTimestamp(index) {
|
|
722
856
|
return `2020-01-01T00:00:${String(index % 60).padStart(2, "0")}.000Z`;
|
|
723
857
|
}
|
|
724
858
|
function normalizeTitle(value) {
|
|
725
|
-
if (typeof value !== "string" ||
|
|
859
|
+
if (typeof value !== "string" ||
|
|
860
|
+
value.trim() === "" ||
|
|
861
|
+
value.trim().length > 120)
|
|
726
862
|
throw new ScenarioBundleError("Scenario bundle title must be 1-120 characters.");
|
|
727
863
|
return value.trim();
|
|
728
864
|
}
|
|
729
865
|
function slugify(value) {
|
|
730
|
-
return value
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (Array.isArray(value))
|
|
736
|
-
return `[${value.map(stableStringify).join(",")}]`;
|
|
737
|
-
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(",")}}`;
|
|
866
|
+
return (value
|
|
867
|
+
.toLowerCase()
|
|
868
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
869
|
+
.replace(/^-|-$/g, "")
|
|
870
|
+
.slice(0, 80) || "sandbox-recording");
|
|
738
871
|
}
|
|
739
872
|
function serializedBytes(value, label) {
|
|
740
873
|
try {
|