@decionis/agent-safe-pipeline 0.1.4 → 0.3.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 +170 -10
- package/dist/Index.d.ts +8 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +7 -0
- package/dist/Index.js.map +1 -1
- package/dist/audit/AuditRecorder.d.ts +1 -1
- package/dist/audit/AuditRecorder.d.ts.map +1 -1
- package/dist/audit/AuditRecorder.js.map +1 -1
- 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 +18 -1
- package/dist/decision/DecionisGate.d.ts.map +1 -1
- package/dist/decision/DecionisGate.js +31 -3
- package/dist/decision/DecionisGate.js.map +1 -1
- package/dist/decision/DecisionAuthority.d.ts +26 -0
- package/dist/decision/DecisionAuthority.d.ts.map +1 -1
- package/dist/decision/DecisionAuthority.js.map +1 -1
- package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
- package/dist/decision/ImmutableGateDecision.js +8 -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 +40 -0
- package/dist/execution/ActionRegistry.d.ts.map +1 -1
- package/dist/execution/ActionRegistry.js +47 -2
- package/dist/execution/ActionRegistry.js.map +1 -1
- package/dist/execution/AuthorizationVerifier.d.ts +147 -1
- package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
- package/dist/execution/AuthorizationVerifier.js +209 -6
- package/dist/execution/AuthorizationVerifier.js.map +1 -1
- package/dist/execution/SafeExecutor.d.ts +34 -3
- package/dist/execution/SafeExecutor.d.ts.map +1 -1
- package/dist/execution/SafeExecutor.js +42 -5
- 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.map +1 -1
- package/dist/intent/CanonicalIntentHasher.js +3 -0
- package/dist/intent/CanonicalIntentHasher.js.map +1 -1
- package/dist/intent/ExecutionIntent.d.ts +13 -1
- package/dist/intent/ExecutionIntent.d.ts.map +1 -1
- package/dist/intent/ExecutionIntent.js +4 -0
- package/dist/intent/ExecutionIntent.js.map +1 -1
- package/dist/intent/IntentCapture.d.ts.map +1 -1
- package/dist/intent/IntentCapture.js +3 -0
- 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/testing/Index.d.ts +1 -1
- package/dist/testing/Index.d.ts.map +1 -1
- package/dist/testing/Index.js +1 -1
- package/dist/testing/Index.js.map +1 -1
- package/dist/testing/LocalAuthority.d.ts +156 -0
- package/dist/testing/LocalAuthority.d.ts.map +1 -1
- package/dist/testing/LocalAuthority.js +531 -9
- package/dist/testing/LocalAuthority.js.map +1 -1
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Grants, claims, escalations, and commit outcomes are synthetic in-memory
|
|
16
16
|
* state. Nothing here is a real policy, tenant, or credential.
|
|
17
17
|
*/
|
|
18
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
18
|
+
import { createHash, createPublicKey, generateKeyPairSync, randomBytes, randomUUID, sign, verify, } from "node:crypto";
|
|
19
19
|
import { createServer } from "node:http";
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { readBody } from "./LocalPresence.js";
|
|
@@ -28,6 +28,16 @@ const MAX_RECORDED_BODY_CHARS = 4_096;
|
|
|
28
28
|
const SOCKET_TIMEOUT_MS = 10_000;
|
|
29
29
|
const GRANT_TTL_MS = 60_000;
|
|
30
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 protected header `typ` of a verifying provider's effect receipt (VP-3). */
|
|
37
|
+
export const EFFECT_RECEIPT_TYPE = "decionis-effect-receipt+jwt";
|
|
38
|
+
const COMPACT_JWS = /^[\w-]+\.[\w-]+\.[\w-]+$/;
|
|
39
|
+
/** The profile the fixture digests parameters under; the only one the pipeline reproduces. */
|
|
40
|
+
const JCS_PROFILE = "RFC8785/JCS";
|
|
31
41
|
const boundedId = z.string().trim().min(1).max(200);
|
|
32
42
|
const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
33
43
|
const roleId = z.string().regex(/^[A-Z][A-Z0-9_]{1,63}$/);
|
|
@@ -60,6 +70,7 @@ const IntentBindingSchema = z.strictObject({
|
|
|
60
70
|
operation: boundedId,
|
|
61
71
|
endpoint: z.string().min(1).max(500).optional(),
|
|
62
72
|
}),
|
|
73
|
+
expected_effect_digest: sha256Digest.optional(),
|
|
63
74
|
});
|
|
64
75
|
const EvidenceSchema = z.strictObject({
|
|
65
76
|
humanApproval: z
|
|
@@ -70,6 +81,61 @@ const EvidenceSchema = z.strictObject({
|
|
|
70
81
|
})
|
|
71
82
|
.optional(),
|
|
72
83
|
});
|
|
84
|
+
/** `boundedIdentifier` from the Decionis `EffectEvidence` contract, verbatim. */
|
|
85
|
+
const boundedEffectIdentifier = z.string().trim().min(1).max(500);
|
|
86
|
+
/**
|
|
87
|
+
* Protocol 1.1 `EffectEvidence`, mirrored from the Decionis shared contract
|
|
88
|
+
* including its `CONFIRMED` refinements: a confirmed observation may not rest
|
|
89
|
+
* on a bare downstream acknowledgement, must name an observer with a version,
|
|
90
|
+
* must have observed exactly the expected effect at a stated time, and must
|
|
91
|
+
* carry a digest or a reference for the observation itself.
|
|
92
|
+
*/
|
|
93
|
+
const EffectEvidenceSchema = z
|
|
94
|
+
.strictObject({
|
|
95
|
+
version: z.literal("1.0"),
|
|
96
|
+
status: z.enum(["CONFIRMED", "UNCONFIRMED"]),
|
|
97
|
+
observation_method: z.enum([
|
|
98
|
+
"DOWNSTREAM_ACK",
|
|
99
|
+
"READ_AFTER_WRITE",
|
|
100
|
+
"EVENT_CONFIRMATION",
|
|
101
|
+
"STATE_RECONCILIATION",
|
|
102
|
+
"SIGNED_RECEIPT",
|
|
103
|
+
"EXTERNAL_ATTESTATION",
|
|
104
|
+
"HUMAN_VALIDATION",
|
|
105
|
+
]),
|
|
106
|
+
observer: z
|
|
107
|
+
.strictObject({
|
|
108
|
+
id: boundedEffectIdentifier,
|
|
109
|
+
version: boundedEffectIdentifier.nullable(),
|
|
110
|
+
})
|
|
111
|
+
.nullable(),
|
|
112
|
+
expected_effect_digest: sha256Digest,
|
|
113
|
+
observed_effect_digest: sha256Digest.nullable(),
|
|
114
|
+
observed_at: z.iso.datetime().nullable(),
|
|
115
|
+
evidence_digest: sha256Digest.nullable(),
|
|
116
|
+
evidence_reference: boundedEffectIdentifier.nullable(),
|
|
117
|
+
execution_correlation_id: boundedEffectIdentifier,
|
|
118
|
+
})
|
|
119
|
+
.superRefine((evidence, context) => {
|
|
120
|
+
if (evidence.status !== "CONFIRMED")
|
|
121
|
+
return;
|
|
122
|
+
const fail = (message) => {
|
|
123
|
+
context.addIssue({ code: "custom", message });
|
|
124
|
+
};
|
|
125
|
+
if (evidence.observation_method === "DOWNSTREAM_ACK")
|
|
126
|
+
fail("EFFECT_METHOD_INSUFFICIENT");
|
|
127
|
+
if (evidence.observer === null || evidence.observer.version === null) {
|
|
128
|
+
fail("EFFECT_OBSERVER_REQUIRED");
|
|
129
|
+
}
|
|
130
|
+
if (evidence.observed_effect_digest !== evidence.expected_effect_digest) {
|
|
131
|
+
fail("EFFECT_OBSERVED_DIGEST_MISMATCH");
|
|
132
|
+
}
|
|
133
|
+
if (evidence.observed_at === null)
|
|
134
|
+
fail("EFFECT_OBSERVED_AT_REQUIRED");
|
|
135
|
+
if (evidence.evidence_digest === null && evidence.evidence_reference === null) {
|
|
136
|
+
fail("EFFECT_EVIDENCE_ARTIFACT_REQUIRED");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
73
139
|
const ManagedEscalationRequestSchema = z.strictObject({
|
|
74
140
|
mode: z.literal("MANAGED"),
|
|
75
141
|
approver: z
|
|
@@ -112,8 +178,36 @@ const FinalizeRequestSchema = z.strictObject({
|
|
|
112
178
|
outcome: z.enum(["COMMITTED", "FAILED", "INDETERMINATE"]),
|
|
113
179
|
commit_correlation_id: boundedId,
|
|
114
180
|
downstream_evidence: z.record(z.string(), z.unknown()).optional(),
|
|
181
|
+
// Deliberately unvalidated here: the hosted route answers malformed effect
|
|
182
|
+
// evidence with a 409 and a reason code, not with a 400 request rejection,
|
|
183
|
+
// so the shape is checked where that answer is produced.
|
|
184
|
+
effect_evidence: z.unknown().optional(),
|
|
185
|
+
// The provider's receipt: only its shape is a request error. What it says
|
|
186
|
+
// is verified and recorded, never a reason to refuse the finalization.
|
|
187
|
+
effect_receipt: z.string().min(1).max(20_000).regex(COMPACT_JWS).optional(),
|
|
188
|
+
});
|
|
189
|
+
/** `ExecutionProviderKeyRegistrationRequest`: the public half of an Ed25519 key. */
|
|
190
|
+
const ProviderKeyRegistrationSchema = z.strictObject({
|
|
191
|
+
org_id: z.uuid().optional(),
|
|
192
|
+
kid: boundedId,
|
|
193
|
+
issuer: z.string().trim().min(1).max(500),
|
|
194
|
+
algorithm: z.literal("EdDSA"),
|
|
195
|
+
public_jwk: z.strictObject({
|
|
196
|
+
kty: z.literal("OKP"),
|
|
197
|
+
crv: z.literal("Ed25519"),
|
|
198
|
+
x: z.string().regex(/^[\w-]{43}$/),
|
|
199
|
+
kid: z.string().optional(),
|
|
200
|
+
alg: z.literal("EdDSA").optional(),
|
|
201
|
+
use: z.literal("sig").optional(),
|
|
202
|
+
}),
|
|
203
|
+
label: z.string().max(200).optional(),
|
|
115
204
|
});
|
|
116
205
|
const BINDING_KEYS = Object.keys(IntentBindingSchema.shape);
|
|
206
|
+
function effectStatus(value) {
|
|
207
|
+
return value === "EFFECTED" || value === "REFUSED" || value === "INDETERMINATE" ? value : null;
|
|
208
|
+
}
|
|
209
|
+
/** The organization a provisioned synthetic workspace belongs to. */
|
|
210
|
+
export const LOCAL_AUTHORITY_PROVISIONAL_ORG_ID = "00000000-0000-4000-8000-000000000008";
|
|
117
211
|
/**
|
|
118
212
|
* Independent canonicalization: keys sorted by UTF-16 code unit, values encoded
|
|
119
213
|
* with JSON.stringify semantics. This deliberately does not import the
|
|
@@ -163,12 +257,25 @@ export class LocalAuthority {
|
|
|
163
257
|
grants = new Map();
|
|
164
258
|
/** Managed escalations keyed by their opaque identifier. */
|
|
165
259
|
escalations = new Map();
|
|
260
|
+
/** The verifying providers' receipt keys, by `kid`, as `/v1/execution/provider-keys` registers them. */
|
|
261
|
+
providerKeys = new Map();
|
|
166
262
|
apiKey;
|
|
167
263
|
presence;
|
|
168
264
|
legacyVerifyReceipt;
|
|
169
265
|
policy;
|
|
170
266
|
managedApproverId;
|
|
267
|
+
trustedEffectObserverIds;
|
|
171
268
|
clock;
|
|
269
|
+
attestationKey;
|
|
270
|
+
/** The `kid` the fixture's attestations name; a provider double looks it up in the JWKS. */
|
|
271
|
+
attestationKeyId = "synthetic-exec-grant-1";
|
|
272
|
+
/**
|
|
273
|
+
* A drill knob: when set, the next claim binds a digest over parameters
|
|
274
|
+
* other than the ones captured, which is what a tampered or mismatched
|
|
275
|
+
* authority looks like from the executor's side, and what BEAP-L3-BND-01
|
|
276
|
+
* says the executor must refuse.
|
|
277
|
+
*/
|
|
278
|
+
misbindNextPayloadDigest = false;
|
|
172
279
|
overrides = {
|
|
173
280
|
enforce: [],
|
|
174
281
|
status: [],
|
|
@@ -178,17 +285,174 @@ export class LocalAuthority {
|
|
|
178
285
|
managedByIntent = new Map();
|
|
179
286
|
nextManagedLifecycle = null;
|
|
180
287
|
decisions = 0;
|
|
288
|
+
/** Every decision minted, by dossier id, for `GET /v1/protocol/dossiers/{id}`. */
|
|
289
|
+
dossiers = new Map();
|
|
290
|
+
verificationLinks;
|
|
181
291
|
server = null;
|
|
182
292
|
port = 0;
|
|
183
293
|
constructor(options = {}) {
|
|
184
294
|
if (process.env.NODE_ENV === "production")
|
|
185
295
|
throw new Error("LOCAL_DOUBLE_FORBIDDEN");
|
|
186
296
|
this.apiKey = options.apiKey ?? LOCAL_AUTHORITY_API_KEY;
|
|
297
|
+
this.verificationLinks = options.verificationLinks === true;
|
|
187
298
|
this.presence = options.presence;
|
|
188
299
|
this.legacyVerifyReceipt = options.verifyReceipt;
|
|
189
300
|
this.policy = options.policy ?? defaultPolicy;
|
|
190
301
|
this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
|
|
302
|
+
this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
|
|
191
303
|
this.clock = options.clock ?? Date.now;
|
|
304
|
+
const keys = generateKeyPairSync("ed25519");
|
|
305
|
+
this.attestationKey = {
|
|
306
|
+
privateKey: keys.privateKey,
|
|
307
|
+
publicJwk: keys.publicKey.export({ format: "jwk" }),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
|
|
311
|
+
get jwks() {
|
|
312
|
+
return {
|
|
313
|
+
keys: [
|
|
314
|
+
{ ...this.attestationKey.publicJwk, kid: this.attestationKeyId, alg: "EdDSA", use: "sig" },
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* A compact JWS over the claim, signed the way Decionis signs one: EdDSA,
|
|
320
|
+
* the grant key, the attestation type in the protected header. Built with
|
|
321
|
+
* `node:crypto` alone so the fixture stays an independent implementation.
|
|
322
|
+
*/
|
|
323
|
+
attest(grant, claimValidatedAt, leaseExpiresAt) {
|
|
324
|
+
const encode = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
325
|
+
const now = Math.floor(this.clock() / 1_000);
|
|
326
|
+
const claims = {
|
|
327
|
+
iss: LOCAL_AUTHORITY_ISSUER,
|
|
328
|
+
sub: grant.jti,
|
|
329
|
+
org_id: grant.tenantId,
|
|
330
|
+
dossier_id: grant.dossierId,
|
|
331
|
+
decision_id: grant.decisionId,
|
|
332
|
+
binding: {
|
|
333
|
+
intent_hash: grant.intentHash,
|
|
334
|
+
execution_payload_digest: grant.payloadDigest,
|
|
335
|
+
execution_payload_canonicalization_profile: JCS_PROFILE,
|
|
336
|
+
...(grant.expectedEffectDigest === null
|
|
337
|
+
? {}
|
|
338
|
+
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
339
|
+
execution_nonce: grant.nonce,
|
|
340
|
+
execution_correlation_id: grant.correlationId ?? "",
|
|
341
|
+
},
|
|
342
|
+
claim_token_digest: `sha256:${createHash("sha256")
|
|
343
|
+
.update(grant.claimToken ?? "", "utf8")
|
|
344
|
+
.digest("hex")}`,
|
|
345
|
+
claim_validated_at: claimValidatedAt,
|
|
346
|
+
jti: randomUUID(),
|
|
347
|
+
iat: now,
|
|
348
|
+
nbf: now,
|
|
349
|
+
exp: Math.floor(Date.parse(leaseExpiresAt) / 1_000),
|
|
350
|
+
};
|
|
351
|
+
const header = encode({
|
|
352
|
+
alg: "EdDSA",
|
|
353
|
+
kid: this.attestationKeyId,
|
|
354
|
+
typ: CLAIM_ATTESTATION_TYPE,
|
|
355
|
+
});
|
|
356
|
+
const payload = encode(claims);
|
|
357
|
+
const signature = sign(null, Buffer.from(`${header}.${payload}`, "ascii"), this.attestationKey.privateKey);
|
|
358
|
+
return `${header}.${payload}.${signature.toString("base64url")}`;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Registers the public half of a provider's receipt key, as the hosted
|
|
362
|
+
* `POST /v1/execution/provider-keys` does; a test or a demo provider calls
|
|
363
|
+
* it directly rather than over HTTP.
|
|
364
|
+
*/
|
|
365
|
+
registerProviderKey(input) {
|
|
366
|
+
this.providerKeys.set(input.kid, {
|
|
367
|
+
kid: input.kid,
|
|
368
|
+
issuer: input.issuer,
|
|
369
|
+
publicKey: createPublicKey({ key: input.publicJwk, format: "jwk" }),
|
|
370
|
+
label: input.label ?? null,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Reads a receipt the way the hosted authority does: the protected header
|
|
375
|
+
* names an Ed25519 key the organisation registered; the signature verifies
|
|
376
|
+
* under it; the issuer is the one registered with the key and the audience
|
|
377
|
+
* is this authority; and the claims describe this grant and this claim,
|
|
378
|
+
* through `sub`, the decision and dossier ids and the digest of the claim
|
|
379
|
+
* token being finalized. Anything else is recorded with its code, and the
|
|
380
|
+
* finalization proceeds regardless.
|
|
381
|
+
*/
|
|
382
|
+
readEffectReceipt(token, grant, claimToken) {
|
|
383
|
+
const record = (code, partial = {}) => ({
|
|
384
|
+
token,
|
|
385
|
+
verified: code === "EFFECT_RECEIPT_VERIFIED",
|
|
386
|
+
verification_code: code,
|
|
387
|
+
provider_key_id: null,
|
|
388
|
+
issuer: null,
|
|
389
|
+
effect_status: null,
|
|
390
|
+
effect_digest: null,
|
|
391
|
+
effect_reference: null,
|
|
392
|
+
effected_at: null,
|
|
393
|
+
...partial,
|
|
394
|
+
});
|
|
395
|
+
const [encodedHeader = "", encodedPayload = "", encodedSignature = ""] = token.split(".");
|
|
396
|
+
let header;
|
|
397
|
+
let payload;
|
|
398
|
+
try {
|
|
399
|
+
header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8"));
|
|
400
|
+
payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return record("EFFECT_RECEIPT_MALFORMED");
|
|
404
|
+
}
|
|
405
|
+
if (header.alg !== "EdDSA" ||
|
|
406
|
+
header.typ !== EFFECT_RECEIPT_TYPE ||
|
|
407
|
+
typeof header.kid !== "string" ||
|
|
408
|
+
header.kid.length === 0) {
|
|
409
|
+
return record("EFFECT_RECEIPT_MALFORMED");
|
|
410
|
+
}
|
|
411
|
+
const key = this.providerKeys.get(header.kid);
|
|
412
|
+
if (key === undefined) {
|
|
413
|
+
return record("EFFECT_RECEIPT_KEY_UNKNOWN", { provider_key_id: header.kid });
|
|
414
|
+
}
|
|
415
|
+
const known = { provider_key_id: key.kid, issuer: key.issuer };
|
|
416
|
+
const signed = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii");
|
|
417
|
+
if (!verify(null, signed, key.publicKey, Buffer.from(encodedSignature, "base64url")) ||
|
|
418
|
+
payload.iss !== key.issuer ||
|
|
419
|
+
payload.aud !== LOCAL_AUTHORITY_ISSUER) {
|
|
420
|
+
return record("EFFECT_RECEIPT_SIGNATURE_INVALID", known);
|
|
421
|
+
}
|
|
422
|
+
const effect = typeof payload.effect === "object" && payload.effect !== null
|
|
423
|
+
? payload.effect
|
|
424
|
+
: null;
|
|
425
|
+
const status = effectStatus(effect?.status);
|
|
426
|
+
const digest = effect?.digest;
|
|
427
|
+
const effectedAt = effect?.effected_at;
|
|
428
|
+
if (typeof payload.sub !== "string" ||
|
|
429
|
+
typeof payload.decision_id !== "string" ||
|
|
430
|
+
typeof payload.dossier_id !== "string" ||
|
|
431
|
+
typeof payload.claim_token_digest !== "string" ||
|
|
432
|
+
typeof payload.jti !== "string" ||
|
|
433
|
+
typeof payload.iat !== "number" ||
|
|
434
|
+
status === null ||
|
|
435
|
+
(digest !== undefined && !sha256Digest.safeParse(digest).success) ||
|
|
436
|
+
typeof effectedAt !== "string" ||
|
|
437
|
+
Number.isNaN(Date.parse(effectedAt))) {
|
|
438
|
+
return record("EFFECT_RECEIPT_MALFORMED", known);
|
|
439
|
+
}
|
|
440
|
+
const described = {
|
|
441
|
+
...known,
|
|
442
|
+
effect_status: status,
|
|
443
|
+
effect_digest: typeof digest === "string" ? digest : null,
|
|
444
|
+
effect_reference: typeof effect?.reference === "string" ? effect.reference : null,
|
|
445
|
+
effected_at: new Date(effectedAt).toISOString(),
|
|
446
|
+
};
|
|
447
|
+
const claimTokenDigest = `sha256:${createHash("sha256").update(claimToken, "utf8").digest("hex")}`;
|
|
448
|
+
if (payload.sub !== grant.jti ||
|
|
449
|
+
payload.decision_id !== grant.decisionId ||
|
|
450
|
+
payload.dossier_id !== grant.dossierId ||
|
|
451
|
+
payload.claim_token_digest !== claimTokenDigest ||
|
|
452
|
+
(typeof payload.intent_hash === "string" && payload.intent_hash !== grant.intentHash)) {
|
|
453
|
+
return record("EFFECT_RECEIPT_BINDING_MISMATCH", described);
|
|
454
|
+
}
|
|
455
|
+
return record("EFFECT_RECEIPT_VERIFIED", described);
|
|
192
456
|
}
|
|
193
457
|
get baseUrl() {
|
|
194
458
|
return `${LOOPBACK_ORIGIN}:${this.port}`;
|
|
@@ -249,9 +513,38 @@ export class LocalAuthority {
|
|
|
249
513
|
record.body = truncate(raw);
|
|
250
514
|
return this.send(res, record, 400, { error: "REQUEST_MALFORMED" });
|
|
251
515
|
}
|
|
516
|
+
// The verification keys are public, as Decionis's are: a provider double
|
|
517
|
+
// fetches them with no credential to the authority at all.
|
|
518
|
+
if (req.method === "GET" && url.pathname === LOCAL_AUTHORITY_JWKS_PATH) {
|
|
519
|
+
return this.send(res, record, 200, this.jwks);
|
|
520
|
+
}
|
|
521
|
+
// The public lane: a workspace minted with no account, as the hosted
|
|
522
|
+
// authority mints one, with this double's own key as the raw key.
|
|
523
|
+
if (req.method === "POST" && url.pathname === "/v1/public/agents/provision") {
|
|
524
|
+
return this.send(res, record, 201, {
|
|
525
|
+
org_id: LOCAL_AUTHORITY_PROVISIONAL_ORG_ID,
|
|
526
|
+
raw_key: this.apiKey,
|
|
527
|
+
provisional: true,
|
|
528
|
+
limits: {
|
|
529
|
+
requests_per_minute: 10,
|
|
530
|
+
workspaces_per_network_per_day: 5,
|
|
531
|
+
governed_decisions_per_month: 50,
|
|
532
|
+
},
|
|
533
|
+
claim: { note: "synthetic workspace on loopback; there is nothing to claim" },
|
|
534
|
+
next: {},
|
|
535
|
+
});
|
|
536
|
+
}
|
|
252
537
|
if (req.headers.authorization !== `Bearer ${this.apiKey}`) {
|
|
253
538
|
return this.send(res, record, 401, { error: "UNAUTHORIZED" });
|
|
254
539
|
}
|
|
540
|
+
const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
|
|
541
|
+
if (req.method === "GET" && dossierLookup !== null) {
|
|
542
|
+
const minted = this.dossiers.get(decodeURIComponent(dossierLookup[1] ?? ""));
|
|
543
|
+
if (minted === undefined || url.searchParams.get("org_id") !== minted.request.tenant_id) {
|
|
544
|
+
return this.send(res, record, 404, { error: "DOSSIER_NOT_FOUND" });
|
|
545
|
+
}
|
|
546
|
+
return this.send(res, record, 200, this.dossierRecord(minted.request, minted.decision));
|
|
547
|
+
}
|
|
255
548
|
const escalationLookup = url.pathname.match(/^\/v1\/authority\/escalations\/([^/]+)$/);
|
|
256
549
|
if (req.method === "GET" && escalationLookup !== null) {
|
|
257
550
|
return this.respond(res, record, "status", this.managedStatus(decodeURIComponent(escalationLookup[1] ?? "")));
|
|
@@ -265,6 +558,8 @@ export class LocalAuthority {
|
|
|
265
558
|
return this.send(res, record, 415, { error: "UNSUPPORTED_MEDIA_TYPE" });
|
|
266
559
|
}
|
|
267
560
|
switch (url.pathname) {
|
|
561
|
+
case "/v1/execution/provider-keys":
|
|
562
|
+
return this.send(res, record, ...this.registerProviderKeyRoute(record));
|
|
268
563
|
case "/v1/authority/enforce-and-bind":
|
|
269
564
|
return this.respond(res, record, "enforce", this.enforceAndBind(record));
|
|
270
565
|
case "/v1/execution/claim-token":
|
|
@@ -276,6 +571,31 @@ export class LocalAuthority {
|
|
|
276
571
|
return this.send(res, record, 404, { error: "NOT_FOUND" });
|
|
277
572
|
}
|
|
278
573
|
}
|
|
574
|
+
/** `POST /v1/execution/provider-keys`: the public half only, or a 400 naming why. */
|
|
575
|
+
registerProviderKeyRoute(record) {
|
|
576
|
+
const parsed = ProviderKeyRegistrationSchema.safeParse(record.body);
|
|
577
|
+
if (!parsed.success)
|
|
578
|
+
return [400, { error: "INVALID_BODY" }];
|
|
579
|
+
const body = parsed.data;
|
|
580
|
+
this.registerProviderKey({
|
|
581
|
+
kid: body.kid,
|
|
582
|
+
issuer: body.issuer,
|
|
583
|
+
publicJwk: { kty: body.public_jwk.kty, crv: body.public_jwk.crv, x: body.public_jwk.x },
|
|
584
|
+
...(body.label === undefined ? {} : { label: body.label }),
|
|
585
|
+
});
|
|
586
|
+
return [
|
|
587
|
+
201,
|
|
588
|
+
{
|
|
589
|
+
service: "decionis",
|
|
590
|
+
kid: body.kid,
|
|
591
|
+
issuer: body.issuer,
|
|
592
|
+
algorithm: "EdDSA",
|
|
593
|
+
label: body.label ?? null,
|
|
594
|
+
created_at: new Date(this.clock()).toISOString(),
|
|
595
|
+
revoked_at: null,
|
|
596
|
+
},
|
|
597
|
+
];
|
|
598
|
+
}
|
|
279
599
|
enforceAndBind(record) {
|
|
280
600
|
const parsed = AuthorityRequestSchema.safeParse(record.body);
|
|
281
601
|
if (!parsed.success)
|
|
@@ -293,12 +613,78 @@ export class LocalAuthority {
|
|
|
293
613
|
this.decisions += 1;
|
|
294
614
|
const decision = this.baseDecision(request, evaluation.status, [evaluation.reasonCode]);
|
|
295
615
|
if (evaluation.status === "ESCALATE" && request.escalation?.mode === "MANAGED") {
|
|
296
|
-
return { status: 200, body: this.openManaged(request, decision) };
|
|
616
|
+
return { status: 200, body: this.remember(request, this.openManaged(request, decision)) };
|
|
297
617
|
}
|
|
298
618
|
if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
|
|
299
|
-
return { status: 200, body: decision };
|
|
619
|
+
return { status: 200, body: this.remember(request, decision) };
|
|
300
620
|
}
|
|
301
|
-
return {
|
|
621
|
+
return {
|
|
622
|
+
status: 200,
|
|
623
|
+
body: this.remember(request, this.grantDecision(request, decision, evaluation.approval)),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** Keeps a minted decision for its dossier route, with the envelope when links are on. */
|
|
627
|
+
remember(request, decision) {
|
|
628
|
+
const dossierId = decision["dossier_id"];
|
|
629
|
+
if (typeof dossierId !== "string")
|
|
630
|
+
return decision;
|
|
631
|
+
const linked = this.verificationLinks
|
|
632
|
+
? {
|
|
633
|
+
...decision,
|
|
634
|
+
verification: {
|
|
635
|
+
verification_page_url: `${this.baseUrl}/verify/${dossierId}?sig=synthetic`,
|
|
636
|
+
verification_url: `${this.baseUrl}/v1/public/decision-dossiers/${dossierId}/verify?sig=synthetic`,
|
|
637
|
+
link_expires_at: new Date(this.clock() + 24 * 60 * 60 * 1_000).toISOString(),
|
|
638
|
+
signature_scheme: "synthetic",
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
: decision;
|
|
642
|
+
this.dossiers.set(dossierId, { request, decision: linked });
|
|
643
|
+
return linked;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The persisted record, shaped as `GET /v1/protocol/dossiers/{id}` returns
|
|
647
|
+
* it: a payload with the routing decision, the inputs and a proof bundle
|
|
648
|
+
* naming this double's key. The artifacts are not signed; the record says
|
|
649
|
+
* so in its issuer tier, and nothing here claims otherwise.
|
|
650
|
+
*/
|
|
651
|
+
dossierRecord(request, decision) {
|
|
652
|
+
const generatedAt = new Date(this.clock()).toISOString();
|
|
653
|
+
return {
|
|
654
|
+
service: "synthetic-authority",
|
|
655
|
+
protocol_version: "synthetic",
|
|
656
|
+
dossier: {
|
|
657
|
+
dossier_payload: {
|
|
658
|
+
schema_version: "decionis.decision_dossier/2.0",
|
|
659
|
+
dossier_id: decision["dossier_id"],
|
|
660
|
+
generated_at: generatedAt,
|
|
661
|
+
routing_decision: {
|
|
662
|
+
decision_id: decision["decision_id"],
|
|
663
|
+
outcome: decision["status"],
|
|
664
|
+
authority: decision["authority_classification"],
|
|
665
|
+
policy_version: decision["policy_version"],
|
|
666
|
+
reason_codes: decision["reason_codes"],
|
|
667
|
+
},
|
|
668
|
+
inputs_snapshot: {
|
|
669
|
+
tenant_id: request.tenant_id,
|
|
670
|
+
actor_id: request.actor.id,
|
|
671
|
+
action: request.action.type,
|
|
672
|
+
target: request.action.resource,
|
|
673
|
+
},
|
|
674
|
+
portable_artifact: { issuer_context: { tier: "synthetic_loopback" } },
|
|
675
|
+
integrity: {
|
|
676
|
+
proof_bundle: {
|
|
677
|
+
bundle_type: "decionis.decision_dossier.proof_bundle",
|
|
678
|
+
version: "2.0",
|
|
679
|
+
issued_at: generatedAt,
|
|
680
|
+
algorithm: "Ed25519",
|
|
681
|
+
key_id: this.attestationKeyId,
|
|
682
|
+
artifacts: [],
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
};
|
|
302
688
|
}
|
|
303
689
|
/** Policy plus evidence handling: a valid receipt turns an escalation into an allow. */
|
|
304
690
|
evaluate(request) {
|
|
@@ -380,9 +766,12 @@ export class LocalAuthority {
|
|
|
380
766
|
expiresAt,
|
|
381
767
|
nonce: randomBytes(32).toString("base64url"),
|
|
382
768
|
bindingDigest,
|
|
769
|
+
payloadDigest: hashBinding(request.action.parameters),
|
|
770
|
+
expectedEffectDigest: request.expected_effect_digest ?? null,
|
|
383
771
|
receiptDossierId: approval?.receiptDossierId ?? null,
|
|
384
772
|
claimed: false,
|
|
385
773
|
claimToken: null,
|
|
774
|
+
receipt: null,
|
|
386
775
|
correlationId: null,
|
|
387
776
|
consumedBy: null,
|
|
388
777
|
finalized: null,
|
|
@@ -648,6 +1037,13 @@ export class LocalAuthority {
|
|
|
648
1037
|
return rejected("GRANT_EXPIRED");
|
|
649
1038
|
if (grant.intentHash !== parsed.data.intent_hash)
|
|
650
1039
|
return rejected("GRANT_BINDING_MISMATCH");
|
|
1040
|
+
// Unreachable while the commitment stays inside the intent hash — a
|
|
1041
|
+
// divergence is already an `INTENT_HASH_MISMATCH` from the independent
|
|
1042
|
+
// re-hash above. Kept because the hosted route checks the committed value
|
|
1043
|
+
// itself, and a double that only checked the hash would stop proving that.
|
|
1044
|
+
if ((intent.data.expected_effect_digest ?? null) !== grant.expectedEffectDigest) {
|
|
1045
|
+
return rejected("GRANT_BINDING_MISMATCH");
|
|
1046
|
+
}
|
|
651
1047
|
if (grant.receiptDossierId !== null &&
|
|
652
1048
|
parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
|
|
653
1049
|
return rejected("PRESENCE_APPROVAL_STALE");
|
|
@@ -658,6 +1054,15 @@ export class LocalAuthority {
|
|
|
658
1054
|
grant.claimToken = randomBytes(32).toString("base64url");
|
|
659
1055
|
grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
|
|
660
1056
|
grant.consumedBy = parsed.data.consumed_by ?? null;
|
|
1057
|
+
const claimValidatedAt = new Date(this.clock()).toISOString();
|
|
1058
|
+
const leaseExpiresAt = new Date(this.clock() + CLAIM_LEASE_MS).toISOString();
|
|
1059
|
+
// The drill knob: an authority that bound a digest over other parameters.
|
|
1060
|
+
// It is consumed by one claim, so the executor's refusal is the one
|
|
1061
|
+
// observation and the next proposal sees an honest authority again.
|
|
1062
|
+
const payloadDigest = this.misbindNextPayloadDigest
|
|
1063
|
+
? hashBinding({ ...intent.data.action.parameters, synthetic_tamper: true })
|
|
1064
|
+
: grant.payloadDigest;
|
|
1065
|
+
this.misbindNextPayloadDigest = false;
|
|
661
1066
|
return {
|
|
662
1067
|
status: 200,
|
|
663
1068
|
body: {
|
|
@@ -667,7 +1072,7 @@ export class LocalAuthority {
|
|
|
667
1072
|
verdict: "ALLOW",
|
|
668
1073
|
reason_codes: [],
|
|
669
1074
|
claims: {
|
|
670
|
-
iss:
|
|
1075
|
+
iss: LOCAL_AUTHORITY_ISSUER,
|
|
671
1076
|
sub: grant.actorId,
|
|
672
1077
|
aud: grant.audience,
|
|
673
1078
|
org_id: grant.tenantId,
|
|
@@ -680,8 +1085,13 @@ export class LocalAuthority {
|
|
|
680
1085
|
binding: {
|
|
681
1086
|
intent_hash: grant.intentHash,
|
|
682
1087
|
execution_binding_digest: grant.bindingDigest,
|
|
1088
|
+
execution_payload_digest: payloadDigest,
|
|
1089
|
+
execution_payload_canonicalization_profile: JCS_PROFILE,
|
|
683
1090
|
execution_nonce: grant.nonce,
|
|
684
1091
|
execution_correlation_id: grant.correlationId,
|
|
1092
|
+
...(grant.expectedEffectDigest === null
|
|
1093
|
+
? {}
|
|
1094
|
+
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
685
1095
|
},
|
|
686
1096
|
jti: grant.jti,
|
|
687
1097
|
iat: grant.issuedAt,
|
|
@@ -689,7 +1099,9 @@ export class LocalAuthority {
|
|
|
689
1099
|
exp: grant.expiresAt,
|
|
690
1100
|
},
|
|
691
1101
|
claim_token: grant.claimToken,
|
|
692
|
-
|
|
1102
|
+
claim_validated_at: claimValidatedAt,
|
|
1103
|
+
claim_lease_expires_at: leaseExpiresAt,
|
|
1104
|
+
claim_attestation: this.attest({ ...grant, payloadDigest }, claimValidatedAt, leaseExpiresAt),
|
|
693
1105
|
evidence: { nonce_claim_state: "CLAIMED", commit_correlation_id: grant.correlationId },
|
|
694
1106
|
},
|
|
695
1107
|
};
|
|
@@ -713,18 +1125,109 @@ export class LocalAuthority {
|
|
|
713
1125
|
}
|
|
714
1126
|
if (grant.finalized !== null)
|
|
715
1127
|
return rejected("NONCE_REPLAY_DETECTED");
|
|
1128
|
+
// The receipt is read first and recorded whatever it says; when the grant
|
|
1129
|
+
// named the effect it expected and the executor supplied no observation of
|
|
1130
|
+
// its own, a verified receipt with a digest stands in as SIGNED_RECEIPT
|
|
1131
|
+
// evidence by the provider's key, exactly as the hosted route reads it.
|
|
1132
|
+
const receipt = parsed.data.effect_receipt === undefined
|
|
1133
|
+
? null
|
|
1134
|
+
: this.readEffectReceipt(parsed.data.effect_receipt, grant, parsed.data.claim_token);
|
|
1135
|
+
let supplied = parsed.data.effect_evidence;
|
|
1136
|
+
if (supplied === undefined &&
|
|
1137
|
+
receipt?.verified === true &&
|
|
1138
|
+
grant.expectedEffectDigest !== null &&
|
|
1139
|
+
receipt.effect_digest !== null) {
|
|
1140
|
+
supplied = {
|
|
1141
|
+
version: "1.0",
|
|
1142
|
+
status: receipt.effect_status === "EFFECTED" &&
|
|
1143
|
+
receipt.effect_digest === grant.expectedEffectDigest &&
|
|
1144
|
+
parsed.data.outcome === "COMMITTED"
|
|
1145
|
+
? "CONFIRMED"
|
|
1146
|
+
: "UNCONFIRMED",
|
|
1147
|
+
observation_method: "SIGNED_RECEIPT",
|
|
1148
|
+
observer: { id: receipt.provider_key_id, version: EFFECT_RECEIPT_TYPE },
|
|
1149
|
+
expected_effect_digest: grant.expectedEffectDigest,
|
|
1150
|
+
observed_effect_digest: receipt.effect_digest,
|
|
1151
|
+
observed_at: receipt.effected_at,
|
|
1152
|
+
evidence_digest: `sha256:${createHash("sha256").update(receipt.token, "utf8").digest("hex")}`,
|
|
1153
|
+
evidence_reference: receipt.effect_reference,
|
|
1154
|
+
execution_correlation_id: parsed.data.commit_correlation_id,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
// The hosted authority refuses the whole finalization for evidence it
|
|
1158
|
+
// cannot bind, in exactly this order, before the commit transition.
|
|
1159
|
+
let evidence;
|
|
1160
|
+
if (supplied !== undefined) {
|
|
1161
|
+
const parsedEvidence = EffectEvidenceSchema.safeParse(supplied);
|
|
1162
|
+
if (!parsedEvidence.success)
|
|
1163
|
+
return rejected(malformedEffectEvidenceReason(supplied));
|
|
1164
|
+
evidence = parsedEvidence.data;
|
|
1165
|
+
if (evidence.execution_correlation_id !== parsed.data.commit_correlation_id) {
|
|
1166
|
+
return rejected("EXECUTION_CORRELATION_MISMATCH");
|
|
1167
|
+
}
|
|
1168
|
+
if (grant.expectedEffectDigest === null) {
|
|
1169
|
+
return rejected("EFFECT_EXPECTED_BINDING_UNAVAILABLE");
|
|
1170
|
+
}
|
|
1171
|
+
if (evidence.expected_effect_digest !== grant.expectedEffectDigest) {
|
|
1172
|
+
return rejected("EFFECT_EXPECTED_DIGEST_MISMATCH");
|
|
1173
|
+
}
|
|
1174
|
+
if (evidence.status === "CONFIRMED" && parsed.data.outcome !== "COMMITTED") {
|
|
1175
|
+
return rejected("EFFECT_EVIDENCE_OUTCOME_MISMATCH");
|
|
1176
|
+
}
|
|
1177
|
+
if (evidence.status === "CONFIRMED") {
|
|
1178
|
+
// A verified receipt's key is an observer for this finalization only.
|
|
1179
|
+
const trusted = [
|
|
1180
|
+
...this.effectObserverIds(),
|
|
1181
|
+
...(receipt?.verified === true && receipt.provider_key_id !== null
|
|
1182
|
+
? [receipt.provider_key_id]
|
|
1183
|
+
: []),
|
|
1184
|
+
];
|
|
1185
|
+
if (trusted.length === 0) {
|
|
1186
|
+
return rejected("EFFECT_OBSERVER_PROVENANCE_UNAVAILABLE");
|
|
1187
|
+
}
|
|
1188
|
+
if (evidence.observer === null || !trusted.includes(evidence.observer.id)) {
|
|
1189
|
+
return rejected("EFFECT_OBSERVER_PROVENANCE_MISMATCH");
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
716
1193
|
grant.finalized = parsed.data.outcome;
|
|
1194
|
+
grant.receipt = receipt;
|
|
1195
|
+
// Byte-for-byte the hosted success body: the commit's own decision-chain
|
|
1196
|
+
// evidence is queued, not yet recorded, so a successful finalization still
|
|
1197
|
+
// carries `COMMIT_EVIDENCE_PENDING`.
|
|
717
1198
|
return {
|
|
718
1199
|
status: 200,
|
|
719
1200
|
body: {
|
|
720
1201
|
finalized: true,
|
|
721
1202
|
outcome: parsed.data.outcome,
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1203
|
+
evidence_durably_queued: true,
|
|
1204
|
+
decision_chain_evidence_recorded: false,
|
|
1205
|
+
evidence_recorded: false,
|
|
1206
|
+
effect_evidence_recorded: evidence !== undefined,
|
|
1207
|
+
effect_confirmation: evidence?.status === "CONFIRMED" ? "CONFIRMED" : "UNCONFIRMED",
|
|
1208
|
+
...(receipt === null
|
|
1209
|
+
? {}
|
|
1210
|
+
: {
|
|
1211
|
+
effect_receipt: {
|
|
1212
|
+
verified: receipt.verified,
|
|
1213
|
+
verification_code: receipt.verification_code,
|
|
1214
|
+
provider_key_id: receipt.provider_key_id,
|
|
1215
|
+
recorded: true,
|
|
1216
|
+
},
|
|
1217
|
+
}),
|
|
1218
|
+
reason_codes: ["COMMIT_EVIDENCE_PENDING"],
|
|
725
1219
|
},
|
|
726
1220
|
};
|
|
727
1221
|
}
|
|
1222
|
+
/**
|
|
1223
|
+
* The hosted route never trusts the allowlist alone: it returns at most the
|
|
1224
|
+
* caller's own authenticated API-key identity, so a confirmed observation
|
|
1225
|
+
* must name the identity that presented the credential. This double has one
|
|
1226
|
+
* credential, so that credential is its identity.
|
|
1227
|
+
*/
|
|
1228
|
+
effectObserverIds() {
|
|
1229
|
+
return this.trustedEffectObserverIds.includes(this.apiKey) ? [this.apiKey] : [];
|
|
1230
|
+
}
|
|
728
1231
|
/** Recomputes the hash independently and applies the contract's time rules. */
|
|
729
1232
|
assertBinding(request, record) {
|
|
730
1233
|
const recomputed = hashBinding(bindingOf(request));
|
|
@@ -789,6 +1292,25 @@ export class LocalAuthority {
|
|
|
789
1292
|
status === "FAILED");
|
|
790
1293
|
}
|
|
791
1294
|
}
|
|
1295
|
+
/**
|
|
1296
|
+
* The hosted route maps an unparseable observation to a 409 reason code rather
|
|
1297
|
+
* than a request rejection, and singles out a `CONFIRMED` observation whose
|
|
1298
|
+
* observer version is missing, because that is a provenance failure rather than
|
|
1299
|
+
* a malformed body.
|
|
1300
|
+
*/
|
|
1301
|
+
function malformedEffectEvidenceReason(supplied) {
|
|
1302
|
+
const candidate = asRecord(supplied);
|
|
1303
|
+
const observer = asRecord(candidate.observer);
|
|
1304
|
+
if (candidate.version === "1.0" &&
|
|
1305
|
+
candidate.status === "CONFIRMED" &&
|
|
1306
|
+
(typeof observer.version !== "string" || observer.version.trim().length === 0)) {
|
|
1307
|
+
return "EFFECT_OBSERVER_PROVENANCE_UNAVAILABLE";
|
|
1308
|
+
}
|
|
1309
|
+
return "EFFECT_EVIDENCE_INVALID";
|
|
1310
|
+
}
|
|
1311
|
+
function asRecord(value) {
|
|
1312
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1313
|
+
}
|
|
792
1314
|
function headerString(value) {
|
|
793
1315
|
if (typeof value === "string")
|
|
794
1316
|
return value;
|