@yiaany/ghostapi 0.1.7 → 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 +42 -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 +27 -12
- 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,594 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getDataPaths } from "../config/dataPaths.js";
|
|
5
|
+
import { actionApprovalHash, actionHash, validateStoredAction } from "../actions/index.js";
|
|
6
|
+
import { createScenarioReplayer, prepareScenarioRecording, validateScenarioBundle, writeScenarioBundle } from "../scenarios/scenarioBundle.js";
|
|
7
|
+
import { sanitizeSecretString } from "../security/secrets.js";
|
|
8
|
+
import { atomicWriteJson, ensurePrivateDirectory, withFileLock } from "../storage/fileStore.js";
|
|
9
|
+
import { createWorld, inspectWorld, SyntheticWorldError } from "../worlds/index.js";
|
|
10
|
+
const LEDGER_SCHEMA_VERSION = 1;
|
|
11
|
+
const LEDGER_KIND = "ghostapi.action-ledger";
|
|
12
|
+
const MAX_LEDGER_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const MAX_ENTRIES = 2_000;
|
|
14
|
+
const MAX_TENANTS = 200;
|
|
15
|
+
const IDENTIFIER = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
16
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
17
|
+
export class ActionLedgerError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ActionLedgerError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class LocalActionLedger {
|
|
24
|
+
path;
|
|
25
|
+
incidentsPath;
|
|
26
|
+
now;
|
|
27
|
+
accessAuthorizer;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.path = options.path ?? getDataPaths().actionLedger;
|
|
30
|
+
this.incidentsPath = options.incidentsPath ?? getDataPaths().incidents;
|
|
31
|
+
this.now = options.now ?? (() => new Date());
|
|
32
|
+
this.accessAuthorizer = options.accessAuthorizer ?? denyLedgerAccess;
|
|
33
|
+
}
|
|
34
|
+
async recordAction(accessValue, recordValue) {
|
|
35
|
+
const access = this.authorize(accessValue, "append");
|
|
36
|
+
const record = normalizeActionRecord(recordValue);
|
|
37
|
+
return this.mutate(async (state) => {
|
|
38
|
+
const existing = entriesForAction(state, access.tenantId, record.envelope.actionId);
|
|
39
|
+
if (existing.length > 0) {
|
|
40
|
+
if (existing[0].actionHash !== actionHash(record.envelope))
|
|
41
|
+
throw new ActionLedgerError("Action id already has a different ledger timeline in this tenant.");
|
|
42
|
+
return { state, result: existing };
|
|
43
|
+
}
|
|
44
|
+
const entries = [];
|
|
45
|
+
const append = (stage, data) => {
|
|
46
|
+
const entry = appendEntry(state, access.tenantId, record.envelope.actionId, actionHash(record.envelope), stage, data, this.now().toISOString());
|
|
47
|
+
entries.push(entry);
|
|
48
|
+
};
|
|
49
|
+
append("intent", {
|
|
50
|
+
operation: record.envelope.operation,
|
|
51
|
+
resourceType: record.envelope.resource.type,
|
|
52
|
+
resourceIdHash: sha256(record.envelope.resource.id),
|
|
53
|
+
argumentsHash: sha256(canonicalJson(record.envelope.arguments)),
|
|
54
|
+
expectedEffectsHash: sha256(canonicalJson(record.envelope.expectedSideEffects)),
|
|
55
|
+
riskClass: record.envelope.riskClass,
|
|
56
|
+
reversibility: record.envelope.reversibility
|
|
57
|
+
});
|
|
58
|
+
append("identity", { actorId: record.envelope.actor.id, workloadId: record.envelope.actor.workloadId, actorType: record.envelope.actor.type });
|
|
59
|
+
append("policy_decision", { policyVersion: record.envelope.policy.version, policyHash: record.envelope.policy.hash, allowed: true, basis: "caller_claimed" });
|
|
60
|
+
append("approval", { approvalHash: actionApprovalHash(record.approval), approverId: record.approval.approvedBy, status: "approved", basis: "caller_claimed" });
|
|
61
|
+
append("credential_grant", record.credentialGrant === undefined
|
|
62
|
+
? { status: "not_used" }
|
|
63
|
+
: { status: "granted", grantIdHash: hash(record.credentialGrant.grantIdHash, "Credential grant reference"), credentialVersion: positiveInteger(record.credentialGrant.credentialVersion, "Credential version") });
|
|
64
|
+
for (const receipt of record.receipts)
|
|
65
|
+
appendReceiptEvidence(append, receipt);
|
|
66
|
+
append("compensation", { status: record.envelope.reversibility === "compensatable" ? "not_attempted" : "not_supported" });
|
|
67
|
+
return { state, result: entries };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async timeline(accessValue, actionIdValue) {
|
|
71
|
+
const access = this.authorize(accessValue, "read");
|
|
72
|
+
const actionId = identifier(actionIdValue, "Action id");
|
|
73
|
+
const state = await this.readState();
|
|
74
|
+
assertTenantIntegrity(state, access.tenantId);
|
|
75
|
+
return entriesForAction(state, access.tenantId, actionId);
|
|
76
|
+
}
|
|
77
|
+
async exportTenant(accessValue) {
|
|
78
|
+
const access = this.authorize(accessValue, "export");
|
|
79
|
+
const state = await this.readState();
|
|
80
|
+
const integrity = verifyTenantState(state, access.tenantId);
|
|
81
|
+
if (!integrity.valid)
|
|
82
|
+
throw new ActionLedgerError(`Ledger integrity verification failed: ${integrity.error}`);
|
|
83
|
+
return {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
kind: "ghostapi.action-ledger-export",
|
|
86
|
+
tenantId: access.tenantId,
|
|
87
|
+
exportedAt: this.now().toISOString(),
|
|
88
|
+
integrity,
|
|
89
|
+
entries: state.entries.filter((entry) => entry.tenantId === access.tenantId).map((entry) => structuredClone(entry))
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async verifyTenant(accessValue) {
|
|
93
|
+
const access = this.authorize(accessValue, "read");
|
|
94
|
+
try {
|
|
95
|
+
return verifyTenantState(await this.readState(), access.tenantId);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return { valid: false, error: error instanceof Error ? error.message : "Ledger state could not be validated." };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async configureRetention(accessValue, retentionDaysValue) {
|
|
102
|
+
const access = this.authorize(accessValue, "manage_retention");
|
|
103
|
+
const retentionDays = retentionDaysValue === null ? null : boundedInteger(retentionDaysValue, "Retention days", 1, 3_650);
|
|
104
|
+
return this.mutate(async (state) => {
|
|
105
|
+
const tenant = getOrCreateTenant(state, access.tenantId);
|
|
106
|
+
tenant.retentionDays = retentionDays;
|
|
107
|
+
appendEntry(state, access.tenantId, governanceActionId("retention", state, access.tenantId), sha256(`retention:${access.tenantId}`), "retention", { retentionDays }, this.now().toISOString());
|
|
108
|
+
return { state, result: structuredClone(tenant) };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async setLegalHold(accessValue, active) {
|
|
112
|
+
const access = this.authorize(accessValue, "manage_hold");
|
|
113
|
+
if (typeof active !== "boolean")
|
|
114
|
+
throw new ActionLedgerError("Legal hold state must be boolean.");
|
|
115
|
+
return this.mutate(async (state) => {
|
|
116
|
+
const tenant = getOrCreateTenant(state, access.tenantId);
|
|
117
|
+
tenant.legalHold = active;
|
|
118
|
+
appendEntry(state, access.tenantId, governanceActionId("hold", state, access.tenantId), sha256(`legal-hold:${access.tenantId}`), "legal_hold", { active }, this.now().toISOString());
|
|
119
|
+
return { state, result: structuredClone(tenant) };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async requestDeletion(accessValue) {
|
|
123
|
+
const access = this.authorize(accessValue, "request_deletion");
|
|
124
|
+
return this.mutate(async (state) => {
|
|
125
|
+
const tenant = getOrCreateTenant(state, access.tenantId);
|
|
126
|
+
if (tenant.legalHold)
|
|
127
|
+
throw new ActionLedgerError("Deletion request is blocked by the active local legal hold.");
|
|
128
|
+
if (tenant.deletionRequestedAt === undefined) {
|
|
129
|
+
tenant.deletionRequestedAt = this.now().toISOString();
|
|
130
|
+
appendEntry(state, access.tenantId, governanceActionId("deletion", state, access.tenantId), sha256(`deletion:${access.tenantId}`), "deletion_request", { status: "requested" }, tenant.deletionRequestedAt);
|
|
131
|
+
}
|
|
132
|
+
return { state, result: structuredClone(tenant) };
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async createIncidentFixture(accessValue, actionIdValue) {
|
|
136
|
+
const access = this.authorize(accessValue, "read");
|
|
137
|
+
const actionId = identifier(actionIdValue, "Action id");
|
|
138
|
+
const state = await this.readState();
|
|
139
|
+
const integrity = verifyTenantState(state, access.tenantId);
|
|
140
|
+
if (!integrity.valid)
|
|
141
|
+
throw new ActionLedgerError(`Ledger integrity verification failed: ${integrity.error}`);
|
|
142
|
+
const timeline = entriesForAction(state, access.tenantId, actionId);
|
|
143
|
+
const intent = timeline.find((entry) => entry.stage === "intent");
|
|
144
|
+
if (intent === undefined)
|
|
145
|
+
throw new ActionLedgerError("Incident replay requires an action intent in the tenant-scoped ledger timeline.");
|
|
146
|
+
const verification = timeline.filter((entry) => entry.stage === "verification").at(-1);
|
|
147
|
+
const outcome = verification?.data.status === "verified" ? "verified" : "requires_reconciliation";
|
|
148
|
+
const actionDigest = intent.actionHash;
|
|
149
|
+
const runId = deterministicUuid(actionDigest);
|
|
150
|
+
const capture = {
|
|
151
|
+
interactions: [{
|
|
152
|
+
request: { method: "POST", url: `https://incident.sandbox.localhost/incidents/${actionId}/runs/${runId}`, headers: { "content-type": "application/json" }, body: { action: actionId, actionHash: actionDigest } },
|
|
153
|
+
response: { status: outcome === "verified" ? 200 : 409, headers: { "content-type": "application/json" }, body: { outcome, action: actionId } }
|
|
154
|
+
}]
|
|
155
|
+
};
|
|
156
|
+
const bundle = prepareScenarioRecording(capture, { title: `Incident ${actionId}`, allowedSandboxHosts: ["incident.sandbox.localhost"], recordedAt: this.now().toISOString() });
|
|
157
|
+
const worldId = `incident-${actionDigest.slice(0, 16)}`;
|
|
158
|
+
const seed = `incident-${actionDigest.slice(0, 32)}`;
|
|
159
|
+
try {
|
|
160
|
+
const world = await inspectWorld(worldId);
|
|
161
|
+
if (world.manifest.seed !== seed)
|
|
162
|
+
throw new ActionLedgerError("Incident synthetic world id collides with a different deterministic seed.");
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof SyntheticWorldError && error.code === "WORLD_NOT_FOUND")
|
|
166
|
+
await createWorld({ id: worldId, seed, title: `Incident replay ${actionId}` });
|
|
167
|
+
else if (error instanceof ActionLedgerError)
|
|
168
|
+
throw error;
|
|
169
|
+
else
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
const request = { method: "POST", path: `/incidents/${actionId}/runs/${runId}`, headers: { "content-type": "application/json" }, body: { action: actionId, actionHash: actionDigest } };
|
|
173
|
+
const fixture = {
|
|
174
|
+
schemaVersion: 1,
|
|
175
|
+
kind: "ghostapi.incident-fixture",
|
|
176
|
+
tenantId: access.tenantId,
|
|
177
|
+
actionId,
|
|
178
|
+
actionHash: actionDigest,
|
|
179
|
+
sourceLedgerHeadHash: integrity.headHash,
|
|
180
|
+
syntheticWorld: { id: worldId, version: "1.0.0", seed },
|
|
181
|
+
bundle,
|
|
182
|
+
request,
|
|
183
|
+
expected: { status: outcome === "verified" ? 200 : 409, outcome }
|
|
184
|
+
};
|
|
185
|
+
await ensurePrivateDirectory(this.incidentsPath);
|
|
186
|
+
const prefix = `incident-${actionDigest.slice(0, 16)}`;
|
|
187
|
+
const bundlePath = await writeScenarioBundle(bundle, join(this.incidentsPath, `${prefix}.bundle.json`), getDataPaths().root);
|
|
188
|
+
const fixturePath = join(this.incidentsPath, `${prefix}.fixture.json`);
|
|
189
|
+
await atomicWriteJson(fixturePath, validateIncidentFixture(fixture));
|
|
190
|
+
return { fixture, bundlePath, fixturePath };
|
|
191
|
+
}
|
|
192
|
+
async replayIncidentFixture(accessValue, fixtureValue) {
|
|
193
|
+
const access = this.authorize(accessValue, "read");
|
|
194
|
+
const fixture = validateIncidentFixture(fixtureValue);
|
|
195
|
+
if (fixture.tenantId !== access.tenantId)
|
|
196
|
+
throw new ActionLedgerError("Incident fixture belongs to a different tenant.");
|
|
197
|
+
const replay = createScenarioReplayer(fixture.bundle);
|
|
198
|
+
const result = replay.replay(fixture.request);
|
|
199
|
+
const outcome = readOutcome(result.body);
|
|
200
|
+
if (result.status !== fixture.expected.status || outcome !== fixture.expected.outcome || replay.remaining !== 0)
|
|
201
|
+
throw new ActionLedgerError("Incident fixture did not reproduce its expected local outcome.");
|
|
202
|
+
return { status: result.status, outcome, remaining: replay.remaining };
|
|
203
|
+
}
|
|
204
|
+
authorize(access, permission) {
|
|
205
|
+
const authorized = this.accessAuthorizer(access, permission, accessTenantId(access));
|
|
206
|
+
if (!authorized.permissions.includes(permission))
|
|
207
|
+
throw new ActionLedgerError("Ledger access authorizer returned an insufficient permission set.");
|
|
208
|
+
return { tenantId: identifier(authorized.tenantId, "Ledger tenant id"), principalId: identifier(authorized.principalId, "Ledger principal id"), permissions: [...authorized.permissions] };
|
|
209
|
+
}
|
|
210
|
+
async readState() {
|
|
211
|
+
await ensurePrivateDirectory(getDataPaths().root);
|
|
212
|
+
const info = await lstat(this.path).catch((error) => isErrorCode(error, "ENOENT") ? null : Promise.reject(error));
|
|
213
|
+
if (info === null)
|
|
214
|
+
return emptyState();
|
|
215
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
216
|
+
throw new ActionLedgerError("Action ledger must be a regular non-symlink file.");
|
|
217
|
+
const source = await readFile(this.path, "utf8");
|
|
218
|
+
if (Buffer.byteLength(source, "utf8") > MAX_LEDGER_BYTES)
|
|
219
|
+
throw new ActionLedgerError(`Action ledger exceeds ${MAX_LEDGER_BYTES} bytes.`);
|
|
220
|
+
try {
|
|
221
|
+
return validateState(JSON.parse(source));
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (error instanceof ActionLedgerError)
|
|
225
|
+
throw error;
|
|
226
|
+
throw new ActionLedgerError("Action ledger is not valid JSON.");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async mutate(operation) {
|
|
230
|
+
return withFileLock(this.path, async () => {
|
|
231
|
+
const state = await this.readState();
|
|
232
|
+
const { state: next, result } = await operation(state);
|
|
233
|
+
validateState(next);
|
|
234
|
+
await atomicWriteJson(this.path, next);
|
|
235
|
+
return result;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export function createLocalActionLedger(options = {}) {
|
|
240
|
+
return new LocalActionLedger(options);
|
|
241
|
+
}
|
|
242
|
+
export function createTestLedgerAccessAuthorizer() {
|
|
243
|
+
const granted = new WeakMap();
|
|
244
|
+
return {
|
|
245
|
+
issue(input) {
|
|
246
|
+
const access = { tenantId: identifier(input.tenantId, "Ledger tenant id"), principalId: identifier(input.principalId, "Ledger principal id"), permissions: normalizePermissions(input.permissions) };
|
|
247
|
+
const capability = Object.freeze({ tenantId: access.tenantId });
|
|
248
|
+
granted.set(capability, access);
|
|
249
|
+
return capability;
|
|
250
|
+
},
|
|
251
|
+
authorizer(access, permission, tenantId) {
|
|
252
|
+
if (access === null || typeof access !== "object")
|
|
253
|
+
throw new ActionLedgerError("Ledger access is not a verified capability.");
|
|
254
|
+
const grantedAccess = granted.get(access);
|
|
255
|
+
if (grantedAccess === undefined || grantedAccess.tenantId !== tenantId || !grantedAccess.permissions.includes(permission))
|
|
256
|
+
throw new ActionLedgerError("Ledger access is unauthorized.");
|
|
257
|
+
return grantedAccess;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export function validateIncidentFixture(value) {
|
|
262
|
+
const fixture = object(value, "Incident fixture must be an object.");
|
|
263
|
+
exactKeys(fixture, ["schemaVersion", "kind", "tenantId", "actionId", "actionHash", "sourceLedgerHeadHash", "syntheticWorld", "bundle", "request", "expected"], "Incident fixture");
|
|
264
|
+
if (fixture.schemaVersion !== 1 || fixture.kind !== "ghostapi.incident-fixture")
|
|
265
|
+
throw new ActionLedgerError("Unsupported incident fixture schema.");
|
|
266
|
+
const world = object(fixture.syntheticWorld, "Incident fixture world is invalid.");
|
|
267
|
+
exactKeys(world, ["id", "version", "seed"], "Incident fixture world");
|
|
268
|
+
if (world.version !== "1.0.0")
|
|
269
|
+
throw new ActionLedgerError("Incident fixture world version is invalid.");
|
|
270
|
+
const expected = object(fixture.expected, "Incident fixture expected outcome is invalid.");
|
|
271
|
+
exactKeys(expected, ["status", "outcome"], "Incident fixture expected outcome");
|
|
272
|
+
if ((expected.status !== 200 && expected.status !== 409) || (expected.outcome !== "verified" && expected.outcome !== "requires_reconciliation"))
|
|
273
|
+
throw new ActionLedgerError("Incident fixture expected outcome is invalid.");
|
|
274
|
+
const request = object(fixture.request, "Incident fixture request is invalid.");
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: 1,
|
|
277
|
+
kind: "ghostapi.incident-fixture",
|
|
278
|
+
tenantId: identifier(fixture.tenantId, "Incident fixture tenant id"),
|
|
279
|
+
actionId: identifier(fixture.actionId, "Incident fixture action id"),
|
|
280
|
+
actionHash: hash(fixture.actionHash, "Incident fixture action hash"),
|
|
281
|
+
sourceLedgerHeadHash: hash(fixture.sourceLedgerHeadHash, "Incident fixture ledger head hash"),
|
|
282
|
+
syntheticWorld: { id: identifier(world.id, "Incident fixture world id"), version: "1.0.0", seed: safeString(world.seed, "Incident fixture world seed", 64) },
|
|
283
|
+
bundle: prepareBundle(fixture.bundle),
|
|
284
|
+
request: { method: safeString(request.method, "Incident fixture request method", 10), path: safeString(request.path, "Incident fixture request path", 8_000), headers: normalizeFixtureHeaders(request.headers), body: sanitizeStructured(request.body, "Incident fixture request body") },
|
|
285
|
+
expected: { status: expected.status, outcome: expected.outcome }
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function prepareBundle(value) {
|
|
289
|
+
// The scenario module performs strict data-only validation; no replay transport is created here.
|
|
290
|
+
const bundle = validateScenarioBundle(value);
|
|
291
|
+
if (bundle.interactions.length !== 1 || bundle.metadata.sandboxHosts.length !== 1 || bundle.metadata.sandboxHosts[0] !== "incident.sandbox.localhost")
|
|
292
|
+
throw new ActionLedgerError("Incident fixture must contain one local-only replay interaction.");
|
|
293
|
+
return bundle;
|
|
294
|
+
}
|
|
295
|
+
function appendReceiptEvidence(append, receipt) {
|
|
296
|
+
const status = safeString(receipt.status, "Action receipt status", 32);
|
|
297
|
+
if (status === "attempted")
|
|
298
|
+
append("execution_attempt", { attempt: 1, status: "attempted", receiptHash: hash(receipt.receiptHash, "Action receipt hash") });
|
|
299
|
+
if (status === "committed")
|
|
300
|
+
append("provider_receipt", { outcome: "committed", receiptHash: hash(receipt.receiptHash, "Action receipt hash"), providerReceiptHash: receipt.providerRequestId === undefined ? null : sha256(receipt.providerRequestId) });
|
|
301
|
+
if (status === "verified")
|
|
302
|
+
append("verification", { status: "verified", receiptHash: hash(receipt.receiptHash, "Action receipt hash"), proofHash: sha256(canonicalJson(receipt.result ?? null)) });
|
|
303
|
+
if (status === "failed") {
|
|
304
|
+
const ambiguous = receipt.failure?.code === "unknown_outcome";
|
|
305
|
+
append("execution_attempt", { attempt: 1, status: ambiguous ? "ambiguous" : "failed", receiptHash: hash(receipt.receiptHash, "Action receipt hash") });
|
|
306
|
+
append("verification", { status: ambiguous ? "ambiguous" : "failed", receiptHash: hash(receipt.receiptHash, "Action receipt hash"), proofHash: null });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function normalizeActionRecord(value) {
|
|
310
|
+
const stored = validateStoredAction({ schemaVersion: 1, kind: "ghostapi.action-record", envelope: value.envelope, actionHash: actionHash(value.envelope), approval: value.approval, receipts: value.receipts });
|
|
311
|
+
return { envelope: stored.envelope, approval: stored.approval, receipts: stored.receipts, ...(value.credentialGrant === undefined ? {} : { credentialGrant: value.credentialGrant }) };
|
|
312
|
+
}
|
|
313
|
+
function emptyState() {
|
|
314
|
+
return { schemaVersion: 1, kind: "ghostapi.action-ledger", entries: [], tenants: [] };
|
|
315
|
+
}
|
|
316
|
+
function validateState(value) {
|
|
317
|
+
const state = object(value, "Action ledger state must be an object.");
|
|
318
|
+
exactKeys(state, ["schemaVersion", "kind", "entries", "tenants"], "Action ledger state");
|
|
319
|
+
if (state.schemaVersion !== LEDGER_SCHEMA_VERSION || state.kind !== LEDGER_KIND)
|
|
320
|
+
throw new ActionLedgerError("Unsupported action ledger schema.");
|
|
321
|
+
if (!Array.isArray(state.entries) || state.entries.length > MAX_ENTRIES || !Array.isArray(state.tenants) || state.tenants.length > MAX_TENANTS)
|
|
322
|
+
throw new ActionLedgerError("Action ledger state exceeds its bounded limits.");
|
|
323
|
+
const entries = state.entries.map(validateEntry);
|
|
324
|
+
const tenants = state.tenants.map(validateTenant);
|
|
325
|
+
if (new Set(tenants.map((tenant) => tenant.tenantId)).size !== tenants.length)
|
|
326
|
+
throw new ActionLedgerError("Action ledger tenant state is duplicated.");
|
|
327
|
+
for (const tenant of tenants)
|
|
328
|
+
assertTenantIntegrity({ schemaVersion: 1, kind: "ghostapi.action-ledger", entries, tenants }, tenant.tenantId);
|
|
329
|
+
if (new Set(entries.map((entry) => entry.tenantId)).size !== tenants.length && entries.some((entry) => !tenants.some((tenant) => tenant.tenantId === entry.tenantId)))
|
|
330
|
+
throw new ActionLedgerError("Action ledger entry has no tenant state.");
|
|
331
|
+
return { schemaVersion: 1, kind: "ghostapi.action-ledger", entries, tenants };
|
|
332
|
+
}
|
|
333
|
+
function validateEntry(value) {
|
|
334
|
+
const entry = object(value, "Ledger entry must be an object.");
|
|
335
|
+
exactKeys(entry, ["schemaVersion", "kind", "tenantId", "sequence", "timestamp", "actionId", "actionHash", "stage", "data", "previousHash", "entryHash"], "Ledger entry");
|
|
336
|
+
if (entry.schemaVersion !== 1 || entry.kind !== "ghostapi.action-ledger-entry" || !isStage(entry.stage))
|
|
337
|
+
throw new ActionLedgerError("Ledger entry schema is invalid.");
|
|
338
|
+
const normalized = {
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
kind: "ghostapi.action-ledger-entry",
|
|
341
|
+
tenantId: identifier(entry.tenantId, "Ledger tenant id"),
|
|
342
|
+
sequence: positiveInteger(entry.sequence, "Ledger sequence"),
|
|
343
|
+
timestamp: timestamp(entry.timestamp, "Ledger timestamp"),
|
|
344
|
+
actionId: identifier(entry.actionId, "Ledger action id"),
|
|
345
|
+
actionHash: hash(entry.actionHash, "Ledger action hash"),
|
|
346
|
+
stage: entry.stage,
|
|
347
|
+
data: normalizeData(entry.data),
|
|
348
|
+
previousHash: hash(entry.previousHash, "Ledger previous hash")
|
|
349
|
+
};
|
|
350
|
+
const entryHash = hash(entry.entryHash, "Ledger entry hash");
|
|
351
|
+
if (entryHash !== sha256(canonicalJson(normalized)))
|
|
352
|
+
throw new ActionLedgerError("Ledger entry hash is invalid.");
|
|
353
|
+
return { ...normalized, entryHash };
|
|
354
|
+
}
|
|
355
|
+
function validateTenant(value) {
|
|
356
|
+
const tenant = object(value, "Ledger tenant state is invalid.");
|
|
357
|
+
exactKeys(tenant, ["tenantId", "entryCount", "headHash", "retentionDays", "legalHold", "deletionRequestedAt"], "Ledger tenant state");
|
|
358
|
+
const deletionRequestedAt = tenant.deletionRequestedAt === undefined ? undefined : timestamp(tenant.deletionRequestedAt, "Ledger deletion request timestamp");
|
|
359
|
+
if (tenant.retentionDays !== null && (!Number.isInteger(tenant.retentionDays) || typeof tenant.retentionDays !== "number" || tenant.retentionDays < 1 || tenant.retentionDays > 3_650) || typeof tenant.legalHold !== "boolean")
|
|
360
|
+
throw new ActionLedgerError("Ledger tenant retention state is invalid.");
|
|
361
|
+
return { tenantId: identifier(tenant.tenantId, "Ledger tenant id"), entryCount: nonNegativeInteger(tenant.entryCount, "Ledger tenant entry count"), headHash: hash(tenant.headHash, "Ledger tenant head hash"), retentionDays: tenant.retentionDays, legalHold: tenant.legalHold, ...(deletionRequestedAt === undefined ? {} : { deletionRequestedAt }) };
|
|
362
|
+
}
|
|
363
|
+
function appendEntry(state, tenantId, actionId, actionDigest, stage, data, timestampValue) {
|
|
364
|
+
const tenant = getOrCreateTenant(state, tenantId);
|
|
365
|
+
if (state.entries.length >= MAX_ENTRIES) {
|
|
366
|
+
rotateForRetention(state, timestampValue);
|
|
367
|
+
if (state.entries.length >= MAX_ENTRIES)
|
|
368
|
+
throw new ActionLedgerError(`Action ledger entry limit of ${MAX_ENTRIES} was reached; export and controlled retention review are required.`);
|
|
369
|
+
}
|
|
370
|
+
const entry = {
|
|
371
|
+
schemaVersion: 1,
|
|
372
|
+
kind: "ghostapi.action-ledger-entry",
|
|
373
|
+
tenantId,
|
|
374
|
+
sequence: tenant.entryCount + 1,
|
|
375
|
+
timestamp: timestamp(timestampValue, "Ledger timestamp"),
|
|
376
|
+
actionId: identifier(actionId, "Ledger action id"),
|
|
377
|
+
actionHash: hash(actionDigest, "Ledger action hash"),
|
|
378
|
+
stage,
|
|
379
|
+
data: normalizeData(data),
|
|
380
|
+
previousHash: tenant.entryCount === 0 ? genesisHash(tenantId) : tenant.headHash
|
|
381
|
+
};
|
|
382
|
+
const appended = { ...entry, entryHash: sha256(canonicalJson(entry)) };
|
|
383
|
+
state.entries.push(appended);
|
|
384
|
+
tenant.entryCount = appended.sequence;
|
|
385
|
+
tenant.headHash = appended.entryHash;
|
|
386
|
+
return appended;
|
|
387
|
+
}
|
|
388
|
+
function rotateForRetention(state, now) {
|
|
389
|
+
for (const tenant of state.tenants) {
|
|
390
|
+
if (tenant.retentionDays === null || tenant.legalHold)
|
|
391
|
+
continue;
|
|
392
|
+
const cutoff = Date.parse(now) - tenant.retentionDays * 86_400_000;
|
|
393
|
+
const surviving = state.entries
|
|
394
|
+
.filter((entry) => entry.tenantId === tenant.tenantId)
|
|
395
|
+
.sort((left, right) => left.sequence - right.sequence)
|
|
396
|
+
.filter((entry) => Date.parse(entry.timestamp) >= cutoff);
|
|
397
|
+
if (surviving.length === state.entries.filter((entry) => entry.tenantId === tenant.tenantId).length)
|
|
398
|
+
continue;
|
|
399
|
+
const rebuilt = relinkTenantChain(tenant.tenantId, surviving);
|
|
400
|
+
state.entries = state.entries.filter((entry) => entry.tenantId !== tenant.tenantId).concat(rebuilt);
|
|
401
|
+
tenant.entryCount = rebuilt.length;
|
|
402
|
+
tenant.headHash = rebuilt.at(-1)?.entryHash ?? genesisHash(tenant.tenantId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function relinkTenantChain(tenantId, surviving) {
|
|
406
|
+
let previousHash = genesisHash(tenantId);
|
|
407
|
+
return surviving.map((entry, index) => {
|
|
408
|
+
const base = { schemaVersion: 1, kind: "ghostapi.action-ledger-entry", tenantId, sequence: index + 1, timestamp: entry.timestamp, actionId: entry.actionId, actionHash: entry.actionHash, stage: entry.stage, data: entry.data, previousHash };
|
|
409
|
+
const entryHash = sha256(canonicalJson(base));
|
|
410
|
+
previousHash = entryHash;
|
|
411
|
+
return { ...base, entryHash };
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
function getOrCreateTenant(state, tenantId) {
|
|
415
|
+
const existing = state.tenants.find((tenant) => tenant.tenantId === tenantId);
|
|
416
|
+
if (existing !== undefined)
|
|
417
|
+
return existing;
|
|
418
|
+
if (state.tenants.length >= MAX_TENANTS)
|
|
419
|
+
throw new ActionLedgerError(`Action ledger tenant limit of ${MAX_TENANTS} was reached.`);
|
|
420
|
+
const tenant = { tenantId, entryCount: 0, headHash: genesisHash(tenantId), retentionDays: null, legalHold: false };
|
|
421
|
+
state.tenants.push(tenant);
|
|
422
|
+
return tenant;
|
|
423
|
+
}
|
|
424
|
+
function assertTenantIntegrity(state, tenantId) {
|
|
425
|
+
const result = verifyTenantState(state, tenantId);
|
|
426
|
+
if (!result.valid)
|
|
427
|
+
throw new ActionLedgerError(result.error);
|
|
428
|
+
}
|
|
429
|
+
function verifyTenantState(state, tenantId) {
|
|
430
|
+
const tenant = state.tenants.find((candidate) => candidate.tenantId === tenantId);
|
|
431
|
+
if (tenant === undefined)
|
|
432
|
+
return { valid: true, entryCount: 0, headHash: genesisHash(tenantId), tracked: false };
|
|
433
|
+
const entries = state.entries.filter((entry) => entry.tenantId === tenantId);
|
|
434
|
+
if (entries.length !== tenant.entryCount)
|
|
435
|
+
return { valid: false, error: "Ledger tenant entry count does not match the append-only record." };
|
|
436
|
+
let previousHash = genesisHash(tenantId);
|
|
437
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
438
|
+
const entry = entries[index];
|
|
439
|
+
if (entry.sequence !== index + 1 || entry.previousHash !== previousHash)
|
|
440
|
+
return { valid: false, error: "Ledger entry ordering or chain link is corrupted." };
|
|
441
|
+
const expectedHash = sha256(canonicalJson({ schemaVersion: entry.schemaVersion, kind: entry.kind, tenantId: entry.tenantId, sequence: entry.sequence, timestamp: entry.timestamp, actionId: entry.actionId, actionHash: entry.actionHash, stage: entry.stage, data: entry.data, previousHash: entry.previousHash }));
|
|
442
|
+
if (entry.entryHash !== expectedHash)
|
|
443
|
+
return { valid: false, error: "Ledger entry content hash is corrupted." };
|
|
444
|
+
previousHash = entry.entryHash;
|
|
445
|
+
}
|
|
446
|
+
if (tenant.headHash !== previousHash)
|
|
447
|
+
return { valid: false, error: "Ledger tenant head hash does not match the append-only record." };
|
|
448
|
+
return { valid: true, entryCount: entries.length, headHash: tenant.headHash, tracked: true };
|
|
449
|
+
}
|
|
450
|
+
function entriesForAction(state, tenantId, actionId) {
|
|
451
|
+
return state.entries.filter((entry) => entry.tenantId === tenantId && entry.actionId === actionId).map((entry) => structuredClone(entry));
|
|
452
|
+
}
|
|
453
|
+
function normalizeData(value) {
|
|
454
|
+
const data = object(value, "Ledger structured data must be an object.");
|
|
455
|
+
const entries = Object.entries(data);
|
|
456
|
+
if (entries.length > 16)
|
|
457
|
+
throw new ActionLedgerError("Ledger structured data has too many fields.");
|
|
458
|
+
const normalized = {};
|
|
459
|
+
for (const [key, entry] of entries) {
|
|
460
|
+
if (!/^[a-z][a-zA-Z0-9]{0,63}$/.test(key) || /(?:authorization|cookie|token|secret|password|email|phone|address|card|payload|body)/i.test(key))
|
|
461
|
+
throw new ActionLedgerError(`Ledger structured field is forbidden: ${key}`);
|
|
462
|
+
if (typeof entry === "string")
|
|
463
|
+
normalized[key] = safeString(entry, `Ledger structured field ${key}`, 512);
|
|
464
|
+
else if (typeof entry === "number") {
|
|
465
|
+
if (!Number.isFinite(entry))
|
|
466
|
+
throw new ActionLedgerError(`Ledger structured field ${key} must be finite.`);
|
|
467
|
+
normalized[key] = entry;
|
|
468
|
+
}
|
|
469
|
+
else if (typeof entry === "boolean" || entry === null)
|
|
470
|
+
normalized[key] = entry;
|
|
471
|
+
else
|
|
472
|
+
throw new ActionLedgerError(`Ledger structured field ${key} must be scalar.`);
|
|
473
|
+
}
|
|
474
|
+
return normalized;
|
|
475
|
+
}
|
|
476
|
+
function normalizeFixtureHeaders(value) {
|
|
477
|
+
const headers = object(value ?? {}, "Incident fixture request headers are invalid.");
|
|
478
|
+
const normalized = {};
|
|
479
|
+
for (const [key, entry] of Object.entries(headers)) {
|
|
480
|
+
if (key.toLowerCase() !== "content-type" || typeof entry !== "string")
|
|
481
|
+
throw new ActionLedgerError("Incident fixture contains an unsafe request header.");
|
|
482
|
+
normalized["content-type"] = safeString(entry, "Incident fixture content type", 128);
|
|
483
|
+
}
|
|
484
|
+
return normalized;
|
|
485
|
+
}
|
|
486
|
+
function sanitizeStructured(value, label) {
|
|
487
|
+
const serialized = canonicalJson(value);
|
|
488
|
+
if (Buffer.byteLength(serialized, "utf8") > 64 * 1024)
|
|
489
|
+
throw new ActionLedgerError(`${label} exceeds 65536 bytes.`);
|
|
490
|
+
const sanitized = sanitizeSecretString(serialized);
|
|
491
|
+
if (sanitized !== serialized || /(?:@|\b\d{3}[ .-]\d{3}[ .-]\d{4}\b)/.test(serialized))
|
|
492
|
+
throw new ActionLedgerError(`${label} contains a secret-shaped value or prohibited PII.`);
|
|
493
|
+
return structuredClone(value);
|
|
494
|
+
}
|
|
495
|
+
function accessTenantId(value) {
|
|
496
|
+
if (value === null || typeof value !== "object")
|
|
497
|
+
throw new ActionLedgerError("Ledger access is not a verified capability.");
|
|
498
|
+
const tenantId = value.tenantId;
|
|
499
|
+
// Test capabilities deliberately expose no mutable fields, so authorizers receive an opaque scope hint instead.
|
|
500
|
+
return typeof tenantId === "string" ? identifier(tenantId, "Ledger tenant id") : "opaque";
|
|
501
|
+
}
|
|
502
|
+
function denyLedgerAccess() {
|
|
503
|
+
throw new ActionLedgerError("Ledger access is disabled until a verified tenant access authorizer is configured.");
|
|
504
|
+
}
|
|
505
|
+
function governanceActionId(kind, state, tenantId) {
|
|
506
|
+
return `governance-${kind}-${tenantId}-${state.entries.filter((entry) => entry.tenantId === tenantId).length + 1}`.slice(0, 128);
|
|
507
|
+
}
|
|
508
|
+
function deterministicUuid(digest) {
|
|
509
|
+
const hex = digest.slice(0, 12) + "4" + digest.slice(13, 16) + "8" + digest.slice(17, 32);
|
|
510
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
511
|
+
}
|
|
512
|
+
function readOutcome(value) {
|
|
513
|
+
const body = object(value, "Incident replay response body is invalid.");
|
|
514
|
+
return body.outcome === "verified" || body.outcome === "requires_reconciliation" ? body.outcome : (() => { throw new ActionLedgerError("Incident replay response outcome is invalid."); })();
|
|
515
|
+
}
|
|
516
|
+
function isStage(value) {
|
|
517
|
+
return value === "intent" || value === "identity" || value === "policy_decision" || value === "approval" || value === "credential_grant" || value === "execution_attempt" || value === "provider_receipt" || value === "verification" || value === "compensation" || value === "retention" || value === "legal_hold" || value === "deletion_request";
|
|
518
|
+
}
|
|
519
|
+
function normalizePermissions(value) {
|
|
520
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((permission) => !["append", "read", "export", "manage_retention", "manage_hold", "request_deletion"].includes(permission)) || new Set(value).size !== value.length)
|
|
521
|
+
throw new ActionLedgerError("Ledger permissions are invalid.");
|
|
522
|
+
return [...value];
|
|
523
|
+
}
|
|
524
|
+
function object(value, message) {
|
|
525
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
526
|
+
throw new ActionLedgerError(message);
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
function exactKeys(value, expected, label) {
|
|
530
|
+
for (const key of Object.keys(value))
|
|
531
|
+
if (!expected.includes(key))
|
|
532
|
+
throw new ActionLedgerError(`${label} contains unsupported field: ${key}`);
|
|
533
|
+
}
|
|
534
|
+
function identifier(value, label) {
|
|
535
|
+
if (typeof value !== "string" || !IDENTIFIER.test(value) || sanitizeSecretString(value) !== value)
|
|
536
|
+
throw new ActionLedgerError(`${label} must be a safe stable identifier.`);
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
function hash(value, label) {
|
|
540
|
+
if (typeof value !== "string" || !HASH.test(value))
|
|
541
|
+
throw new ActionLedgerError(`${label} must be a SHA-256 hex digest.`);
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
function positiveInteger(value, label) {
|
|
545
|
+
if (!Number.isInteger(value) || typeof value !== "number" || value < 1)
|
|
546
|
+
throw new ActionLedgerError(`${label} must be a positive integer.`);
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
function nonNegativeInteger(value, label) {
|
|
550
|
+
if (!Number.isInteger(value) || typeof value !== "number" || value < 0)
|
|
551
|
+
throw new ActionLedgerError(`${label} must be a non-negative integer.`);
|
|
552
|
+
return value;
|
|
553
|
+
}
|
|
554
|
+
function boundedInteger(value, label, minimum, maximum) {
|
|
555
|
+
if (!Number.isInteger(value) || typeof value !== "number" || value < minimum || value > maximum)
|
|
556
|
+
throw new ActionLedgerError(`${label} must be an integer from ${minimum} through ${maximum}.`);
|
|
557
|
+
return value;
|
|
558
|
+
}
|
|
559
|
+
function timestamp(value, label) {
|
|
560
|
+
if (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)))
|
|
561
|
+
throw new ActionLedgerError(`${label} must be an ISO UTC timestamp.`);
|
|
562
|
+
return value;
|
|
563
|
+
}
|
|
564
|
+
function safeString(value, label, maximum) {
|
|
565
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maximum || sanitizeSecretString(value) !== value)
|
|
566
|
+
throw new ActionLedgerError(`${label} must be a bounded non-secret string.`);
|
|
567
|
+
return value;
|
|
568
|
+
}
|
|
569
|
+
function genesisHash(tenantId) {
|
|
570
|
+
return sha256(`ghostapi.action-ledger.v1:${tenantId}`);
|
|
571
|
+
}
|
|
572
|
+
function sha256(value) {
|
|
573
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
574
|
+
}
|
|
575
|
+
function canonicalJson(value) {
|
|
576
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
577
|
+
return JSON.stringify(value);
|
|
578
|
+
if (typeof value === "number") {
|
|
579
|
+
if (!Number.isFinite(value))
|
|
580
|
+
throw new ActionLedgerError("Ledger values must be finite JSON data.");
|
|
581
|
+
return JSON.stringify(value);
|
|
582
|
+
}
|
|
583
|
+
if (Array.isArray(value))
|
|
584
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
585
|
+
if (typeof value === "object") {
|
|
586
|
+
const record = value;
|
|
587
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
588
|
+
}
|
|
589
|
+
throw new ActionLedgerError("Ledger values must be JSON data.");
|
|
590
|
+
}
|
|
591
|
+
function isErrorCode(error, code) {
|
|
592
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
593
|
+
}
|
|
594
|
+
//# sourceMappingURL=actionLedger.js.map
|