@decionis/agent-safe-pipeline 0.1.3 → 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/README.md +477 -16
- package/dist/Index.d.ts +9 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +8 -0
- package/dist/Index.js.map +1 -1
- package/dist/approval/PresenceApprovalCoordinator.d.ts +25 -0
- package/dist/approval/PresenceApprovalCoordinator.d.ts.map +1 -1
- package/dist/approval/PresenceApprovalCoordinator.js +54 -6
- package/dist/approval/PresenceApprovalCoordinator.js.map +1 -1
- package/dist/audit/AuditRecorder.d.ts +122 -0
- package/dist/audit/AuditRecorder.d.ts.map +1 -0
- package/dist/audit/AuditRecorder.js +312 -0
- package/dist/audit/AuditRecorder.js.map +1 -0
- package/dist/decision/CreateGate.d.ts +123 -0
- package/dist/decision/CreateGate.d.ts.map +1 -0
- package/dist/decision/CreateGate.js +211 -0
- package/dist/decision/CreateGate.js.map +1 -0
- package/dist/decision/DecionisGate.d.ts +109 -3
- package/dist/decision/DecionisGate.d.ts.map +1 -1
- package/dist/decision/DecionisGate.js +823 -46
- package/dist/decision/DecionisGate.js.map +1 -1
- package/dist/decision/DecisionAuthority.d.ts +76 -1
- package/dist/decision/DecisionAuthority.d.ts.map +1 -1
- package/dist/decision/DecisionAuthority.js.map +1 -1
- package/dist/decision/FixtureDecisionAuthority.d.ts +27 -1
- package/dist/decision/FixtureDecisionAuthority.d.ts.map +1 -1
- package/dist/decision/FixtureDecisionAuthority.js +47 -0
- package/dist/decision/FixtureDecisionAuthority.js.map +1 -1
- package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
- package/dist/decision/ImmutableGateDecision.js +26 -0
- package/dist/decision/ImmutableGateDecision.js.map +1 -1
- package/dist/decision/Provision.d.ts +33 -0
- package/dist/decision/Provision.d.ts.map +1 -0
- package/dist/decision/Provision.js +116 -0
- package/dist/decision/Provision.js.map +1 -0
- package/dist/decision/ShadowGate.d.ts +31 -0
- package/dist/decision/ShadowGate.d.ts.map +1 -0
- package/dist/decision/ShadowGate.js +79 -0
- package/dist/decision/ShadowGate.js.map +1 -0
- package/dist/execution/ActionRegistry.d.ts +66 -0
- package/dist/execution/ActionRegistry.d.ts.map +1 -1
- package/dist/execution/ActionRegistry.js +110 -1
- package/dist/execution/ActionRegistry.js.map +1 -1
- package/dist/execution/AuthorizationVerifier.d.ts +149 -1
- package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
- package/dist/execution/AuthorizationVerifier.js +302 -27
- package/dist/execution/AuthorizationVerifier.js.map +1 -1
- package/dist/execution/SafeExecutor.d.ts +102 -7
- package/dist/execution/SafeExecutor.d.ts.map +1 -1
- package/dist/execution/SafeExecutor.js +288 -17
- package/dist/execution/SafeExecutor.js.map +1 -1
- package/dist/http/ClientIdentification.d.ts +22 -0
- package/dist/http/ClientIdentification.d.ts.map +1 -0
- package/dist/http/ClientIdentification.js +44 -0
- package/dist/http/ClientIdentification.js.map +1 -0
- package/dist/http/Credential.d.ts +17 -0
- package/dist/http/Credential.d.ts.map +1 -0
- package/dist/http/Credential.js +5 -0
- package/dist/http/Credential.js.map +1 -0
- package/dist/http/StoredCredentials.d.ts +34 -0
- package/dist/http/StoredCredentials.d.ts.map +1 -0
- package/dist/http/StoredCredentials.js +85 -0
- package/dist/http/StoredCredentials.js.map +1 -0
- package/dist/intent/CanonicalIntentHasher.d.ts +1 -1
- package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
- package/dist/intent/CanonicalIntentHasher.js +9 -2
- package/dist/intent/CanonicalIntentHasher.js.map +1 -1
- package/dist/intent/ExecutionIntent.d.ts +26 -2
- package/dist/intent/ExecutionIntent.d.ts.map +1 -1
- package/dist/intent/ExecutionIntent.js +11 -0
- package/dist/intent/ExecutionIntent.js.map +1 -1
- package/dist/intent/IntentCapture.d.ts +5 -0
- package/dist/intent/IntentCapture.d.ts.map +1 -1
- package/dist/intent/IntentCapture.js +15 -1
- package/dist/intent/IntentCapture.js.map +1 -1
- package/dist/report/DecisionReport.d.ts +22 -0
- package/dist/report/DecisionReport.d.ts.map +1 -0
- package/dist/report/DecisionReport.js +37 -0
- package/dist/report/DecisionReport.js.map +1 -0
- package/dist/report/DossierReport.d.ts +43 -0
- package/dist/report/DossierReport.d.ts.map +1 -0
- package/dist/report/DossierReport.js +130 -0
- package/dist/report/DossierReport.js.map +1 -0
- package/dist/report/HostedOutcome.d.ts +18 -0
- package/dist/report/HostedOutcome.d.ts.map +1 -0
- package/dist/report/HostedOutcome.js +24 -0
- package/dist/report/HostedOutcome.js.map +1 -0
- package/dist/shadow/ShadowPipeline.d.ts +75 -5
- package/dist/shadow/ShadowPipeline.d.ts.map +1 -1
- package/dist/shadow/ShadowPipeline.js +157 -7
- package/dist/shadow/ShadowPipeline.js.map +1 -1
- package/dist/testing/Index.d.ts +12 -0
- package/dist/testing/Index.d.ts.map +1 -0
- package/dist/testing/Index.js +12 -0
- package/dist/testing/Index.js.map +1 -0
- package/dist/testing/LocalAuthority.d.ts +292 -0
- package/dist/testing/LocalAuthority.d.ts.map +1 -0
- package/dist/testing/LocalAuthority.js +1124 -0
- package/dist/testing/LocalAuthority.js.map +1 -0
- package/dist/testing/LocalPresence.d.ts +210 -0
- package/dist/testing/LocalPresence.d.ts.map +1 -0
- package/dist/testing/LocalPresence.js +473 -0
- package/dist/testing/LocalPresence.js.map +1 -0
- package/package.json +40 -4
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback stand-in for the Decionis execution-authority routes that
|
|
3
|
+
* `@decionis/agent-safe-pipeline` consumes: enforce-and-bind (including
|
|
4
|
+
* shadow mode and managed escalation), escalation status and cancellation,
|
|
5
|
+
* claim-token with its consume-token alias, and finalize-token.
|
|
6
|
+
*
|
|
7
|
+
* It validates every request against the published OpenAPI contract with its
|
|
8
|
+
* own canonicalizer, so a hash it recomputes is independent evidence that the
|
|
9
|
+
* package binds intents the way Decionis does. Direct Presence evidence is
|
|
10
|
+
* verified against a `LocalPresence`, and a managed escalation is orchestrated
|
|
11
|
+
* through that same `LocalPresence` the way Decionis orchestrates Presence:
|
|
12
|
+
* create a request bound to the exact intent hash, wait for the ceremony,
|
|
13
|
+
* verify the receipt, re-evaluate policy, then expose a normal grant.
|
|
14
|
+
*
|
|
15
|
+
* Grants, claims, escalations, and commit outcomes are synthetic in-memory
|
|
16
|
+
* state. Nothing here is a real policy, tenant, or credential.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign, } from "node:crypto";
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { readBody } from "./LocalPresence.js";
|
|
22
|
+
export const LOCAL_AUTHORITY_API_KEY = "test-key";
|
|
23
|
+
export const AUTONOMOUS_LIMIT_MINOR = 10_000;
|
|
24
|
+
export const HUMAN_LIMIT_MINOR = 100_000;
|
|
25
|
+
const LOOPBACK_ORIGIN = "http://127.0.0.1";
|
|
26
|
+
const MAX_REQUEST_BYTES = 256 * 1024;
|
|
27
|
+
const MAX_RECORDED_BODY_CHARS = 4_096;
|
|
28
|
+
const SOCKET_TIMEOUT_MS = 10_000;
|
|
29
|
+
const GRANT_TTL_MS = 60_000;
|
|
30
|
+
const CLAIM_LEASE_MS = 30_000;
|
|
31
|
+
/** The issuer the fixture's grants and attestations name; a provider double checks it. */
|
|
32
|
+
export const LOCAL_AUTHORITY_ISSUER = "synthetic-authority";
|
|
33
|
+
/** Where the fixture publishes its attestation key, at the path Decionis documents. */
|
|
34
|
+
export const LOCAL_AUTHORITY_JWKS_PATH = "/.well-known/decionis-execution-grant-jwks.json";
|
|
35
|
+
export const CLAIM_ATTESTATION_TYPE = "decionis-claim-attestation+jwt";
|
|
36
|
+
/** The profile the fixture digests parameters under; the only one the pipeline reproduces. */
|
|
37
|
+
const JCS_PROFILE = "RFC8785/JCS";
|
|
38
|
+
const boundedId = z.string().trim().min(1).max(200);
|
|
39
|
+
const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
40
|
+
const roleId = z.string().regex(/^[A-Z][A-Z0-9_]{1,63}$/);
|
|
41
|
+
/** `ExecutionIntentBinding` from the Decionis OpenAPI contract; extra properties are rejected. */
|
|
42
|
+
const IntentBindingSchema = z.strictObject({
|
|
43
|
+
protocol_version: z.literal("agent-safe.intent/1"),
|
|
44
|
+
tenant_id: z.uuid(),
|
|
45
|
+
intent_id: z.uuid(),
|
|
46
|
+
captured_at: z.iso.datetime(),
|
|
47
|
+
expires_at: z.iso.datetime(),
|
|
48
|
+
actor: z.strictObject({
|
|
49
|
+
id: boundedId,
|
|
50
|
+
type: boundedId,
|
|
51
|
+
runtime: boundedId.optional(),
|
|
52
|
+
trust_level: z.string().trim().min(1).max(80).optional(),
|
|
53
|
+
}),
|
|
54
|
+
action: z.strictObject({
|
|
55
|
+
type: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(120)
|
|
59
|
+
.regex(/^[a-z][a-z0-9._:-]*$/),
|
|
60
|
+
resource: z.string().min(1).max(500),
|
|
61
|
+
parameters: z.record(z.string(), z.unknown()),
|
|
62
|
+
}),
|
|
63
|
+
context: z.record(z.string(), z.unknown()),
|
|
64
|
+
downstream_target: z.strictObject({
|
|
65
|
+
system: boundedId,
|
|
66
|
+
environment: boundedId.optional(),
|
|
67
|
+
operation: boundedId,
|
|
68
|
+
endpoint: z.string().min(1).max(500).optional(),
|
|
69
|
+
}),
|
|
70
|
+
expected_effect_digest: sha256Digest.optional(),
|
|
71
|
+
});
|
|
72
|
+
const EvidenceSchema = z.strictObject({
|
|
73
|
+
humanApproval: z
|
|
74
|
+
.strictObject({
|
|
75
|
+
provider: z.literal("presence"),
|
|
76
|
+
requestId: boundedId,
|
|
77
|
+
receiptDossierId: boundedId,
|
|
78
|
+
})
|
|
79
|
+
.optional(),
|
|
80
|
+
});
|
|
81
|
+
/** `boundedIdentifier` from the Decionis `EffectEvidence` contract, verbatim. */
|
|
82
|
+
const boundedEffectIdentifier = z.string().trim().min(1).max(500);
|
|
83
|
+
/**
|
|
84
|
+
* Protocol 1.1 `EffectEvidence`, mirrored from the Decionis shared contract
|
|
85
|
+
* including its `CONFIRMED` refinements: a confirmed observation may not rest
|
|
86
|
+
* on a bare downstream acknowledgement, must name an observer with a version,
|
|
87
|
+
* must have observed exactly the expected effect at a stated time, and must
|
|
88
|
+
* carry a digest or a reference for the observation itself.
|
|
89
|
+
*/
|
|
90
|
+
const EffectEvidenceSchema = z
|
|
91
|
+
.strictObject({
|
|
92
|
+
version: z.literal("1.0"),
|
|
93
|
+
status: z.enum(["CONFIRMED", "UNCONFIRMED"]),
|
|
94
|
+
observation_method: z.enum([
|
|
95
|
+
"DOWNSTREAM_ACK",
|
|
96
|
+
"READ_AFTER_WRITE",
|
|
97
|
+
"EVENT_CONFIRMATION",
|
|
98
|
+
"STATE_RECONCILIATION",
|
|
99
|
+
"SIGNED_RECEIPT",
|
|
100
|
+
"EXTERNAL_ATTESTATION",
|
|
101
|
+
"HUMAN_VALIDATION",
|
|
102
|
+
]),
|
|
103
|
+
observer: z
|
|
104
|
+
.strictObject({
|
|
105
|
+
id: boundedEffectIdentifier,
|
|
106
|
+
version: boundedEffectIdentifier.nullable(),
|
|
107
|
+
})
|
|
108
|
+
.nullable(),
|
|
109
|
+
expected_effect_digest: sha256Digest,
|
|
110
|
+
observed_effect_digest: sha256Digest.nullable(),
|
|
111
|
+
observed_at: z.iso.datetime().nullable(),
|
|
112
|
+
evidence_digest: sha256Digest.nullable(),
|
|
113
|
+
evidence_reference: boundedEffectIdentifier.nullable(),
|
|
114
|
+
execution_correlation_id: boundedEffectIdentifier,
|
|
115
|
+
})
|
|
116
|
+
.superRefine((evidence, context) => {
|
|
117
|
+
if (evidence.status !== "CONFIRMED")
|
|
118
|
+
return;
|
|
119
|
+
const fail = (message) => {
|
|
120
|
+
context.addIssue({ code: "custom", message });
|
|
121
|
+
};
|
|
122
|
+
if (evidence.observation_method === "DOWNSTREAM_ACK")
|
|
123
|
+
fail("EFFECT_METHOD_INSUFFICIENT");
|
|
124
|
+
if (evidence.observer === null || evidence.observer.version === null) {
|
|
125
|
+
fail("EFFECT_OBSERVER_REQUIRED");
|
|
126
|
+
}
|
|
127
|
+
if (evidence.observed_effect_digest !== evidence.expected_effect_digest) {
|
|
128
|
+
fail("EFFECT_OBSERVED_DIGEST_MISMATCH");
|
|
129
|
+
}
|
|
130
|
+
if (evidence.observed_at === null)
|
|
131
|
+
fail("EFFECT_OBSERVED_AT_REQUIRED");
|
|
132
|
+
if (evidence.evidence_digest === null && evidence.evidence_reference === null) {
|
|
133
|
+
fail("EFFECT_EVIDENCE_ARTIFACT_REQUIRED");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
const ManagedEscalationRequestSchema = z.strictObject({
|
|
137
|
+
mode: z.literal("MANAGED"),
|
|
138
|
+
approver: z
|
|
139
|
+
.strictObject({
|
|
140
|
+
principal_id: boundedId.optional(),
|
|
141
|
+
role_id: roleId.optional(),
|
|
142
|
+
})
|
|
143
|
+
.optional(),
|
|
144
|
+
verification_requirements: z
|
|
145
|
+
.strictObject({
|
|
146
|
+
methods: z
|
|
147
|
+
.array(z.enum(["WEBAUTHN", "ACTIVE_LIVENESS"]))
|
|
148
|
+
.min(1)
|
|
149
|
+
.max(3),
|
|
150
|
+
level: z.enum(["STANDARD", "HIGH_CONFIDENCE"]).optional(),
|
|
151
|
+
})
|
|
152
|
+
.optional(),
|
|
153
|
+
});
|
|
154
|
+
/** `ExecutionAuthorityRequest`: the binding plus hash, mode, evidence, and managed constraints. */
|
|
155
|
+
const AuthorityRequestSchema = z.strictObject({
|
|
156
|
+
...IntentBindingSchema.shape,
|
|
157
|
+
intent_hash: sha256Digest,
|
|
158
|
+
mode: z.enum(["SHADOW", "ENFORCEMENT"]),
|
|
159
|
+
evidence: EvidenceSchema.optional(),
|
|
160
|
+
escalation: ManagedEscalationRequestSchema.optional(),
|
|
161
|
+
});
|
|
162
|
+
/** `ExecutionTokenRequest`. */
|
|
163
|
+
const TokenRequestSchema = z.strictObject({
|
|
164
|
+
execution_token: z.string().min(1).max(20_000),
|
|
165
|
+
intent_hash: sha256Digest,
|
|
166
|
+
intent: z.record(z.string(), z.unknown()),
|
|
167
|
+
evidence: EvidenceSchema.optional(),
|
|
168
|
+
consumed_by: boundedId.optional(),
|
|
169
|
+
commit_correlation_id: boundedId.optional(),
|
|
170
|
+
});
|
|
171
|
+
/** `ExecutionFinalizeRequest`. */
|
|
172
|
+
const FinalizeRequestSchema = z.strictObject({
|
|
173
|
+
execution_token: z.string().min(1).max(20_000),
|
|
174
|
+
claim_token: z.string().regex(/^[\w-]{43,128}$/),
|
|
175
|
+
outcome: z.enum(["COMMITTED", "FAILED", "INDETERMINATE"]),
|
|
176
|
+
commit_correlation_id: boundedId,
|
|
177
|
+
downstream_evidence: z.record(z.string(), z.unknown()).optional(),
|
|
178
|
+
// Deliberately unvalidated here: the hosted route answers malformed effect
|
|
179
|
+
// evidence with a 409 and a reason code, not with a 400 request rejection,
|
|
180
|
+
// so the shape is checked where that answer is produced.
|
|
181
|
+
effect_evidence: z.unknown().optional(),
|
|
182
|
+
});
|
|
183
|
+
const BINDING_KEYS = Object.keys(IntentBindingSchema.shape);
|
|
184
|
+
/** The organization a provisioned synthetic workspace belongs to. */
|
|
185
|
+
export const LOCAL_AUTHORITY_PROVISIONAL_ORG_ID = "00000000-0000-4000-8000-000000000008";
|
|
186
|
+
/**
|
|
187
|
+
* Independent canonicalization: keys sorted by UTF-16 code unit, values encoded
|
|
188
|
+
* with JSON.stringify semantics. This deliberately does not import the
|
|
189
|
+
* package's hasher so the two implementations check each other.
|
|
190
|
+
*/
|
|
191
|
+
export function stableStringify(value) {
|
|
192
|
+
if (Array.isArray(value))
|
|
193
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
194
|
+
if (value === null || typeof value !== "object")
|
|
195
|
+
return JSON.stringify(value);
|
|
196
|
+
const record = value;
|
|
197
|
+
const entries = Object.keys(record)
|
|
198
|
+
.sort()
|
|
199
|
+
.filter((key) => record[key] !== undefined)
|
|
200
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
201
|
+
return `{${entries.join(",")}}`;
|
|
202
|
+
}
|
|
203
|
+
export function hashBinding(binding) {
|
|
204
|
+
return `sha256:${createHash("sha256").update(stableStringify(binding), "utf8").digest("hex")}`;
|
|
205
|
+
}
|
|
206
|
+
function bindingOf(request) {
|
|
207
|
+
const binding = {};
|
|
208
|
+
for (const key of BINDING_KEYS)
|
|
209
|
+
binding[key] = request[key];
|
|
210
|
+
return binding;
|
|
211
|
+
}
|
|
212
|
+
function truncate(text) {
|
|
213
|
+
return text.length > MAX_RECORDED_BODY_CHARS
|
|
214
|
+
? `${text.slice(0, MAX_RECORDED_BODY_CHARS)}…[truncated ${text.length - MAX_RECORDED_BODY_CHARS} chars]`
|
|
215
|
+
: text;
|
|
216
|
+
}
|
|
217
|
+
function sleep(ms) {
|
|
218
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
|
+
}
|
|
220
|
+
function defaultPolicy(request) {
|
|
221
|
+
const amount = Number(request.action.parameters.amountMinor ?? 0);
|
|
222
|
+
if (amount > HUMAN_LIMIT_MINOR)
|
|
223
|
+
return "BLOCK";
|
|
224
|
+
if (amount > AUTONOMOUS_LIMIT_MINOR)
|
|
225
|
+
return "ESCALATE";
|
|
226
|
+
return "ALLOW";
|
|
227
|
+
}
|
|
228
|
+
export class LocalAuthority {
|
|
229
|
+
/** Every request seen, oldest first, with bounded bodies and the response given. */
|
|
230
|
+
requests = [];
|
|
231
|
+
/** Issued grants keyed by execution token. */
|
|
232
|
+
grants = new Map();
|
|
233
|
+
/** Managed escalations keyed by their opaque identifier. */
|
|
234
|
+
escalations = new Map();
|
|
235
|
+
apiKey;
|
|
236
|
+
presence;
|
|
237
|
+
legacyVerifyReceipt;
|
|
238
|
+
policy;
|
|
239
|
+
managedApproverId;
|
|
240
|
+
trustedEffectObserverIds;
|
|
241
|
+
clock;
|
|
242
|
+
attestationKey;
|
|
243
|
+
/** The `kid` the fixture's attestations name; a provider double looks it up in the JWKS. */
|
|
244
|
+
attestationKeyId = "synthetic-exec-grant-1";
|
|
245
|
+
/**
|
|
246
|
+
* A drill knob: when set, the next claim binds a digest over parameters
|
|
247
|
+
* other than the ones captured, which is what a tampered or mismatched
|
|
248
|
+
* authority looks like from the executor's side, and what BEAP-L3-BND-01
|
|
249
|
+
* says the executor must refuse.
|
|
250
|
+
*/
|
|
251
|
+
misbindNextPayloadDigest = false;
|
|
252
|
+
overrides = {
|
|
253
|
+
enforce: [],
|
|
254
|
+
status: [],
|
|
255
|
+
claim: [],
|
|
256
|
+
finalize: [],
|
|
257
|
+
};
|
|
258
|
+
managedByIntent = new Map();
|
|
259
|
+
nextManagedLifecycle = null;
|
|
260
|
+
decisions = 0;
|
|
261
|
+
/** Every decision minted, by dossier id, for `GET /v1/protocol/dossiers/{id}`. */
|
|
262
|
+
dossiers = new Map();
|
|
263
|
+
verificationLinks;
|
|
264
|
+
server = null;
|
|
265
|
+
port = 0;
|
|
266
|
+
constructor(options = {}) {
|
|
267
|
+
if (process.env.NODE_ENV === "production")
|
|
268
|
+
throw new Error("LOCAL_DOUBLE_FORBIDDEN");
|
|
269
|
+
this.apiKey = options.apiKey ?? LOCAL_AUTHORITY_API_KEY;
|
|
270
|
+
this.verificationLinks = options.verificationLinks === true;
|
|
271
|
+
this.presence = options.presence;
|
|
272
|
+
this.legacyVerifyReceipt = options.verifyReceipt;
|
|
273
|
+
this.policy = options.policy ?? defaultPolicy;
|
|
274
|
+
this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
|
|
275
|
+
this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
|
|
276
|
+
this.clock = options.clock ?? Date.now;
|
|
277
|
+
const keys = generateKeyPairSync("ed25519");
|
|
278
|
+
this.attestationKey = {
|
|
279
|
+
privateKey: keys.privateKey,
|
|
280
|
+
publicJwk: keys.publicKey.export({ format: "jwk" }),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
|
|
284
|
+
get jwks() {
|
|
285
|
+
return {
|
|
286
|
+
keys: [
|
|
287
|
+
{ ...this.attestationKey.publicJwk, kid: this.attestationKeyId, alg: "EdDSA", use: "sig" },
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A compact JWS over the claim, signed the way Decionis signs one: EdDSA,
|
|
293
|
+
* the grant key, the attestation type in the protected header. Built with
|
|
294
|
+
* `node:crypto` alone so the fixture stays an independent implementation.
|
|
295
|
+
*/
|
|
296
|
+
attest(grant, claimValidatedAt, leaseExpiresAt) {
|
|
297
|
+
const encode = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
298
|
+
const now = Math.floor(this.clock() / 1_000);
|
|
299
|
+
const claims = {
|
|
300
|
+
iss: LOCAL_AUTHORITY_ISSUER,
|
|
301
|
+
sub: grant.jti,
|
|
302
|
+
org_id: grant.tenantId,
|
|
303
|
+
dossier_id: grant.dossierId,
|
|
304
|
+
decision_id: grant.decisionId,
|
|
305
|
+
binding: {
|
|
306
|
+
intent_hash: grant.intentHash,
|
|
307
|
+
execution_payload_digest: grant.payloadDigest,
|
|
308
|
+
execution_payload_canonicalization_profile: JCS_PROFILE,
|
|
309
|
+
...(grant.expectedEffectDigest === null
|
|
310
|
+
? {}
|
|
311
|
+
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
312
|
+
execution_nonce: grant.nonce,
|
|
313
|
+
execution_correlation_id: grant.correlationId ?? "",
|
|
314
|
+
},
|
|
315
|
+
claim_token_digest: `sha256:${createHash("sha256")
|
|
316
|
+
.update(grant.claimToken ?? "", "utf8")
|
|
317
|
+
.digest("hex")}`,
|
|
318
|
+
claim_validated_at: claimValidatedAt,
|
|
319
|
+
jti: randomUUID(),
|
|
320
|
+
iat: now,
|
|
321
|
+
nbf: now,
|
|
322
|
+
exp: Math.floor(Date.parse(leaseExpiresAt) / 1_000),
|
|
323
|
+
};
|
|
324
|
+
const header = encode({
|
|
325
|
+
alg: "EdDSA",
|
|
326
|
+
kid: this.attestationKeyId,
|
|
327
|
+
typ: CLAIM_ATTESTATION_TYPE,
|
|
328
|
+
});
|
|
329
|
+
const payload = encode(claims);
|
|
330
|
+
const signature = sign(null, Buffer.from(`${header}.${payload}`, "ascii"), this.attestationKey.privateKey);
|
|
331
|
+
return `${header}.${payload}.${signature.toString("base64url")}`;
|
|
332
|
+
}
|
|
333
|
+
get baseUrl() {
|
|
334
|
+
return `${LOOPBACK_ORIGIN}:${this.port}`;
|
|
335
|
+
}
|
|
336
|
+
async start() {
|
|
337
|
+
const server = createServer((req, res) => {
|
|
338
|
+
void this.handle(req, res);
|
|
339
|
+
});
|
|
340
|
+
server.setTimeout(SOCKET_TIMEOUT_MS);
|
|
341
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
342
|
+
const address = server.address();
|
|
343
|
+
this.port = typeof address === "object" && address !== null ? address.port : 0;
|
|
344
|
+
this.server = server;
|
|
345
|
+
}
|
|
346
|
+
async stop() {
|
|
347
|
+
const server = this.server;
|
|
348
|
+
if (server === null)
|
|
349
|
+
return;
|
|
350
|
+
server.closeAllConnections();
|
|
351
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
352
|
+
this.server = null;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Alters the next response on one route only. `transform` receives the
|
|
356
|
+
* computed body; `body` replaces it (an object or a raw string); `status`,
|
|
357
|
+
* `delayMs`, `truncateTo`, and `destroy` shape the transport behavior.
|
|
358
|
+
*/
|
|
359
|
+
scriptOnce(route, override) {
|
|
360
|
+
this.overrides[route].push(override);
|
|
361
|
+
}
|
|
362
|
+
/** Statuses returned, in order, for the next managed escalation instead of orchestration. */
|
|
363
|
+
scriptNextManagedLifecycle(statuses) {
|
|
364
|
+
this.nextManagedLifecycle = [...statuses];
|
|
365
|
+
}
|
|
366
|
+
async handle(req, res) {
|
|
367
|
+
const url = new URL(req.url ?? "/", this.baseUrl);
|
|
368
|
+
const record = {
|
|
369
|
+
method: req.method ?? "GET",
|
|
370
|
+
path: url.pathname,
|
|
371
|
+
headers: {
|
|
372
|
+
authorization: headerString(req.headers.authorization),
|
|
373
|
+
"content-type": headerString(req.headers["content-type"]),
|
|
374
|
+
"idempotency-key": headerString(req.headers["idempotency-key"]),
|
|
375
|
+
},
|
|
376
|
+
body: null,
|
|
377
|
+
recomputedHash: null,
|
|
378
|
+
response: null,
|
|
379
|
+
at: new Date(this.clock()).toISOString(),
|
|
380
|
+
};
|
|
381
|
+
this.requests.push(record);
|
|
382
|
+
const raw = await readBody(req, MAX_REQUEST_BYTES);
|
|
383
|
+
if (raw === null)
|
|
384
|
+
return this.send(res, record, 413, { error: "REQUEST_TOO_LARGE" });
|
|
385
|
+
try {
|
|
386
|
+
record.body = raw.length === 0 ? null : JSON.parse(raw);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
record.body = truncate(raw);
|
|
390
|
+
return this.send(res, record, 400, { error: "REQUEST_MALFORMED" });
|
|
391
|
+
}
|
|
392
|
+
// The verification keys are public, as Decionis's are: a provider double
|
|
393
|
+
// fetches them with no credential to the authority at all.
|
|
394
|
+
if (req.method === "GET" && url.pathname === LOCAL_AUTHORITY_JWKS_PATH) {
|
|
395
|
+
return this.send(res, record, 200, this.jwks);
|
|
396
|
+
}
|
|
397
|
+
// The public lane: a workspace minted with no account, as the hosted
|
|
398
|
+
// authority mints one, with this double's own key as the raw key.
|
|
399
|
+
if (req.method === "POST" && url.pathname === "/v1/public/agents/provision") {
|
|
400
|
+
return this.send(res, record, 201, {
|
|
401
|
+
org_id: LOCAL_AUTHORITY_PROVISIONAL_ORG_ID,
|
|
402
|
+
raw_key: this.apiKey,
|
|
403
|
+
provisional: true,
|
|
404
|
+
limits: {
|
|
405
|
+
requests_per_minute: 10,
|
|
406
|
+
workspaces_per_network_per_day: 5,
|
|
407
|
+
governed_decisions_per_month: 50,
|
|
408
|
+
},
|
|
409
|
+
claim: { note: "synthetic workspace on loopback; there is nothing to claim" },
|
|
410
|
+
next: {},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (req.headers.authorization !== `Bearer ${this.apiKey}`) {
|
|
414
|
+
return this.send(res, record, 401, { error: "UNAUTHORIZED" });
|
|
415
|
+
}
|
|
416
|
+
const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
|
|
417
|
+
if (req.method === "GET" && dossierLookup !== null) {
|
|
418
|
+
const minted = this.dossiers.get(decodeURIComponent(dossierLookup[1] ?? ""));
|
|
419
|
+
if (minted === undefined || url.searchParams.get("org_id") !== minted.request.tenant_id) {
|
|
420
|
+
return this.send(res, record, 404, { error: "DOSSIER_NOT_FOUND" });
|
|
421
|
+
}
|
|
422
|
+
return this.send(res, record, 200, this.dossierRecord(minted.request, minted.decision));
|
|
423
|
+
}
|
|
424
|
+
const escalationLookup = url.pathname.match(/^\/v1\/authority\/escalations\/([^/]+)$/);
|
|
425
|
+
if (req.method === "GET" && escalationLookup !== null) {
|
|
426
|
+
return this.respond(res, record, "status", this.managedStatus(decodeURIComponent(escalationLookup[1] ?? "")));
|
|
427
|
+
}
|
|
428
|
+
if (req.method === "DELETE" && escalationLookup !== null) {
|
|
429
|
+
return this.cancelManaged(res, record, decodeURIComponent(escalationLookup[1] ?? ""));
|
|
430
|
+
}
|
|
431
|
+
if (req.method !== "POST")
|
|
432
|
+
return this.send(res, record, 405, { error: "METHOD_NOT_ALLOWED" });
|
|
433
|
+
if (req.headers["content-type"] !== "application/json") {
|
|
434
|
+
return this.send(res, record, 415, { error: "UNSUPPORTED_MEDIA_TYPE" });
|
|
435
|
+
}
|
|
436
|
+
switch (url.pathname) {
|
|
437
|
+
case "/v1/authority/enforce-and-bind":
|
|
438
|
+
return this.respond(res, record, "enforce", this.enforceAndBind(record));
|
|
439
|
+
case "/v1/execution/claim-token":
|
|
440
|
+
case "/v1/execution/consume-token":
|
|
441
|
+
return this.respond(res, record, "claim", this.claim(record));
|
|
442
|
+
case "/v1/execution/finalize-token":
|
|
443
|
+
return this.respond(res, record, "finalize", this.finalize(record));
|
|
444
|
+
default:
|
|
445
|
+
return this.send(res, record, 404, { error: "NOT_FOUND" });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
enforceAndBind(record) {
|
|
449
|
+
const parsed = AuthorityRequestSchema.safeParse(record.body);
|
|
450
|
+
if (!parsed.success)
|
|
451
|
+
return { status: 400, body: { error: "REQUEST_INVALID" } };
|
|
452
|
+
const request = parsed.data;
|
|
453
|
+
if (record.headers["idempotency-key"] !== request.intent_id) {
|
|
454
|
+
return { status: 400, body: { error: "IDEMPOTENCY_KEY_MISMATCH" } };
|
|
455
|
+
}
|
|
456
|
+
const bindingError = this.assertBinding(request, record);
|
|
457
|
+
if (bindingError !== null)
|
|
458
|
+
return { status: 400, body: { error: bindingError } };
|
|
459
|
+
const evaluation = this.evaluate(request);
|
|
460
|
+
if (evaluation.error !== undefined)
|
|
461
|
+
return { status: 409, body: { error: evaluation.error } };
|
|
462
|
+
this.decisions += 1;
|
|
463
|
+
const decision = this.baseDecision(request, evaluation.status, [evaluation.reasonCode]);
|
|
464
|
+
if (evaluation.status === "ESCALATE" && request.escalation?.mode === "MANAGED") {
|
|
465
|
+
return { status: 200, body: this.remember(request, this.openManaged(request, decision)) };
|
|
466
|
+
}
|
|
467
|
+
if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
|
|
468
|
+
return { status: 200, body: this.remember(request, decision) };
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
status: 200,
|
|
472
|
+
body: this.remember(request, this.grantDecision(request, decision, evaluation.approval)),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/** Keeps a minted decision for its dossier route, with the envelope when links are on. */
|
|
476
|
+
remember(request, decision) {
|
|
477
|
+
const dossierId = decision["dossier_id"];
|
|
478
|
+
if (typeof dossierId !== "string")
|
|
479
|
+
return decision;
|
|
480
|
+
const linked = this.verificationLinks
|
|
481
|
+
? {
|
|
482
|
+
...decision,
|
|
483
|
+
verification: {
|
|
484
|
+
verification_page_url: `${this.baseUrl}/verify/${dossierId}?sig=synthetic`,
|
|
485
|
+
verification_url: `${this.baseUrl}/v1/public/decision-dossiers/${dossierId}/verify?sig=synthetic`,
|
|
486
|
+
link_expires_at: new Date(this.clock() + 24 * 60 * 60 * 1_000).toISOString(),
|
|
487
|
+
signature_scheme: "synthetic",
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
: decision;
|
|
491
|
+
this.dossiers.set(dossierId, { request, decision: linked });
|
|
492
|
+
return linked;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The persisted record, shaped as `GET /v1/protocol/dossiers/{id}` returns
|
|
496
|
+
* it: a payload with the routing decision, the inputs and a proof bundle
|
|
497
|
+
* naming this double's key. The artifacts are not signed; the record says
|
|
498
|
+
* so in its issuer tier, and nothing here claims otherwise.
|
|
499
|
+
*/
|
|
500
|
+
dossierRecord(request, decision) {
|
|
501
|
+
const generatedAt = new Date(this.clock()).toISOString();
|
|
502
|
+
return {
|
|
503
|
+
service: "synthetic-authority",
|
|
504
|
+
protocol_version: "synthetic",
|
|
505
|
+
dossier: {
|
|
506
|
+
dossier_payload: {
|
|
507
|
+
schema_version: "decionis.decision_dossier/2.0",
|
|
508
|
+
dossier_id: decision["dossier_id"],
|
|
509
|
+
generated_at: generatedAt,
|
|
510
|
+
routing_decision: {
|
|
511
|
+
decision_id: decision["decision_id"],
|
|
512
|
+
outcome: decision["status"],
|
|
513
|
+
authority: decision["authority_classification"],
|
|
514
|
+
policy_version: decision["policy_version"],
|
|
515
|
+
reason_codes: decision["reason_codes"],
|
|
516
|
+
},
|
|
517
|
+
inputs_snapshot: {
|
|
518
|
+
tenant_id: request.tenant_id,
|
|
519
|
+
actor_id: request.actor.id,
|
|
520
|
+
action: request.action.type,
|
|
521
|
+
target: request.action.resource,
|
|
522
|
+
},
|
|
523
|
+
portable_artifact: { issuer_context: { tier: "synthetic_loopback" } },
|
|
524
|
+
integrity: {
|
|
525
|
+
proof_bundle: {
|
|
526
|
+
bundle_type: "decionis.decision_dossier.proof_bundle",
|
|
527
|
+
version: "2.0",
|
|
528
|
+
issued_at: generatedAt,
|
|
529
|
+
algorithm: "Ed25519",
|
|
530
|
+
key_id: this.attestationKeyId,
|
|
531
|
+
artifacts: [],
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** Policy plus evidence handling: a valid receipt turns an escalation into an allow. */
|
|
539
|
+
evaluate(request) {
|
|
540
|
+
const verdict = this.policy(request);
|
|
541
|
+
if (verdict === "BLOCK") {
|
|
542
|
+
return { status: "BLOCK", reasonCode: "POLICY_HARD_LIMIT_EXCEEDED", approval: null };
|
|
543
|
+
}
|
|
544
|
+
if (verdict === "ALLOW") {
|
|
545
|
+
return { status: "ALLOW", reasonCode: "POLICY_AUTONOMOUS_LIMIT", approval: null };
|
|
546
|
+
}
|
|
547
|
+
const humanApproval = request.evidence?.humanApproval;
|
|
548
|
+
if (humanApproval === undefined) {
|
|
549
|
+
return { status: "ESCALATE", reasonCode: "HUMAN_APPROVAL_REQUIRED", approval: null };
|
|
550
|
+
}
|
|
551
|
+
if (!this.verifyEvidence(humanApproval, request)) {
|
|
552
|
+
// Decionis verifies the receipt with Presence and refuses the request.
|
|
553
|
+
return {
|
|
554
|
+
status: "BLOCK",
|
|
555
|
+
reasonCode: "PRESENCE_RECEIPT_INVALID",
|
|
556
|
+
approval: null,
|
|
557
|
+
error: "PRESENCE_RECEIPT_INVALID",
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return { status: "ALLOW", reasonCode: "PRESENCE_RECEIPT_VERIFIED", approval: humanApproval };
|
|
561
|
+
}
|
|
562
|
+
verifyEvidence(approval, request, requiredRole) {
|
|
563
|
+
if (this.presence !== undefined) {
|
|
564
|
+
return this.presence.verifyReceiptDetailed(approval, {
|
|
565
|
+
intentHash: request.intent_hash,
|
|
566
|
+
actionType: request.action.type,
|
|
567
|
+
resource: request.action.resource,
|
|
568
|
+
...(requiredRole === undefined ? {} : { requiredRole }),
|
|
569
|
+
}).ok;
|
|
570
|
+
}
|
|
571
|
+
return this.legacyVerifyReceipt?.(approval, request.intent_hash) ?? false;
|
|
572
|
+
}
|
|
573
|
+
baseDecision(request, status, reasonCodes) {
|
|
574
|
+
const sequence = this.decisions;
|
|
575
|
+
const dossierId = `synthetic-dossier-${sequence}`;
|
|
576
|
+
return {
|
|
577
|
+
decision_id: `synthetic-decision-${sequence}`,
|
|
578
|
+
chain_id: "synthetic-chain-1",
|
|
579
|
+
status,
|
|
580
|
+
should_execute: false,
|
|
581
|
+
reason_codes: [...reasonCodes],
|
|
582
|
+
action_hash: request.intent_hash,
|
|
583
|
+
policy_version: "synthetic-policy-v1",
|
|
584
|
+
mode: request.mode,
|
|
585
|
+
execution_token: null,
|
|
586
|
+
execution_token_expires_at: null,
|
|
587
|
+
dossier_id: dossierId,
|
|
588
|
+
dossier_sha256: `sha256:${createHash("sha256").update(dossierId).digest("hex")}`,
|
|
589
|
+
dossier_url: `/v1/protocol/dossiers/${dossierId}`,
|
|
590
|
+
approval_request_id: status === "ESCALATE" ? `synthetic-approval-${sequence}` : null,
|
|
591
|
+
ledger_entry_id: `synthetic-ledger-${sequence}`,
|
|
592
|
+
authority_classification: request.mode === "ENFORCEMENT" ? "AUTHORITATIVE" : "OBSERVATIONAL",
|
|
593
|
+
execution_eligible: false,
|
|
594
|
+
execution_binding_digest: null,
|
|
595
|
+
execution_token_jti: null,
|
|
596
|
+
execution_token_key_id: null,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
grantDecision(request, decision, approval) {
|
|
600
|
+
const issuedAt = Math.floor(this.clock() / 1_000);
|
|
601
|
+
const expiresAt = Math.min(Math.floor((this.clock() + GRANT_TTL_MS) / 1_000), Math.floor(Date.parse(request.expires_at) / 1_000));
|
|
602
|
+
const jti = `synthetic-jti-${randomUUID()}`;
|
|
603
|
+
const token = `synthetic-grant.${jti}`;
|
|
604
|
+
const bindingDigest = `sha256:${createHash("sha256").update(request.intent_hash).digest("hex")}`;
|
|
605
|
+
this.grants.set(token, {
|
|
606
|
+
jti,
|
|
607
|
+
tenantId: request.tenant_id,
|
|
608
|
+
actorId: request.actor.id,
|
|
609
|
+
action: request.action.type,
|
|
610
|
+
audience: `${request.downstream_target.system}:${request.downstream_target.operation}`,
|
|
611
|
+
decisionId: String(decision.decision_id),
|
|
612
|
+
dossierId: String(decision.dossier_id),
|
|
613
|
+
intentHash: request.intent_hash,
|
|
614
|
+
issuedAt,
|
|
615
|
+
expiresAt,
|
|
616
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
617
|
+
bindingDigest,
|
|
618
|
+
payloadDigest: hashBinding(request.action.parameters),
|
|
619
|
+
expectedEffectDigest: request.expected_effect_digest ?? null,
|
|
620
|
+
receiptDossierId: approval?.receiptDossierId ?? null,
|
|
621
|
+
claimed: false,
|
|
622
|
+
claimToken: null,
|
|
623
|
+
correlationId: null,
|
|
624
|
+
consumedBy: null,
|
|
625
|
+
finalized: null,
|
|
626
|
+
});
|
|
627
|
+
return {
|
|
628
|
+
...decision,
|
|
629
|
+
should_execute: true,
|
|
630
|
+
execution_token: token,
|
|
631
|
+
execution_token_expires_at: new Date(expiresAt * 1_000).toISOString(),
|
|
632
|
+
execution_eligible: true,
|
|
633
|
+
execution_binding_digest: bindingDigest,
|
|
634
|
+
execution_token_jti: jti,
|
|
635
|
+
execution_token_key_id: "synthetic-key-1",
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/** Opens a managed escalation: scripted, orchestrated through LocalPresence, or default-scripted. */
|
|
639
|
+
openManaged(request, decision) {
|
|
640
|
+
const existing = this.managedByIntent.get(request.intent_id);
|
|
641
|
+
if (existing !== undefined)
|
|
642
|
+
return existing.initialDecision;
|
|
643
|
+
const escalationId = `synthetic-escalation-${randomUUID()}`;
|
|
644
|
+
const initialDecision = {
|
|
645
|
+
...decision,
|
|
646
|
+
managed_escalation: {
|
|
647
|
+
outcome: "ESCALATE_PENDING",
|
|
648
|
+
escalation_id: escalationId,
|
|
649
|
+
intent_id: request.intent_id,
|
|
650
|
+
status: "PENDING_PRESENCE",
|
|
651
|
+
expires_at: request.expires_at,
|
|
652
|
+
reason_codes: ["PRESENCE_PENDING"],
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
let lifecycle = this.nextManagedLifecycle;
|
|
656
|
+
this.nextManagedLifecycle = null;
|
|
657
|
+
let presenceRequestId = null;
|
|
658
|
+
let status = "PENDING_PRESENCE";
|
|
659
|
+
let reasonCodes = ["PRESENCE_PENDING"];
|
|
660
|
+
if (lifecycle === null && this.presence !== undefined) {
|
|
661
|
+
const created = this.requestPresence(request, escalationId);
|
|
662
|
+
if (created === null) {
|
|
663
|
+
status = "FAILED";
|
|
664
|
+
reasonCodes = ["PRESENCE_REQUEST_FAILED"];
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
presenceRequestId = created;
|
|
668
|
+
status = "AWAITING_APPROVER";
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
else if (lifecycle === null) {
|
|
672
|
+
lifecycle = ["AWAITING_APPROVER", "GRANT_READY"];
|
|
673
|
+
}
|
|
674
|
+
const managed = {
|
|
675
|
+
escalationId,
|
|
676
|
+
request,
|
|
677
|
+
presenceRequestId,
|
|
678
|
+
lifecycle,
|
|
679
|
+
lookup: 0,
|
|
680
|
+
status,
|
|
681
|
+
reasonCodes,
|
|
682
|
+
finalDecision: null,
|
|
683
|
+
initialDecision,
|
|
684
|
+
};
|
|
685
|
+
this.escalations.set(escalationId, managed);
|
|
686
|
+
this.managedByIntent.set(request.intent_id, managed);
|
|
687
|
+
return initialDecision;
|
|
688
|
+
}
|
|
689
|
+
/** Creates the Presence request the way Decionis does: bound structurally to the intent hash. */
|
|
690
|
+
requestPresence(request, escalationId) {
|
|
691
|
+
if (this.presence === undefined)
|
|
692
|
+
return null;
|
|
693
|
+
const requirements = request.escalation?.verification_requirements;
|
|
694
|
+
const amountMinor = request.action.parameters.amountMinor;
|
|
695
|
+
const currency = request.action.parameters.currency;
|
|
696
|
+
const remainingSeconds = Math.floor((Date.parse(request.expires_at) - this.clock()) / 1_000);
|
|
697
|
+
const created = this.presence.createRequest({
|
|
698
|
+
action_context: {
|
|
699
|
+
intent: request.action.type,
|
|
700
|
+
surface: "decionis_managed",
|
|
701
|
+
actor_id: request.escalation?.approver?.principal_id ?? this.managedApproverId,
|
|
702
|
+
target_resource_id: request.action.resource,
|
|
703
|
+
intent_hash: request.intent_hash,
|
|
704
|
+
...(typeof amountMinor === "number" && typeof currency === "string"
|
|
705
|
+
? { amount: amountMinor / 100, currency: currency.toUpperCase() }
|
|
706
|
+
: {}),
|
|
707
|
+
},
|
|
708
|
+
originator: {
|
|
709
|
+
organization_name: "Local Decionis",
|
|
710
|
+
actor_id: request.actor.id,
|
|
711
|
+
display_name: request.actor.id,
|
|
712
|
+
role: "AI execution authority requester",
|
|
713
|
+
},
|
|
714
|
+
presentation: {
|
|
715
|
+
locale: "en",
|
|
716
|
+
title: `Approve ${request.action.type}`,
|
|
717
|
+
description: `${request.action.resource} is held until human authority is verified.`,
|
|
718
|
+
display_fields: [
|
|
719
|
+
{ key: "action", label: "Action", value: request.action.type },
|
|
720
|
+
{ key: "target", label: "Target", value: request.action.resource },
|
|
721
|
+
{ key: "intent_hash", label: "Intent hash", value: request.intent_hash },
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
verification_requirements: {
|
|
725
|
+
level: requirements?.level ?? "STANDARD",
|
|
726
|
+
methods: requirements?.methods ?? ["WEBAUTHN"],
|
|
727
|
+
hardware_pki_required: false,
|
|
728
|
+
disallow_virtual_cameras: true,
|
|
729
|
+
},
|
|
730
|
+
ttl_seconds: Math.min(Math.max(remainingSeconds, 30), 600),
|
|
731
|
+
}, `managed-${createHash("sha256").update(escalationId).digest("hex").slice(0, 40)}`);
|
|
732
|
+
if (created.status !== 200)
|
|
733
|
+
return null;
|
|
734
|
+
const body = created.body;
|
|
735
|
+
return typeof body.request_id === "string" ? body.request_id : null;
|
|
736
|
+
}
|
|
737
|
+
managedStatus(escalationId) {
|
|
738
|
+
const managed = this.escalations.get(escalationId);
|
|
739
|
+
if (managed === undefined)
|
|
740
|
+
return { status: 404, body: { error: "NOT_FOUND" } };
|
|
741
|
+
const state = managed.lifecycle === null ? this.advance(managed) : this.scripted(managed);
|
|
742
|
+
let outcome = "ESCALATE_PENDING";
|
|
743
|
+
let decision = null;
|
|
744
|
+
if (state.status === "GRANT_READY") {
|
|
745
|
+
outcome = "ALLOW";
|
|
746
|
+
if (managed.finalDecision === null) {
|
|
747
|
+
this.decisions += 1;
|
|
748
|
+
managed.finalDecision = this.grantDecision(managed.request, this.baseDecision({ ...managed.request, mode: "ENFORCEMENT" }, "ALLOW", state.reasonCodes), null);
|
|
749
|
+
}
|
|
750
|
+
decision = managed.finalDecision;
|
|
751
|
+
}
|
|
752
|
+
else if (state.status === "EXPIRED" ||
|
|
753
|
+
state.status === "REJECTED" ||
|
|
754
|
+
state.status === "BLOCKED" ||
|
|
755
|
+
state.status === "CANCELLED") {
|
|
756
|
+
outcome = "BLOCK";
|
|
757
|
+
}
|
|
758
|
+
else if (state.status === "FAILED") {
|
|
759
|
+
outcome = "ERROR";
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
status: 200,
|
|
763
|
+
body: {
|
|
764
|
+
escalation_id: managed.escalationId,
|
|
765
|
+
intent_id: managed.request.intent_id,
|
|
766
|
+
action_hash: managed.request.intent_hash,
|
|
767
|
+
status: state.status,
|
|
768
|
+
outcome,
|
|
769
|
+
expires_at: managed.request.expires_at,
|
|
770
|
+
reason_codes: state.reasonCodes,
|
|
771
|
+
decision,
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
scripted(managed) {
|
|
776
|
+
const lifecycle = managed.lifecycle ?? [];
|
|
777
|
+
const index = Math.min(managed.lookup, lifecycle.length - 1);
|
|
778
|
+
const entry = lifecycle[index] ?? "FAILED";
|
|
779
|
+
managed.lookup += 1;
|
|
780
|
+
const status = typeof entry === "string" ? entry : entry.status;
|
|
781
|
+
const scriptedReasonCodes = typeof entry === "string" ? null : (entry.reasonCodes ?? null);
|
|
782
|
+
const reasonCodes = scriptedReasonCodes ??
|
|
783
|
+
(status === "GRANT_READY"
|
|
784
|
+
? ["PRESENCE_RECEIPT_VERIFIED"]
|
|
785
|
+
: status === "EXPIRED"
|
|
786
|
+
? ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]
|
|
787
|
+
: status === "REJECTED"
|
|
788
|
+
? ["PRESENCE_REJECTED"]
|
|
789
|
+
: status === "CANCELLED"
|
|
790
|
+
? ["CANCELLED"]
|
|
791
|
+
: status === "BLOCKED"
|
|
792
|
+
? ["REAUTHORIZATION_BLOCKED"]
|
|
793
|
+
: status === "FAILED"
|
|
794
|
+
? ["PRESENCE_VERIFICATION_FAILED"]
|
|
795
|
+
: ["PRESENCE_PENDING"]);
|
|
796
|
+
managed.status = status;
|
|
797
|
+
managed.reasonCodes = reasonCodes;
|
|
798
|
+
return { status, reasonCodes };
|
|
799
|
+
}
|
|
800
|
+
/** Orchestrated state machine, mirroring Decionis's transitions against Presence. */
|
|
801
|
+
advance(managed) {
|
|
802
|
+
const done = (status, reasonCodes) => {
|
|
803
|
+
managed.status = status;
|
|
804
|
+
managed.reasonCodes = reasonCodes;
|
|
805
|
+
return { status, reasonCodes };
|
|
806
|
+
};
|
|
807
|
+
if (LocalAuthority.isTerminal(managed.status)) {
|
|
808
|
+
return { status: managed.status, reasonCodes: managed.reasonCodes };
|
|
809
|
+
}
|
|
810
|
+
if (Date.parse(managed.request.expires_at) <= this.clock()) {
|
|
811
|
+
return done("EXPIRED", ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]);
|
|
812
|
+
}
|
|
813
|
+
if (this.presence === undefined || managed.presenceRequestId === null) {
|
|
814
|
+
return done("FAILED", ["PRESENCE_REQUEST_FAILED"]);
|
|
815
|
+
}
|
|
816
|
+
const view = this.presence.poll(managed.presenceRequestId);
|
|
817
|
+
if (view === null)
|
|
818
|
+
return done("FAILED", ["PRESENCE_REQUEST_FAILED"]);
|
|
819
|
+
switch (view.status) {
|
|
820
|
+
case "PENDING":
|
|
821
|
+
case "DELIVERED":
|
|
822
|
+
return done("AWAITING_APPROVER", ["PRESENCE_PENDING"]);
|
|
823
|
+
case "BLOCKED":
|
|
824
|
+
return done("REJECTED", ["PRESENCE_REJECTED"]);
|
|
825
|
+
case "EXPIRED":
|
|
826
|
+
return done("EXPIRED", ["PRESENCE_EXPIRED", "RECAPTURE_REQUIRED"]);
|
|
827
|
+
case "CANCELLED":
|
|
828
|
+
return done("CANCELLED", ["MANAGED_ESCALATION_CANCELLED"]);
|
|
829
|
+
case "ALLOWED":
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
if (view.receiptDossierId === null)
|
|
833
|
+
return done("FAILED", ["PRESENCE_VERIFICATION_FAILED"]);
|
|
834
|
+
const verification = this.presence.verifyReceiptDetailed({ requestId: view.requestId, receiptDossierId: view.receiptDossierId }, {
|
|
835
|
+
intentHash: managed.request.intent_hash,
|
|
836
|
+
actionType: managed.request.action.type,
|
|
837
|
+
resource: managed.request.action.resource,
|
|
838
|
+
...(managed.request.escalation?.approver?.role_id === undefined
|
|
839
|
+
? {}
|
|
840
|
+
: { requiredRole: managed.request.escalation.approver.role_id }),
|
|
841
|
+
});
|
|
842
|
+
if (!verification.ok)
|
|
843
|
+
return done("FAILED", ["PRESENCE_VERIFICATION_FAILED", verification.reasonCode]);
|
|
844
|
+
// PRESENCE_VERIFIED and REAUTHORIZING are passed through within one lookup, as Decionis does.
|
|
845
|
+
const reevaluated = this.policy(managed.request);
|
|
846
|
+
if (reevaluated === "BLOCK")
|
|
847
|
+
return done("BLOCKED", ["REAUTHORIZATION_BLOCKED"]);
|
|
848
|
+
return done("GRANT_READY", ["PRESENCE_RECEIPT_VERIFIED"]);
|
|
849
|
+
}
|
|
850
|
+
cancelManaged(res, record, escalationId) {
|
|
851
|
+
const managed = this.escalations.get(escalationId);
|
|
852
|
+
if (managed === undefined)
|
|
853
|
+
return this.send(res, record, 404, { error: "NOT_FOUND" });
|
|
854
|
+
if (managed.lifecycle !== null) {
|
|
855
|
+
managed.lifecycle = ["CANCELLED"];
|
|
856
|
+
managed.lookup = 0;
|
|
857
|
+
}
|
|
858
|
+
else if (!LocalAuthority.isTerminal(managed.status)) {
|
|
859
|
+
if (managed.presenceRequestId !== null)
|
|
860
|
+
this.presence?.cancel(managed.presenceRequestId);
|
|
861
|
+
managed.status = "CANCELLED";
|
|
862
|
+
managed.reasonCodes = ["MANAGED_ESCALATION_CANCELLED"];
|
|
863
|
+
}
|
|
864
|
+
const result = this.managedStatus(escalationId);
|
|
865
|
+
return this.send(res, record, result.status, result.body);
|
|
866
|
+
}
|
|
867
|
+
claim(record) {
|
|
868
|
+
const parsed = TokenRequestSchema.safeParse(record.body);
|
|
869
|
+
if (!parsed.success)
|
|
870
|
+
return { status: 400, body: { error: "REQUEST_INVALID" } };
|
|
871
|
+
const intent = IntentBindingSchema.safeParse(parsed.data.intent);
|
|
872
|
+
if (!intent.success)
|
|
873
|
+
return { status: 400, body: { error: "REQUEST_INVALID" } };
|
|
874
|
+
const rejected = (reasonCode, status = 409) => ({
|
|
875
|
+
status,
|
|
876
|
+
body: { valid: false, reason_codes: [reasonCode], claims: null },
|
|
877
|
+
});
|
|
878
|
+
const bindingError = this.assertBinding({ ...intent.data, intent_hash: parsed.data.intent_hash }, record);
|
|
879
|
+
if (bindingError !== null)
|
|
880
|
+
return rejected(bindingError);
|
|
881
|
+
const grant = this.grants.get(parsed.data.execution_token);
|
|
882
|
+
if (grant === undefined)
|
|
883
|
+
return rejected("GRANT_INVALID");
|
|
884
|
+
if (grant.expiresAt * 1_000 <= this.clock())
|
|
885
|
+
return rejected("GRANT_EXPIRED");
|
|
886
|
+
if (grant.intentHash !== parsed.data.intent_hash)
|
|
887
|
+
return rejected("GRANT_BINDING_MISMATCH");
|
|
888
|
+
// Unreachable while the commitment stays inside the intent hash — a
|
|
889
|
+
// divergence is already an `INTENT_HASH_MISMATCH` from the independent
|
|
890
|
+
// re-hash above. Kept because the hosted route checks the committed value
|
|
891
|
+
// itself, and a double that only checked the hash would stop proving that.
|
|
892
|
+
if ((intent.data.expected_effect_digest ?? null) !== grant.expectedEffectDigest) {
|
|
893
|
+
return rejected("GRANT_BINDING_MISMATCH");
|
|
894
|
+
}
|
|
895
|
+
if (grant.receiptDossierId !== null &&
|
|
896
|
+
parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
|
|
897
|
+
return rejected("PRESENCE_APPROVAL_STALE");
|
|
898
|
+
}
|
|
899
|
+
if (grant.claimed)
|
|
900
|
+
return rejected("NONCE_REPLAY_DETECTED");
|
|
901
|
+
grant.claimed = true;
|
|
902
|
+
grant.claimToken = randomBytes(32).toString("base64url");
|
|
903
|
+
grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
|
|
904
|
+
grant.consumedBy = parsed.data.consumed_by ?? null;
|
|
905
|
+
const claimValidatedAt = new Date(this.clock()).toISOString();
|
|
906
|
+
const leaseExpiresAt = new Date(this.clock() + CLAIM_LEASE_MS).toISOString();
|
|
907
|
+
// The drill knob: an authority that bound a digest over other parameters.
|
|
908
|
+
// It is consumed by one claim, so the executor's refusal is the one
|
|
909
|
+
// observation and the next proposal sees an honest authority again.
|
|
910
|
+
const payloadDigest = this.misbindNextPayloadDigest
|
|
911
|
+
? hashBinding({ ...intent.data.action.parameters, synthetic_tamper: true })
|
|
912
|
+
: grant.payloadDigest;
|
|
913
|
+
this.misbindNextPayloadDigest = false;
|
|
914
|
+
return {
|
|
915
|
+
status: 200,
|
|
916
|
+
body: {
|
|
917
|
+
valid: true,
|
|
918
|
+
should_execute: true,
|
|
919
|
+
would_block: false,
|
|
920
|
+
verdict: "ALLOW",
|
|
921
|
+
reason_codes: [],
|
|
922
|
+
claims: {
|
|
923
|
+
iss: LOCAL_AUTHORITY_ISSUER,
|
|
924
|
+
sub: grant.actorId,
|
|
925
|
+
aud: grant.audience,
|
|
926
|
+
org_id: grant.tenantId,
|
|
927
|
+
dossier_id: grant.dossierId,
|
|
928
|
+
decision_id: grant.decisionId,
|
|
929
|
+
chain_id: "synthetic-chain-1",
|
|
930
|
+
action: grant.action,
|
|
931
|
+
decision: "allow",
|
|
932
|
+
scope: "execute",
|
|
933
|
+
binding: {
|
|
934
|
+
intent_hash: grant.intentHash,
|
|
935
|
+
execution_binding_digest: grant.bindingDigest,
|
|
936
|
+
execution_payload_digest: payloadDigest,
|
|
937
|
+
execution_payload_canonicalization_profile: JCS_PROFILE,
|
|
938
|
+
execution_nonce: grant.nonce,
|
|
939
|
+
execution_correlation_id: grant.correlationId,
|
|
940
|
+
...(grant.expectedEffectDigest === null
|
|
941
|
+
? {}
|
|
942
|
+
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
943
|
+
},
|
|
944
|
+
jti: grant.jti,
|
|
945
|
+
iat: grant.issuedAt,
|
|
946
|
+
nbf: grant.issuedAt,
|
|
947
|
+
exp: grant.expiresAt,
|
|
948
|
+
},
|
|
949
|
+
claim_token: grant.claimToken,
|
|
950
|
+
claim_validated_at: claimValidatedAt,
|
|
951
|
+
claim_lease_expires_at: leaseExpiresAt,
|
|
952
|
+
claim_attestation: this.attest({ ...grant, payloadDigest }, claimValidatedAt, leaseExpiresAt),
|
|
953
|
+
evidence: { nonce_claim_state: "CLAIMED", commit_correlation_id: grant.correlationId },
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
finalize(record) {
|
|
958
|
+
const parsed = FinalizeRequestSchema.safeParse(record.body);
|
|
959
|
+
if (!parsed.success)
|
|
960
|
+
return { status: 400, body: { error: "REQUEST_INVALID" } };
|
|
961
|
+
const rejected = (reasonCode) => ({
|
|
962
|
+
status: 409,
|
|
963
|
+
body: { finalized: false, reason_codes: [reasonCode] },
|
|
964
|
+
});
|
|
965
|
+
const grant = this.grants.get(parsed.data.execution_token);
|
|
966
|
+
if (grant === undefined)
|
|
967
|
+
return rejected("GRANT_INVALID");
|
|
968
|
+
if (!grant.claimed || grant.claimToken !== parsed.data.claim_token) {
|
|
969
|
+
return rejected("NONCE_REPLAY_DETECTED");
|
|
970
|
+
}
|
|
971
|
+
if (grant.correlationId !== parsed.data.commit_correlation_id) {
|
|
972
|
+
return rejected("EXECUTION_CORRELATION_MISMATCH");
|
|
973
|
+
}
|
|
974
|
+
if (grant.finalized !== null)
|
|
975
|
+
return rejected("NONCE_REPLAY_DETECTED");
|
|
976
|
+
// The hosted authority refuses the whole finalization for evidence it
|
|
977
|
+
// cannot bind, in exactly this order, before the commit transition.
|
|
978
|
+
const supplied = parsed.data.effect_evidence;
|
|
979
|
+
let evidence;
|
|
980
|
+
if (supplied !== undefined) {
|
|
981
|
+
const parsedEvidence = EffectEvidenceSchema.safeParse(supplied);
|
|
982
|
+
if (!parsedEvidence.success)
|
|
983
|
+
return rejected(malformedEffectEvidenceReason(supplied));
|
|
984
|
+
evidence = parsedEvidence.data;
|
|
985
|
+
if (evidence.execution_correlation_id !== parsed.data.commit_correlation_id) {
|
|
986
|
+
return rejected("EXECUTION_CORRELATION_MISMATCH");
|
|
987
|
+
}
|
|
988
|
+
if (grant.expectedEffectDigest === null) {
|
|
989
|
+
return rejected("EFFECT_EXPECTED_BINDING_UNAVAILABLE");
|
|
990
|
+
}
|
|
991
|
+
if (evidence.expected_effect_digest !== grant.expectedEffectDigest) {
|
|
992
|
+
return rejected("EFFECT_EXPECTED_DIGEST_MISMATCH");
|
|
993
|
+
}
|
|
994
|
+
if (evidence.status === "CONFIRMED" && parsed.data.outcome !== "COMMITTED") {
|
|
995
|
+
return rejected("EFFECT_EVIDENCE_OUTCOME_MISMATCH");
|
|
996
|
+
}
|
|
997
|
+
if (evidence.status === "CONFIRMED") {
|
|
998
|
+
const trusted = this.effectObserverIds();
|
|
999
|
+
if (trusted.length === 0) {
|
|
1000
|
+
return rejected("EFFECT_OBSERVER_PROVENANCE_UNAVAILABLE");
|
|
1001
|
+
}
|
|
1002
|
+
if (evidence.observer === null || !trusted.includes(evidence.observer.id)) {
|
|
1003
|
+
return rejected("EFFECT_OBSERVER_PROVENANCE_MISMATCH");
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
grant.finalized = parsed.data.outcome;
|
|
1008
|
+
// Byte-for-byte the hosted success body: the commit's own decision-chain
|
|
1009
|
+
// evidence is queued, not yet recorded, so a successful finalization still
|
|
1010
|
+
// carries `COMMIT_EVIDENCE_PENDING`.
|
|
1011
|
+
return {
|
|
1012
|
+
status: 200,
|
|
1013
|
+
body: {
|
|
1014
|
+
finalized: true,
|
|
1015
|
+
outcome: parsed.data.outcome,
|
|
1016
|
+
evidence_durably_queued: true,
|
|
1017
|
+
decision_chain_evidence_recorded: false,
|
|
1018
|
+
evidence_recorded: false,
|
|
1019
|
+
effect_evidence_recorded: evidence !== undefined,
|
|
1020
|
+
effect_confirmation: evidence?.status === "CONFIRMED" ? "CONFIRMED" : "UNCONFIRMED",
|
|
1021
|
+
reason_codes: ["COMMIT_EVIDENCE_PENDING"],
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* The hosted route never trusts the allowlist alone: it returns at most the
|
|
1027
|
+
* caller's own authenticated API-key identity, so a confirmed observation
|
|
1028
|
+
* must name the identity that presented the credential. This double has one
|
|
1029
|
+
* credential, so that credential is its identity.
|
|
1030
|
+
*/
|
|
1031
|
+
effectObserverIds() {
|
|
1032
|
+
return this.trustedEffectObserverIds.includes(this.apiKey) ? [this.apiKey] : [];
|
|
1033
|
+
}
|
|
1034
|
+
/** Recomputes the hash independently and applies the contract's time rules. */
|
|
1035
|
+
assertBinding(request, record) {
|
|
1036
|
+
const recomputed = hashBinding(bindingOf(request));
|
|
1037
|
+
record.recomputedHash = recomputed;
|
|
1038
|
+
if (recomputed !== request.intent_hash)
|
|
1039
|
+
return "INTENT_HASH_MISMATCH";
|
|
1040
|
+
const capturedAt = Date.parse(request.captured_at);
|
|
1041
|
+
const expiresAt = Date.parse(request.expires_at);
|
|
1042
|
+
const now = this.clock();
|
|
1043
|
+
if (capturedAt > now + 5_000)
|
|
1044
|
+
return "INTENT_CAPTURED_IN_FUTURE";
|
|
1045
|
+
if (expiresAt <= now)
|
|
1046
|
+
return "INTENT_EXPIRED";
|
|
1047
|
+
if (expiresAt <= capturedAt)
|
|
1048
|
+
return "INTENT_TIME_ORDER_INVALID";
|
|
1049
|
+
if (expiresAt - capturedAt > 300_000)
|
|
1050
|
+
return "INTENT_LIFETIME_TOO_LONG";
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
async respond(res, record, route, computed) {
|
|
1054
|
+
const override = this.overrides[route].shift();
|
|
1055
|
+
if (override === undefined)
|
|
1056
|
+
return this.send(res, record, computed.status, computed.body);
|
|
1057
|
+
if (override.delayMs !== undefined)
|
|
1058
|
+
await sleep(override.delayMs);
|
|
1059
|
+
if (override.destroy === true) {
|
|
1060
|
+
record.response = { status: null, body: "[connection destroyed]" };
|
|
1061
|
+
res.socket?.destroy();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const status = override.status ?? computed.status;
|
|
1065
|
+
const body = override.body !== undefined
|
|
1066
|
+
? override.body
|
|
1067
|
+
: override.transform !== undefined
|
|
1068
|
+
? override.transform(computed.body)
|
|
1069
|
+
: computed.body;
|
|
1070
|
+
if (override.truncateTo !== undefined) {
|
|
1071
|
+
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
|
1072
|
+
record.response = { status, body: `[truncated to ${override.truncateTo} bytes]` };
|
|
1073
|
+
res.writeHead(status, {
|
|
1074
|
+
"content-type": "application/json",
|
|
1075
|
+
"content-length": String(Buffer.byteLength(raw, "utf8")),
|
|
1076
|
+
});
|
|
1077
|
+
res.write(raw.slice(0, override.truncateTo));
|
|
1078
|
+
res.socket?.destroy();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
return this.send(res, record, status, body);
|
|
1082
|
+
}
|
|
1083
|
+
send(res, record, status, body) {
|
|
1084
|
+
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
|
1085
|
+
record.response = { status, body: typeof body === "string" ? truncate(body) : body };
|
|
1086
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
1087
|
+
res.end(raw);
|
|
1088
|
+
}
|
|
1089
|
+
static isTerminal(status) {
|
|
1090
|
+
return (status === "GRANT_READY" ||
|
|
1091
|
+
status === "EXPIRED" ||
|
|
1092
|
+
status === "REJECTED" ||
|
|
1093
|
+
status === "BLOCKED" ||
|
|
1094
|
+
status === "CANCELLED" ||
|
|
1095
|
+
status === "FAILED");
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* The hosted route maps an unparseable observation to a 409 reason code rather
|
|
1100
|
+
* than a request rejection, and singles out a `CONFIRMED` observation whose
|
|
1101
|
+
* observer version is missing, because that is a provenance failure rather than
|
|
1102
|
+
* a malformed body.
|
|
1103
|
+
*/
|
|
1104
|
+
function malformedEffectEvidenceReason(supplied) {
|
|
1105
|
+
const candidate = asRecord(supplied);
|
|
1106
|
+
const observer = asRecord(candidate.observer);
|
|
1107
|
+
if (candidate.version === "1.0" &&
|
|
1108
|
+
candidate.status === "CONFIRMED" &&
|
|
1109
|
+
(typeof observer.version !== "string" || observer.version.trim().length === 0)) {
|
|
1110
|
+
return "EFFECT_OBSERVER_PROVENANCE_UNAVAILABLE";
|
|
1111
|
+
}
|
|
1112
|
+
return "EFFECT_EVIDENCE_INVALID";
|
|
1113
|
+
}
|
|
1114
|
+
function asRecord(value) {
|
|
1115
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1116
|
+
}
|
|
1117
|
+
function headerString(value) {
|
|
1118
|
+
if (typeof value === "string")
|
|
1119
|
+
return value;
|
|
1120
|
+
if (Array.isArray(value))
|
|
1121
|
+
return value[0] ?? null;
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
//# sourceMappingURL=LocalAuthority.js.map
|