@capaxle/runtime 0.1.0-alpha.1
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/LICENSE +202 -0
- package/README.md +5 -0
- package/dist/authorization.d.ts +12 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +23 -0
- package/dist/authorization.js.map +1 -0
- package/dist/base64url.d.ts +4 -0
- package/dist/base64url.d.ts.map +1 -0
- package/dist/base64url.js +11 -0
- package/dist/base64url.js.map +1 -0
- package/dist/confirmation-types.d.ts +255 -0
- package/dist/confirmation-types.d.ts.map +1 -0
- package/dist/confirmation-types.js +2 -0
- package/dist/confirmation-types.js.map +1 -0
- package/dist/confirmation.d.ts +6 -0
- package/dist/confirmation.d.ts.map +1 -0
- package/dist/confirmation.js +1205 -0
- package/dist/confirmation.js.map +1 -0
- package/dist/idempotency-types.d.ts +130 -0
- package/dist/idempotency-types.d.ts.map +1 -0
- package/dist/idempotency-types.js +2 -0
- package/dist/idempotency-types.js.map +1 -0
- package/dist/idempotency.d.ts +4 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +798 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ingress.d.ts +33 -0
- package/dist/ingress.d.ts.map +1 -0
- package/dist/ingress.js +325 -0
- package/dist/ingress.js.map +1 -0
- package/dist/kernel.d.ts +5 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +2872 -0
- package/dist/kernel.js.map +1 -0
- package/dist/principal.d.ts +7 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js +130 -0
- package/dist/principal.js.map +1 -0
- package/dist/rate-limit.d.ts +12 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +82 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redaction.d.ts +19 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +338 -0
- package/dist/redaction.js.map +1 -0
- package/dist/registry.d.ts +23 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +188 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +401 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
import { RuntimeConfigurationError, ownData } from "./registry.js";
|
|
3
|
+
import { compileBearerDescriptors, } from "./ingress.js";
|
|
4
|
+
import { decodeCanonicalBase64Url256 } from "./base64url.js";
|
|
5
|
+
import { jcs } from "@capaxle/ir";
|
|
6
|
+
const MAX_TIME = 253402300799999;
|
|
7
|
+
const actionKeys = [
|
|
8
|
+
"irHash",
|
|
9
|
+
"capabilityId",
|
|
10
|
+
"version",
|
|
11
|
+
"impact",
|
|
12
|
+
"summary",
|
|
13
|
+
"requesterFingerprint",
|
|
14
|
+
"tenantFingerprint",
|
|
15
|
+
"fingerprintGenerationId",
|
|
16
|
+
"kernelInvocationId",
|
|
17
|
+
"input",
|
|
18
|
+
"idempotencyKey",
|
|
19
|
+
"correlationId",
|
|
20
|
+
"sourceChain",
|
|
21
|
+
];
|
|
22
|
+
class Failure extends Error {
|
|
23
|
+
reason;
|
|
24
|
+
constructor(reason) {
|
|
25
|
+
super("Confirmation dependency unavailable.");
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
class Invalid extends Error {
|
|
30
|
+
audited;
|
|
31
|
+
constructor(audited = false) {
|
|
32
|
+
super("Confirmation evidence is invalid.");
|
|
33
|
+
this.audited = audited;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class Interrupted extends Error {
|
|
37
|
+
code;
|
|
38
|
+
constructor(code) {
|
|
39
|
+
super("Confirmation interrupted.");
|
|
40
|
+
this.code = code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const unavailable = (retryable = true) => ({
|
|
44
|
+
ok: false,
|
|
45
|
+
error: {
|
|
46
|
+
code: "CAP_DEPENDENCY_UNAVAILABLE",
|
|
47
|
+
status: "unavailable",
|
|
48
|
+
message: "Confirmation dependency unavailable.",
|
|
49
|
+
retryable,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const invalid = () => ({
|
|
53
|
+
ok: false,
|
|
54
|
+
error: {
|
|
55
|
+
code: "CAP_CONFIRMATION_INVALID",
|
|
56
|
+
status: "failed_precondition",
|
|
57
|
+
message: "Confirmation evidence is invalid.",
|
|
58
|
+
retryable: false,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
function digest(value) {
|
|
62
|
+
try {
|
|
63
|
+
return decodeCanonicalBase64Url256(value);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Invalid();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function persisted(value) {
|
|
70
|
+
try {
|
|
71
|
+
return digest(value);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Failure("noncanonical_stored_digest");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const hash = (value) => createHash("sha256").update(jcs(value)).digest("base64url");
|
|
78
|
+
function keyed(key, domain, value) {
|
|
79
|
+
const subkey = createHmac("sha256", key)
|
|
80
|
+
.update(`capaxle.confirmation.v1.${domain}`)
|
|
81
|
+
.digest();
|
|
82
|
+
return createHmac("sha256", subkey).update(value, "utf8").digest("base64url");
|
|
83
|
+
}
|
|
84
|
+
function generation(state, id, active = false, permitTerminalTransition = false) {
|
|
85
|
+
let g;
|
|
86
|
+
let data;
|
|
87
|
+
try {
|
|
88
|
+
const entries = ownData(state.generations);
|
|
89
|
+
g = entries[id];
|
|
90
|
+
data = ownData(g, [
|
|
91
|
+
"id",
|
|
92
|
+
"key",
|
|
93
|
+
"state",
|
|
94
|
+
"retired",
|
|
95
|
+
"revoked",
|
|
96
|
+
"quarantined",
|
|
97
|
+
"ownerId",
|
|
98
|
+
"pendingTransition",
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Failure("generation_integrity");
|
|
103
|
+
}
|
|
104
|
+
if (data.id !== id ||
|
|
105
|
+
typeof data.retired !== "boolean" ||
|
|
106
|
+
typeof data.revoked !== "boolean" ||
|
|
107
|
+
typeof data.quarantined !== "boolean" ||
|
|
108
|
+
!(data.key instanceof Uint8Array) ||
|
|
109
|
+
data.key.length !== 32 ||
|
|
110
|
+
!Uint8Array.prototype.some.call(data.key, (v) => v !== 0) ||
|
|
111
|
+
!["ACTIVE", "RETIRED", "REVOKED"].includes(data.state) ||
|
|
112
|
+
data.retired !== (data.state === "RETIRED") ||
|
|
113
|
+
data.revoked !== (data.state === "REVOKED") ||
|
|
114
|
+
(Object.hasOwn(data, "pendingTransition") &&
|
|
115
|
+
!["RETIRED", "REVOKED"].includes(data.pendingTransition)))
|
|
116
|
+
throw new Failure("generation_integrity");
|
|
117
|
+
persisted(data.ownerId);
|
|
118
|
+
if (g.quarantined ||
|
|
119
|
+
g.revoked ||
|
|
120
|
+
(active && (g.state !== "ACTIVE" || id !== state.activeGeneration))) {
|
|
121
|
+
if (active)
|
|
122
|
+
throw new Failure("active_generation_unavailable");
|
|
123
|
+
if (!permitTerminalTransition)
|
|
124
|
+
throw new Invalid();
|
|
125
|
+
}
|
|
126
|
+
return g;
|
|
127
|
+
}
|
|
128
|
+
function safeSummary(value) {
|
|
129
|
+
if (typeof value !== "string" ||
|
|
130
|
+
value.length === 0 ||
|
|
131
|
+
Buffer.byteLength(value) > 512 ||
|
|
132
|
+
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(value))
|
|
133
|
+
return false;
|
|
134
|
+
for (let i = 0; i < value.length; i++) {
|
|
135
|
+
const c = value.charCodeAt(i);
|
|
136
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
137
|
+
const n = value.charCodeAt(++i);
|
|
138
|
+
if (!(n >= 0xdc00 && n <= 0xdfff))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
else if (c >= 0xdc00 && c <= 0xdfff)
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
function bearerGuardPointer(pointer, guard) {
|
|
147
|
+
return ((pointer !== "" && !/^\/(?:[^~]|~[01])*$/.test(pointer)) ||
|
|
148
|
+
guard.detects(pointer));
|
|
149
|
+
}
|
|
150
|
+
function summary(action, options, guard) {
|
|
151
|
+
if (!safeSummary(action.summary) || guard.detects(action.summary))
|
|
152
|
+
throw new Failure("invalid_canonical_summary");
|
|
153
|
+
if (!options.summaryRenderer)
|
|
154
|
+
return action.summary;
|
|
155
|
+
try {
|
|
156
|
+
const leaves = Object.create(null);
|
|
157
|
+
for (const pointer of options.summaryRenderer.pointers) {
|
|
158
|
+
if (bearerGuardPointer(pointer, guard))
|
|
159
|
+
continue;
|
|
160
|
+
let value = action.input;
|
|
161
|
+
for (const part of pointer === "" ? [] : pointer.slice(1).split("/")) {
|
|
162
|
+
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
163
|
+
if (!value || typeof value !== "object" || !Object.hasOwn(value, key)) {
|
|
164
|
+
value = undefined;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
value = value[key];
|
|
168
|
+
}
|
|
169
|
+
if (value === null ||
|
|
170
|
+
["string", "number", "boolean"].includes(typeof value))
|
|
171
|
+
if (typeof value !== "string" || !guard.detects(value))
|
|
172
|
+
leaves[pointer] = value;
|
|
173
|
+
}
|
|
174
|
+
const rendered = options.summaryRenderer.render(Object.freeze(leaves));
|
|
175
|
+
return safeSummary(rendered) && !guard.detects(rendered)
|
|
176
|
+
? rendered
|
|
177
|
+
: action.summary;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return action.summary;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Initializes data for a host's durable store; this is not a persistence implementation.
|
|
184
|
+
* Keys and generation owner IDs must be securely provisioned by the host. */
|
|
185
|
+
export function createConfirmationState(service, deployment, generations, activeGeneration) {
|
|
186
|
+
const state = {
|
|
187
|
+
service,
|
|
188
|
+
deployment,
|
|
189
|
+
timeFloor: 0,
|
|
190
|
+
unhealthy: false,
|
|
191
|
+
activeGeneration,
|
|
192
|
+
generations: Object.create(null),
|
|
193
|
+
records: Object.create(null),
|
|
194
|
+
challengeIndex: Object.create(null),
|
|
195
|
+
evidenceIndex: Object.create(null),
|
|
196
|
+
executions: Object.create(null),
|
|
197
|
+
randomReservations: Object.create(null),
|
|
198
|
+
pendingEvents: Object.create(null),
|
|
199
|
+
durableEvents: Object.create(null),
|
|
200
|
+
completionProofs: Object.create(null),
|
|
201
|
+
};
|
|
202
|
+
for (const g of generations) {
|
|
203
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(g.id) ||
|
|
204
|
+
Object.hasOwn(state.generations, g.id))
|
|
205
|
+
throw new RuntimeConfigurationError("CAP_CONFIRMATION_CONFIGURATION_INVALID");
|
|
206
|
+
persisted(g.ownerId);
|
|
207
|
+
if (Object.hasOwn(state.randomReservations, g.ownerId))
|
|
208
|
+
throw new RuntimeConfigurationError("CAP_CONFIRMATION_CONFIGURATION_INVALID");
|
|
209
|
+
state.generations[g.id] = { ...g, key: new Uint8Array(g.key) };
|
|
210
|
+
state.randomReservations[g.ownerId] = true;
|
|
211
|
+
}
|
|
212
|
+
generation(state, activeGeneration, true);
|
|
213
|
+
return state;
|
|
214
|
+
}
|
|
215
|
+
export function createConfirmationProvider(options) {
|
|
216
|
+
const { store } = options;
|
|
217
|
+
const bearerGuard = compileBearerDescriptors(options.bearerDescriptors);
|
|
218
|
+
if (!store ||
|
|
219
|
+
typeof store.atomic !== "function" ||
|
|
220
|
+
typeof store.commitJournal !== "function" ||
|
|
221
|
+
typeof store.incident !== "function" ||
|
|
222
|
+
!options.service ||
|
|
223
|
+
!options.deployment ||
|
|
224
|
+
!Number.isSafeInteger(options.ttlMs) ||
|
|
225
|
+
options.ttlMs <= 0 ||
|
|
226
|
+
options.ttlMs > 300000 ||
|
|
227
|
+
!Number.isSafeInteger(options.maxClockSkewMs) ||
|
|
228
|
+
options.maxClockSkewMs < 0 ||
|
|
229
|
+
options.maxClockSkewMs > 30000 ||
|
|
230
|
+
!options.approvalProvider ||
|
|
231
|
+
typeof options.approvalProvider.authenticate !== "function")
|
|
232
|
+
throw new RuntimeConfigurationError("CAP_CONFIRMATION_CONFIGURATION_INVALID");
|
|
233
|
+
const random = options.randomBytes ?? randomBytes;
|
|
234
|
+
const clock = options.clock ?? Date.now;
|
|
235
|
+
const receipts = new WeakSet();
|
|
236
|
+
const attempts = new WeakSet();
|
|
237
|
+
const pendingCompletions = new Map();
|
|
238
|
+
function checked(state) {
|
|
239
|
+
if (state.service !== options.service ||
|
|
240
|
+
state.deployment !== options.deployment ||
|
|
241
|
+
!Number.isSafeInteger(state.timeFloor) ||
|
|
242
|
+
state.timeFloor < 0 ||
|
|
243
|
+
state.timeFloor > MAX_TIME ||
|
|
244
|
+
!state.completionProofs ||
|
|
245
|
+
typeof state.completionProofs !== "object")
|
|
246
|
+
throw new Failure("store_integrity");
|
|
247
|
+
if (state.unhealthy &&
|
|
248
|
+
![
|
|
249
|
+
"journal_unavailable",
|
|
250
|
+
"store_unavailable",
|
|
251
|
+
"post_handler_persistence_unavailable",
|
|
252
|
+
].includes(state.unhealthyReason ?? ""))
|
|
253
|
+
throw new Failure("provider_unhealthy");
|
|
254
|
+
}
|
|
255
|
+
function unique(state) {
|
|
256
|
+
let value;
|
|
257
|
+
try {
|
|
258
|
+
value = random(32);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
throw new Failure("random_unavailable");
|
|
262
|
+
}
|
|
263
|
+
if (!(value instanceof Uint8Array) ||
|
|
264
|
+
value.length !== 32 ||
|
|
265
|
+
!value.some((v) => v !== 0))
|
|
266
|
+
throw new Failure("random_invalid");
|
|
267
|
+
const id = Buffer.from(value).toString("base64url");
|
|
268
|
+
if (Object.hasOwn(state.randomReservations, id))
|
|
269
|
+
throw new Failure("random_collision");
|
|
270
|
+
state.randomReservations[id] = true;
|
|
271
|
+
return id;
|
|
272
|
+
}
|
|
273
|
+
function now(state) {
|
|
274
|
+
let raw;
|
|
275
|
+
try {
|
|
276
|
+
raw = clock();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
throw new Failure("clock_unavailable");
|
|
280
|
+
}
|
|
281
|
+
if (!Number.isSafeInteger(raw) ||
|
|
282
|
+
raw < -8640000000000000 ||
|
|
283
|
+
raw > 8640000000000000)
|
|
284
|
+
throw new Failure("clock_invalid");
|
|
285
|
+
const effective = Math.max(raw, state.timeFloor);
|
|
286
|
+
if (effective > MAX_TIME)
|
|
287
|
+
throw new Failure("derived_time_out_of_range");
|
|
288
|
+
state.timeFloor = effective;
|
|
289
|
+
return effective;
|
|
290
|
+
}
|
|
291
|
+
function event(state, eventType, ownerType, ownerId, transitionOrdinal, occurredAt, context = {}) {
|
|
292
|
+
const eventId = `cape1.${hash({ service: options.service, deployment: options.deployment, ownerType, ownerId, eventType, transitionOrdinal })}`;
|
|
293
|
+
if (!state.durableEvents[eventId] && !state.pendingEvents[eventId])
|
|
294
|
+
state.pendingEvents[eventId] = {
|
|
295
|
+
eventId,
|
|
296
|
+
eventType,
|
|
297
|
+
protocolVersion: "1",
|
|
298
|
+
occurredAt,
|
|
299
|
+
stage: "confirmation",
|
|
300
|
+
outcome: eventType,
|
|
301
|
+
service: options.service,
|
|
302
|
+
deployment: options.deployment,
|
|
303
|
+
ownerType,
|
|
304
|
+
ownerId,
|
|
305
|
+
transitionOrdinal,
|
|
306
|
+
context,
|
|
307
|
+
};
|
|
308
|
+
return eventId;
|
|
309
|
+
}
|
|
310
|
+
function context(record) {
|
|
311
|
+
return {
|
|
312
|
+
confirmationRef: record.confirmationRef,
|
|
313
|
+
capabilityId: record.action.capabilityId,
|
|
314
|
+
version: record.action.version,
|
|
315
|
+
irHash: record.action.irHash,
|
|
316
|
+
impact: record.action.impact,
|
|
317
|
+
requesterFingerprint: record.action.requesterFingerprint,
|
|
318
|
+
tenantFingerprint: record.action.tenantFingerprint,
|
|
319
|
+
inputFingerprint: record.inputFingerprint,
|
|
320
|
+
idempotencyPresent: record.idempotencyFingerprint !== undefined,
|
|
321
|
+
...(record.idempotencyFingerprint
|
|
322
|
+
? { idempotencyFingerprint: record.idempotencyFingerprint }
|
|
323
|
+
: {}),
|
|
324
|
+
challengeHandleGeneration: record.challengeHandleGeneration,
|
|
325
|
+
bindingFingerprintGeneration: record.bindingFingerprintGeneration,
|
|
326
|
+
auditReferenceGeneration: record.auditReferenceGeneration,
|
|
327
|
+
...(record.evidenceHandleGeneration
|
|
328
|
+
? { evidenceHandleGeneration: record.evidenceHandleGeneration }
|
|
329
|
+
: {}),
|
|
330
|
+
state: record.state,
|
|
331
|
+
...(record.action.correlationId
|
|
332
|
+
? { correlationId: record.action.correlationId }
|
|
333
|
+
: {}),
|
|
334
|
+
...(record.action.sourceChain
|
|
335
|
+
? { sourceChain: record.action.sourceChain }
|
|
336
|
+
: {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function recordEvent(state, record, type, ordinal, time, extra = {}) {
|
|
340
|
+
return event(state, type, "confirmation_record", record.id, ordinal, time, {
|
|
341
|
+
...context(record),
|
|
342
|
+
...extra,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
async function fail(reason) {
|
|
346
|
+
try {
|
|
347
|
+
await store.atomic((s) => {
|
|
348
|
+
s.unhealthy = true;
|
|
349
|
+
if (!s.unhealthyReason ||
|
|
350
|
+
[
|
|
351
|
+
"journal_unavailable",
|
|
352
|
+
"store_unavailable",
|
|
353
|
+
"post_handler_persistence_unavailable",
|
|
354
|
+
].includes(s.unhealthyReason))
|
|
355
|
+
s.unhealthyReason = reason;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
/* independent incident path below */
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
await store.incident({
|
|
363
|
+
service: options.service,
|
|
364
|
+
deployment: options.deployment,
|
|
365
|
+
protocolVersion: "1",
|
|
366
|
+
eventType: "provider_failed",
|
|
367
|
+
stage: "confirmation",
|
|
368
|
+
outcome: "unavailable",
|
|
369
|
+
providerKind: "confirmation",
|
|
370
|
+
operation: "confirmation_operation",
|
|
371
|
+
reason,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
/* no recursive provider dependency */
|
|
376
|
+
}
|
|
377
|
+
return unavailable();
|
|
378
|
+
}
|
|
379
|
+
function checkControls(controls) {
|
|
380
|
+
if (controls?.refreshCancellation !== undefined) {
|
|
381
|
+
try {
|
|
382
|
+
if (typeof controls.refreshCancellation !== "function")
|
|
383
|
+
throw new Failure("cancellation_refresh_invalid");
|
|
384
|
+
controls.refreshCancellation();
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
throw new Failure("cancellation_refresh_unavailable");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (controls?.signal?.aborted)
|
|
391
|
+
throw new Interrupted("CAP_CANCELLED");
|
|
392
|
+
if (controls?.deadlineMs !== undefined) {
|
|
393
|
+
if (!Number.isSafeInteger(controls.deadlineMs))
|
|
394
|
+
throw new Failure("deadline_invalid");
|
|
395
|
+
if (Date.now() >= controls.deadlineMs)
|
|
396
|
+
throw new Interrupted("CAP_TIMEOUT");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function operation(fn, controls) {
|
|
400
|
+
try {
|
|
401
|
+
const outcome = await store.atomic((state) => {
|
|
402
|
+
checked(state);
|
|
403
|
+
const time = now(state);
|
|
404
|
+
checkControls(controls);
|
|
405
|
+
const invocationId = controls?.kernelInvocationId ?? unique(state);
|
|
406
|
+
if (controls?.kernelInvocationId &&
|
|
407
|
+
!state.randomReservations[controls.kernelInvocationId])
|
|
408
|
+
throw new Failure("invocation_owner_invalid");
|
|
409
|
+
try {
|
|
410
|
+
return { value: fn(state, time, invocationId) };
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
if (error instanceof Invalid) {
|
|
414
|
+
if (!error.audited)
|
|
415
|
+
event(state, "verification_rejected", "kernel_invocation", invocationId, 1, time, {
|
|
416
|
+
providerKind: "confirmation",
|
|
417
|
+
operation: "verification",
|
|
418
|
+
reason: "invalid_evidence",
|
|
419
|
+
});
|
|
420
|
+
return { invalid: true };
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}, controls);
|
|
425
|
+
checkControls(controls);
|
|
426
|
+
try {
|
|
427
|
+
await flush(controls);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
throw new Failure("journal_unavailable");
|
|
431
|
+
}
|
|
432
|
+
checkControls(controls);
|
|
433
|
+
if ("invalid" in outcome)
|
|
434
|
+
return invalid();
|
|
435
|
+
return { ok: true, ...outcome.value };
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
try {
|
|
439
|
+
checkControls(controls);
|
|
440
|
+
}
|
|
441
|
+
catch (interrupted) {
|
|
442
|
+
if (interrupted instanceof Interrupted ||
|
|
443
|
+
interrupted instanceof Failure)
|
|
444
|
+
error = interrupted;
|
|
445
|
+
}
|
|
446
|
+
if (error instanceof Invalid)
|
|
447
|
+
return invalid();
|
|
448
|
+
if (error instanceof Interrupted)
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
error: {
|
|
452
|
+
code: error.code,
|
|
453
|
+
status: "unavailable",
|
|
454
|
+
message: "Confirmation interrupted.",
|
|
455
|
+
retryable: false,
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
return fail(error instanceof Failure ? error.reason : "store_unavailable");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async function flush(controls) {
|
|
462
|
+
const events = await store.atomic((state) => Object.values(state.pendingEvents), controls);
|
|
463
|
+
if (!events.length)
|
|
464
|
+
return;
|
|
465
|
+
checkControls(controls);
|
|
466
|
+
await store.commitJournal(events, controls);
|
|
467
|
+
checkControls(controls);
|
|
468
|
+
await store.atomic((state) => {
|
|
469
|
+
if ([
|
|
470
|
+
"journal_unavailable",
|
|
471
|
+
"store_unavailable",
|
|
472
|
+
"post_handler_persistence_unavailable",
|
|
473
|
+
].includes(state.unhealthyReason ?? "")) {
|
|
474
|
+
state.unhealthy = false;
|
|
475
|
+
delete state.unhealthyReason;
|
|
476
|
+
}
|
|
477
|
+
for (const e of events) {
|
|
478
|
+
state.durableEvents[e.eventId] = true;
|
|
479
|
+
delete state.pendingEvents[e.eventId];
|
|
480
|
+
}
|
|
481
|
+
for (const g of Object.values(state.generations))
|
|
482
|
+
if (g.pendingTransition) {
|
|
483
|
+
const kind = g.pendingTransition === "REVOKED"
|
|
484
|
+
? "generation_revoked"
|
|
485
|
+
: "generation_retired";
|
|
486
|
+
const eid = `cape1.${hash({ service: options.service, deployment: options.deployment, ownerType: "key_generation", ownerId: g.ownerId, eventType: kind, transitionOrdinal: g.pendingTransition === "REVOKED" ? 2 : 1 })}`;
|
|
487
|
+
if (state.durableEvents[eid]) {
|
|
488
|
+
g.quarantined = false;
|
|
489
|
+
delete g.pendingTransition;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const x of Object.values(state.executions))
|
|
493
|
+
if (x.state === "COMPLETION_PENDING") {
|
|
494
|
+
const eid = `cape1.${hash({ service: options.service, deployment: options.deployment, ownerType: "execution_attempt", ownerId: x.id, eventType: "execution_audit_completed", transitionOrdinal: 2 })}`;
|
|
495
|
+
if (state.durableEvents[eid]) {
|
|
496
|
+
x.state = "COMPLETED";
|
|
497
|
+
const r = state.records[x.recordId];
|
|
498
|
+
if (r && x.completion)
|
|
499
|
+
state.completionProofs[x.id] = {
|
|
500
|
+
recordId: r.id,
|
|
501
|
+
confirmationRef: r.confirmationRef,
|
|
502
|
+
outcome: x.completion,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}, controls);
|
|
507
|
+
}
|
|
508
|
+
function handle(state, prefix, g) {
|
|
509
|
+
const nonce = unique(state);
|
|
510
|
+
const unsigned = `${prefix}.${g.id}.${nonce}`;
|
|
511
|
+
// The normative tag signs the unsigned ASCII prefix directly.
|
|
512
|
+
const tag = createHmac("sha256", g.key)
|
|
513
|
+
.update(unsigned, "ascii")
|
|
514
|
+
.digest("base64url");
|
|
515
|
+
const text = `${unsigned}.${tag}`;
|
|
516
|
+
return { text, nonce, lookup: keyed(g.key, `${prefix}-lookup`, text) };
|
|
517
|
+
}
|
|
518
|
+
function lookup(state, text, prefix) {
|
|
519
|
+
if (typeof text !== "string" || text.length > 180)
|
|
520
|
+
throw new Invalid();
|
|
521
|
+
const parts = text.split(".");
|
|
522
|
+
if (parts.length !== 4 ||
|
|
523
|
+
parts[0] !== prefix ||
|
|
524
|
+
!/^[A-Za-z0-9_-]{1,32}$/.test(parts[1]))
|
|
525
|
+
throw new Invalid();
|
|
526
|
+
digest(parts[2]);
|
|
527
|
+
const tag = digest(parts[3]);
|
|
528
|
+
if (!Object.hasOwn(state.generations, parts[1]))
|
|
529
|
+
throw new Invalid();
|
|
530
|
+
const g = generation(state, parts[1]);
|
|
531
|
+
const expected = createHmac("sha256", g.key)
|
|
532
|
+
.update(parts.slice(0, 3).join("."), "ascii")
|
|
533
|
+
.digest();
|
|
534
|
+
if (!timingSafeEqual(tag, expected))
|
|
535
|
+
throw new Invalid();
|
|
536
|
+
const key = keyed(g.key, `${prefix}-lookup`, text);
|
|
537
|
+
const index = prefix === "capc1" ? state.challengeIndex : state.evidenceIndex;
|
|
538
|
+
const id = index[key];
|
|
539
|
+
if (!id || !Object.hasOwn(state.records, id))
|
|
540
|
+
throw new Invalid();
|
|
541
|
+
const r = state.records[id];
|
|
542
|
+
if (![r.issuedAt, r.expiresAt, r.purgeAt].every((t) => Number.isSafeInteger(t) && t >= 0 && t <= MAX_TIME) ||
|
|
543
|
+
r.issuedAt >= r.expiresAt ||
|
|
544
|
+
r.expiresAt > r.purgeAt ||
|
|
545
|
+
![
|
|
546
|
+
"PENDING",
|
|
547
|
+
"APPROVED",
|
|
548
|
+
"CONSUMED",
|
|
549
|
+
"DENIED",
|
|
550
|
+
"EXPIRED",
|
|
551
|
+
"REVOKED",
|
|
552
|
+
].includes(r.state))
|
|
553
|
+
throw new Failure("record_integrity");
|
|
554
|
+
persisted(r.id);
|
|
555
|
+
persisted(r.nonce);
|
|
556
|
+
if (r.id !== id ||
|
|
557
|
+
!timingSafeEqual(persisted(prefix === "capc1" ? r.challengeLookup : r.evidenceLookup), digest(key)))
|
|
558
|
+
throw new Failure("lookup_integrity");
|
|
559
|
+
persisted(r.bindingDigest);
|
|
560
|
+
persisted(r.inputFingerprint);
|
|
561
|
+
if (!timingSafeEqual(persisted(r.summaryDigest), digest(hash(r.action.summary))))
|
|
562
|
+
throw new Failure("stored_summary_integrity");
|
|
563
|
+
persisted(r.action.requesterFingerprint);
|
|
564
|
+
persisted(r.action.tenantFingerprint);
|
|
565
|
+
persisted(r.challengeLookup);
|
|
566
|
+
if (r.evidenceLookup !== undefined)
|
|
567
|
+
persisted(r.evidenceLookup);
|
|
568
|
+
if (r.idempotencyFingerprint !== undefined)
|
|
569
|
+
persisted(r.idempotencyFingerprint);
|
|
570
|
+
for (const ref of [
|
|
571
|
+
r.challengeHandleGeneration,
|
|
572
|
+
r.bindingFingerprintGeneration,
|
|
573
|
+
r.auditReferenceGeneration,
|
|
574
|
+
...(r.evidenceHandleGeneration ? [r.evidenceHandleGeneration] : []),
|
|
575
|
+
])
|
|
576
|
+
generation(state, ref);
|
|
577
|
+
return r;
|
|
578
|
+
}
|
|
579
|
+
function expire(state, r, time) {
|
|
580
|
+
if (r.expiryEventId || r.state === "EXPIRED" || time >= r.expiresAt) {
|
|
581
|
+
if (["PENDING", "APPROVED"].includes(r.state)) {
|
|
582
|
+
r.state = "EXPIRED";
|
|
583
|
+
r.expiryEventId = recordEvent(state, r, "challenge_expired", 4, time, {
|
|
584
|
+
reason: "expired",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
throw new Invalid();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function identity(action) {
|
|
591
|
+
try {
|
|
592
|
+
decodeCanonicalBase64Url256(action.requesterFingerprint);
|
|
593
|
+
decodeCanonicalBase64Url256(action.tenantFingerprint);
|
|
594
|
+
return {
|
|
595
|
+
requesterFingerprint: action.requesterFingerprint,
|
|
596
|
+
tenantFingerprint: action.tenantFingerprint,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
throw new Failure("identity_invalid");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function validatedAction(action) {
|
|
604
|
+
try {
|
|
605
|
+
const data = ownData(action, actionKeys);
|
|
606
|
+
if (!Object.hasOwn(data, "fingerprintGenerationId") ||
|
|
607
|
+
typeof data.fingerprintGenerationId !== "string" ||
|
|
608
|
+
!/^[A-Za-z0-9_-]{1,32}$/.test(data.fingerprintGenerationId))
|
|
609
|
+
throw new Error("fingerprint_generation");
|
|
610
|
+
return data;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
throw new Failure("identity_generation_invalid");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function binding(action, r, key, generationId) {
|
|
617
|
+
if (action.fingerprintGenerationId !== generationId)
|
|
618
|
+
throw new Failure("identity_generation_mismatch");
|
|
619
|
+
const input = keyed(key, "input", jcs(action.input));
|
|
620
|
+
const idempotency = action.idempotencyKey === undefined
|
|
621
|
+
? undefined
|
|
622
|
+
: keyed(key, "idempotency", action.idempotencyKey);
|
|
623
|
+
const value = {
|
|
624
|
+
protocolVersion: "1",
|
|
625
|
+
service: options.service,
|
|
626
|
+
deployment: options.deployment,
|
|
627
|
+
irHash: action.irHash,
|
|
628
|
+
capabilityId: action.capabilityId,
|
|
629
|
+
version: action.version,
|
|
630
|
+
impact: action.impact,
|
|
631
|
+
requester: keyed(key, "requester", identity(action).requesterFingerprint),
|
|
632
|
+
tenant: keyed(key, "tenant", identity(action).tenantFingerprint),
|
|
633
|
+
input,
|
|
634
|
+
idempotencyPresent: idempotency !== undefined,
|
|
635
|
+
...(idempotency ? { idempotency } : {}),
|
|
636
|
+
summaryDigest: r.summaryDigest,
|
|
637
|
+
issuedAt: r.issuedAt,
|
|
638
|
+
expiresAt: r.expiresAt,
|
|
639
|
+
nonce: r.nonce,
|
|
640
|
+
};
|
|
641
|
+
return {
|
|
642
|
+
digest: keyed(key, "action-binding", jcs(value)),
|
|
643
|
+
input,
|
|
644
|
+
...(idempotency ? { idempotency } : {}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function details(r, challenge) {
|
|
648
|
+
return {
|
|
649
|
+
challenge,
|
|
650
|
+
capability: { id: r.action.capabilityId, version: r.action.version },
|
|
651
|
+
summary: r.action.summary,
|
|
652
|
+
impact: r.action.impact,
|
|
653
|
+
expiresAt: new Date(r.expiresAt).toISOString(),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const provider = {
|
|
657
|
+
async reportProviderFailure(incident, controls) {
|
|
658
|
+
try {
|
|
659
|
+
checkControls(controls);
|
|
660
|
+
const data = ownData(incident, ["providerKind", "operation", "reason"]);
|
|
661
|
+
if (Object.keys(data).length !== 3 ||
|
|
662
|
+
data.providerKind !== "identity_fingerprint" ||
|
|
663
|
+
data.operation !== "fingerprint" ||
|
|
664
|
+
![
|
|
665
|
+
"provider_missing",
|
|
666
|
+
"provider_failed",
|
|
667
|
+
"provider_malformed",
|
|
668
|
+
].includes(data.reason))
|
|
669
|
+
throw new Error("incident");
|
|
670
|
+
await store.incident({
|
|
671
|
+
service: options.service,
|
|
672
|
+
deployment: options.deployment,
|
|
673
|
+
protocolVersion: "1",
|
|
674
|
+
eventType: "provider_failed",
|
|
675
|
+
stage: "confirmation",
|
|
676
|
+
outcome: "unavailable",
|
|
677
|
+
providerKind: data.providerKind,
|
|
678
|
+
operation: data.operation,
|
|
679
|
+
reason: data.reason,
|
|
680
|
+
}, controls);
|
|
681
|
+
checkControls(controls);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
/* Independent incident reporting is contained and never recursive. */
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
async queryExecutionCompletion(linkage, outcome) {
|
|
688
|
+
try {
|
|
689
|
+
const data = ownData(linkage, [
|
|
690
|
+
"recordId",
|
|
691
|
+
"confirmationRef",
|
|
692
|
+
"executionAttemptId",
|
|
693
|
+
]);
|
|
694
|
+
if (!["succeeded", "failed"].includes(outcome) ||
|
|
695
|
+
typeof data.confirmationRef !== "string" ||
|
|
696
|
+
!data.confirmationRef.startsWith("capr1."))
|
|
697
|
+
return invalid();
|
|
698
|
+
digest(data.recordId);
|
|
699
|
+
digest(data.executionAttemptId);
|
|
700
|
+
digest(data.confirmationRef.slice(6));
|
|
701
|
+
const completed = await store.atomic((state) => {
|
|
702
|
+
checked(state);
|
|
703
|
+
ownData(state.completionProofs);
|
|
704
|
+
const stored = Object.hasOwn(state.completionProofs, data.executionAttemptId)
|
|
705
|
+
? state.completionProofs[data.executionAttemptId]
|
|
706
|
+
: undefined;
|
|
707
|
+
const proof = stored
|
|
708
|
+
? ownData(stored, ["recordId", "confirmationRef", "outcome"])
|
|
709
|
+
: undefined;
|
|
710
|
+
const eid = `cape1.${hash({ service: options.service, deployment: options.deployment, ownerType: "execution_attempt", ownerId: data.executionAttemptId, eventType: "execution_audit_completed", transitionOrdinal: 2 })}`;
|
|
711
|
+
return (!!proof &&
|
|
712
|
+
Object.hasOwn(state.durableEvents, eid) &&
|
|
713
|
+
state.durableEvents[eid] === true &&
|
|
714
|
+
proof.recordId === data.recordId &&
|
|
715
|
+
proof.confirmationRef === data.confirmationRef &&
|
|
716
|
+
proof.outcome === outcome);
|
|
717
|
+
});
|
|
718
|
+
return { ok: true, completed };
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return unavailable(false);
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
async allocateKernelInvocationId(controls) {
|
|
725
|
+
return operation((_state, _time, id) => ({ kernelInvocationId: id }), controls);
|
|
726
|
+
},
|
|
727
|
+
async issue(action, controls) {
|
|
728
|
+
return operation((state, time) => {
|
|
729
|
+
const checkedAction = validatedAction(action);
|
|
730
|
+
if (time > MAX_TIME - options.ttlMs - options.maxClockSkewMs)
|
|
731
|
+
throw new Failure("derived_time_out_of_range");
|
|
732
|
+
const g = generation(state, state.activeGeneration, true);
|
|
733
|
+
if (checkedAction.fingerprintGenerationId !== g.id)
|
|
734
|
+
throw new Failure("identity_generation_mismatch");
|
|
735
|
+
const rendered = summary(checkedAction, options, bearerGuard);
|
|
736
|
+
const h = handle(state, "capc1", g);
|
|
737
|
+
const id = unique(state);
|
|
738
|
+
const fingerprints = identity(checkedAction);
|
|
739
|
+
const safeAction = {
|
|
740
|
+
irHash: checkedAction.irHash,
|
|
741
|
+
capabilityId: checkedAction.capabilityId,
|
|
742
|
+
version: checkedAction.version,
|
|
743
|
+
impact: checkedAction.impact,
|
|
744
|
+
summary: rendered,
|
|
745
|
+
...fingerprints,
|
|
746
|
+
...(checkedAction.correlationId
|
|
747
|
+
? { correlationId: checkedAction.correlationId }
|
|
748
|
+
: {}),
|
|
749
|
+
...(checkedAction.sourceChain
|
|
750
|
+
? { sourceChain: checkedAction.sourceChain }
|
|
751
|
+
: {}),
|
|
752
|
+
};
|
|
753
|
+
const r = {
|
|
754
|
+
id,
|
|
755
|
+
confirmationRef: `capr1.${keyed(g.key, "audit-reference", id)}`,
|
|
756
|
+
state: "PENDING",
|
|
757
|
+
challengeLookup: h.lookup,
|
|
758
|
+
challengeHandleGeneration: g.id,
|
|
759
|
+
bindingFingerprintGeneration: g.id,
|
|
760
|
+
auditReferenceGeneration: g.id,
|
|
761
|
+
bindingDigest: "",
|
|
762
|
+
inputFingerprint: "",
|
|
763
|
+
summaryDigest: hash(rendered),
|
|
764
|
+
nonce: h.nonce,
|
|
765
|
+
issuedAt: time,
|
|
766
|
+
expiresAt: time + options.ttlMs,
|
|
767
|
+
purgeAt: time + options.ttlMs + options.maxClockSkewMs,
|
|
768
|
+
action: { ...safeAction, summary: rendered },
|
|
769
|
+
};
|
|
770
|
+
const b = binding(checkedAction, r, g.key, g.id);
|
|
771
|
+
r.bindingDigest = b.digest;
|
|
772
|
+
r.inputFingerprint = b.input;
|
|
773
|
+
if (b.idempotency)
|
|
774
|
+
r.idempotencyFingerprint = b.idempotency;
|
|
775
|
+
state.records[id] = r;
|
|
776
|
+
state.challengeIndex[h.lookup] = id;
|
|
777
|
+
recordEvent(state, r, "challenge_issued", 1, time);
|
|
778
|
+
return { challenge: details(r, h.text) };
|
|
779
|
+
}, controls);
|
|
780
|
+
},
|
|
781
|
+
async decideConfirmation(command, approvalRequest, controls) {
|
|
782
|
+
const allocated = await provider.allocateKernelInvocationId(controls);
|
|
783
|
+
if (!allocated.ok)
|
|
784
|
+
return allocated;
|
|
785
|
+
try {
|
|
786
|
+
command = ownData(command, [
|
|
787
|
+
"challenge",
|
|
788
|
+
"decision",
|
|
789
|
+
]);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
return {
|
|
793
|
+
ok: false,
|
|
794
|
+
error: {
|
|
795
|
+
code: "CAP_INPUT_INVALID",
|
|
796
|
+
status: "invalid_argument",
|
|
797
|
+
message: "Invalid confirmation decision.",
|
|
798
|
+
retryable: false,
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (!Object.hasOwn(command, "decision") ||
|
|
803
|
+
!["approve", "deny"].includes(command.decision))
|
|
804
|
+
return {
|
|
805
|
+
ok: false,
|
|
806
|
+
error: {
|
|
807
|
+
code: "CAP_INPUT_INVALID",
|
|
808
|
+
status: "invalid_argument",
|
|
809
|
+
message: "Invalid confirmation decision.",
|
|
810
|
+
retryable: false,
|
|
811
|
+
},
|
|
812
|
+
};
|
|
813
|
+
let trusted;
|
|
814
|
+
try {
|
|
815
|
+
const inspected = await store.atomic((state) => {
|
|
816
|
+
checked(state);
|
|
817
|
+
const time = now(state);
|
|
818
|
+
checkControls(controls);
|
|
819
|
+
const ownerId = unique(state);
|
|
820
|
+
try {
|
|
821
|
+
const r = lookup(state, command.challenge, "capc1");
|
|
822
|
+
expire(state, r, time);
|
|
823
|
+
if (r.state !== "PENDING" ||
|
|
824
|
+
Object.values(state.pendingEvents).some((e) => e.ownerId === r.id))
|
|
825
|
+
throw new Invalid();
|
|
826
|
+
return { view: details(r, command.challenge) };
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
if (!(error instanceof Invalid))
|
|
830
|
+
throw error;
|
|
831
|
+
event(state, "verification_rejected", "kernel_invocation", ownerId, 1, time, {
|
|
832
|
+
providerKind: "confirmation",
|
|
833
|
+
operation: "decision",
|
|
834
|
+
reason: "invalid_challenge",
|
|
835
|
+
});
|
|
836
|
+
return { invalid: true };
|
|
837
|
+
}
|
|
838
|
+
}, controls);
|
|
839
|
+
checkControls(controls);
|
|
840
|
+
try {
|
|
841
|
+
await flush(controls);
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
throw new Failure("journal_unavailable");
|
|
845
|
+
}
|
|
846
|
+
if ("invalid" in inspected)
|
|
847
|
+
return invalid();
|
|
848
|
+
const view = inspected.view;
|
|
849
|
+
trusted = await options.approvalProvider.authenticate(approvalRequest, Object.freeze({
|
|
850
|
+
...view,
|
|
851
|
+
capability: Object.freeze({ ...view.capability }),
|
|
852
|
+
}));
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
try {
|
|
856
|
+
checkControls(controls);
|
|
857
|
+
}
|
|
858
|
+
catch (interrupted) {
|
|
859
|
+
if (interrupted instanceof Interrupted ||
|
|
860
|
+
interrupted instanceof Failure)
|
|
861
|
+
error = interrupted;
|
|
862
|
+
}
|
|
863
|
+
if (error instanceof Interrupted)
|
|
864
|
+
return {
|
|
865
|
+
ok: false,
|
|
866
|
+
error: {
|
|
867
|
+
code: error.code,
|
|
868
|
+
status: "unavailable",
|
|
869
|
+
message: "Confirmation interrupted.",
|
|
870
|
+
retryable: false,
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
if (error instanceof Invalid) {
|
|
874
|
+
try {
|
|
875
|
+
await flush(controls);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return fail("journal_unavailable");
|
|
879
|
+
}
|
|
880
|
+
return invalid();
|
|
881
|
+
}
|
|
882
|
+
return fail(error instanceof Failure
|
|
883
|
+
? error.reason
|
|
884
|
+
: "approval_provider_unavailable");
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
trusted = ownData(trusted, [
|
|
888
|
+
"providerAuthenticated",
|
|
889
|
+
"approvalAuthorized",
|
|
890
|
+
"approverRef",
|
|
891
|
+
"assurance",
|
|
892
|
+
]);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
return fail("approval_context_invalid");
|
|
896
|
+
}
|
|
897
|
+
if (trusted.providerAuthenticated !== true)
|
|
898
|
+
return {
|
|
899
|
+
ok: false,
|
|
900
|
+
error: {
|
|
901
|
+
code: "CAP_UNAUTHENTICATED",
|
|
902
|
+
status: "unauthenticated",
|
|
903
|
+
message: "Approval authentication required.",
|
|
904
|
+
retryable: false,
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
if (trusted.approvalAuthorized === false)
|
|
908
|
+
return {
|
|
909
|
+
ok: false,
|
|
910
|
+
error: {
|
|
911
|
+
code: "CAP_PERMISSION_DENIED",
|
|
912
|
+
status: "permission_denied",
|
|
913
|
+
message: "Approval permission required.",
|
|
914
|
+
retryable: false,
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
if (trusted.approvalAuthorized !== true ||
|
|
918
|
+
typeof trusted.approverRef !== "string" ||
|
|
919
|
+
!trusted.approverRef.trim() ||
|
|
920
|
+
typeof trusted.assurance !== "string" ||
|
|
921
|
+
!trusted.assurance.trim() ||
|
|
922
|
+
!safeSummary(trusted.approverRef) ||
|
|
923
|
+
!safeSummary(trusted.assurance) ||
|
|
924
|
+
bearerGuard.detects(trusted.approverRef) ||
|
|
925
|
+
bearerGuard.detects(trusted.assurance))
|
|
926
|
+
return fail("approval_context_invalid");
|
|
927
|
+
// Copy only the four contracted trusted fields, never provider credentials.
|
|
928
|
+
trusted = {
|
|
929
|
+
providerAuthenticated: true,
|
|
930
|
+
approvalAuthorized: true,
|
|
931
|
+
approverRef: trusted.approverRef,
|
|
932
|
+
assurance: trusted.assurance,
|
|
933
|
+
};
|
|
934
|
+
return operation((state, time) => {
|
|
935
|
+
const r = lookup(state, command.challenge, "capc1");
|
|
936
|
+
expire(state, r, time);
|
|
937
|
+
if (r.state !== "PENDING")
|
|
938
|
+
throw new Invalid();
|
|
939
|
+
r.approver = trusted;
|
|
940
|
+
if (controls?.correlationId)
|
|
941
|
+
r.action = { ...r.action, correlationId: controls.correlationId };
|
|
942
|
+
if (command.decision === "deny") {
|
|
943
|
+
r.state = "DENIED";
|
|
944
|
+
recordEvent(state, r, "approval_denied", 2, time, {
|
|
945
|
+
approverRef: trusted.approverRef,
|
|
946
|
+
assurance: trusted.assurance,
|
|
947
|
+
decision: "deny",
|
|
948
|
+
});
|
|
949
|
+
return { outcome: "denied" };
|
|
950
|
+
}
|
|
951
|
+
const g = generation(state, state.activeGeneration, true);
|
|
952
|
+
const h = handle(state, "capa1", g);
|
|
953
|
+
r.evidenceHandleGeneration = g.id;
|
|
954
|
+
r.evidenceLookup = h.lookup;
|
|
955
|
+
r.state = "APPROVED";
|
|
956
|
+
state.evidenceIndex[h.lookup] = r.id;
|
|
957
|
+
recordEvent(state, r, "approval_granted", 2, time, {
|
|
958
|
+
approverRef: trusted.approverRef,
|
|
959
|
+
assurance: trusted.assurance,
|
|
960
|
+
decision: "approve",
|
|
961
|
+
});
|
|
962
|
+
return {
|
|
963
|
+
outcome: "approved",
|
|
964
|
+
confirmationToken: h.text,
|
|
965
|
+
expiresAt: new Date(r.expiresAt).toISOString(),
|
|
966
|
+
};
|
|
967
|
+
}, controls);
|
|
968
|
+
},
|
|
969
|
+
async consume(action, evidence, controls) {
|
|
970
|
+
const result = await operation((state, time, invocationId) => {
|
|
971
|
+
const checkedAction = validatedAction(action);
|
|
972
|
+
const r = lookup(state, evidence, "capa1");
|
|
973
|
+
if (checkedAction.fingerprintGenerationId !==
|
|
974
|
+
r.bindingFingerprintGeneration)
|
|
975
|
+
throw new Failure("identity_generation_mismatch");
|
|
976
|
+
const reject = (reason) => {
|
|
977
|
+
event(state, "verification_rejected", "kernel_invocation", invocationId, 1, time, {
|
|
978
|
+
...context(r),
|
|
979
|
+
reason,
|
|
980
|
+
...(checkedAction.correlationId
|
|
981
|
+
? { correlationId: checkedAction.correlationId }
|
|
982
|
+
: {}),
|
|
983
|
+
...(checkedAction.sourceChain
|
|
984
|
+
? { sourceChain: checkedAction.sourceChain }
|
|
985
|
+
: {}),
|
|
986
|
+
});
|
|
987
|
+
throw new Invalid(true);
|
|
988
|
+
};
|
|
989
|
+
try {
|
|
990
|
+
expire(state, r, time);
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
if (error instanceof Invalid)
|
|
994
|
+
reject("expired");
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
if (r.state !== "APPROVED" ||
|
|
998
|
+
Object.values(state.pendingEvents).some((e) => e.ownerId === r.id))
|
|
999
|
+
reject("record_ineligible");
|
|
1000
|
+
const b = binding(checkedAction, r, generation(state, r.bindingFingerprintGeneration).key, r.bindingFingerprintGeneration);
|
|
1001
|
+
if (!timingSafeEqual(persisted(r.bindingDigest), digest(b.digest))) {
|
|
1002
|
+
reject("binding_mismatch");
|
|
1003
|
+
}
|
|
1004
|
+
r.state = "CONSUMED";
|
|
1005
|
+
recordEvent(state, r, "evidence_consumed", 3, time);
|
|
1006
|
+
return {
|
|
1007
|
+
receipt: Object.freeze({
|
|
1008
|
+
recordId: r.id,
|
|
1009
|
+
confirmationRef: r.confirmationRef,
|
|
1010
|
+
}),
|
|
1011
|
+
};
|
|
1012
|
+
}, controls);
|
|
1013
|
+
if (result.ok)
|
|
1014
|
+
receipts.add(result.receipt);
|
|
1015
|
+
return result;
|
|
1016
|
+
},
|
|
1017
|
+
async beginExecution(receipt, controls) {
|
|
1018
|
+
if (!receipts.has(receipt))
|
|
1019
|
+
return invalid();
|
|
1020
|
+
const result = await operation((state, time) => {
|
|
1021
|
+
const r = state.records[receipt.recordId];
|
|
1022
|
+
if (!r ||
|
|
1023
|
+
r.state !== "CONSUMED" ||
|
|
1024
|
+
r.executionAttemptId ||
|
|
1025
|
+
r.confirmationRef !== receipt.confirmationRef ||
|
|
1026
|
+
Object.values(state.pendingEvents).some((e) => e.ownerId === r.id))
|
|
1027
|
+
throw new Invalid();
|
|
1028
|
+
for (const ref of [
|
|
1029
|
+
r.challengeHandleGeneration,
|
|
1030
|
+
r.bindingFingerprintGeneration,
|
|
1031
|
+
r.auditReferenceGeneration,
|
|
1032
|
+
r.evidenceHandleGeneration,
|
|
1033
|
+
])
|
|
1034
|
+
generation(state, ref);
|
|
1035
|
+
const id = unique(state);
|
|
1036
|
+
r.executionAttemptId = id;
|
|
1037
|
+
state.executions[id] = {
|
|
1038
|
+
id,
|
|
1039
|
+
recordId: r.id,
|
|
1040
|
+
state: "HANDLER_ENTRY_AUTHORIZED",
|
|
1041
|
+
};
|
|
1042
|
+
event(state, "execution_audit_started", "execution_attempt", id, 1, time, { ...context(r), executionAttemptId: id });
|
|
1043
|
+
return {
|
|
1044
|
+
attempt: Object.freeze({
|
|
1045
|
+
...receipt,
|
|
1046
|
+
executionAttemptId: id,
|
|
1047
|
+
}),
|
|
1048
|
+
};
|
|
1049
|
+
}, controls);
|
|
1050
|
+
if (result.ok)
|
|
1051
|
+
attempts.add(result.attempt);
|
|
1052
|
+
return result;
|
|
1053
|
+
},
|
|
1054
|
+
async completeExecution(attempt, outcome) {
|
|
1055
|
+
if (!attempts.has(attempt) || !["succeeded", "failed"].includes(outcome))
|
|
1056
|
+
return invalid();
|
|
1057
|
+
if (pendingCompletions.has(attempt.executionAttemptId))
|
|
1058
|
+
return unavailable(false);
|
|
1059
|
+
pendingCompletions.set(attempt.executionAttemptId, { attempt, outcome });
|
|
1060
|
+
try {
|
|
1061
|
+
await store.atomic((state) => {
|
|
1062
|
+
const x = state.executions[attempt.executionAttemptId];
|
|
1063
|
+
const r = state.records[attempt.recordId];
|
|
1064
|
+
if (!x ||
|
|
1065
|
+
!r ||
|
|
1066
|
+
x.recordId !== r.id ||
|
|
1067
|
+
x.state !== "HANDLER_ENTRY_AUTHORIZED")
|
|
1068
|
+
throw new Invalid();
|
|
1069
|
+
x.state = "COMPLETION_PENDING";
|
|
1070
|
+
x.completion = outcome;
|
|
1071
|
+
// Completion uses the durable floor: a failed host clock cannot erase the fence.
|
|
1072
|
+
event(state, "execution_audit_completed", "execution_attempt", x.id, 2, state.timeFloor, { ...context(r), completion: outcome, executionAttemptId: x.id });
|
|
1073
|
+
});
|
|
1074
|
+
await flush();
|
|
1075
|
+
pendingCompletions.delete(attempt.executionAttemptId);
|
|
1076
|
+
attempts.delete(attempt);
|
|
1077
|
+
return { ok: true };
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
if (error instanceof Invalid) {
|
|
1081
|
+
pendingCompletions.delete(attempt.executionAttemptId);
|
|
1082
|
+
return invalid();
|
|
1083
|
+
}
|
|
1084
|
+
await fail("post_handler_persistence_unavailable");
|
|
1085
|
+
return unavailable(false);
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
async transitionGeneration(id, transition) {
|
|
1089
|
+
if (!["RETIRED", "REVOKED"].includes(transition))
|
|
1090
|
+
return invalid();
|
|
1091
|
+
return operation((state, time) => {
|
|
1092
|
+
if (!Object.hasOwn(state.generations, id))
|
|
1093
|
+
throw new Invalid();
|
|
1094
|
+
const g = generation(state, id, false, true);
|
|
1095
|
+
if (g.quarantined) {
|
|
1096
|
+
if (g.pendingTransition !== transition)
|
|
1097
|
+
throw new Invalid();
|
|
1098
|
+
return {};
|
|
1099
|
+
}
|
|
1100
|
+
if (g.state === transition)
|
|
1101
|
+
return {};
|
|
1102
|
+
if (transition === "RETIRED" && g.state !== "ACTIVE")
|
|
1103
|
+
throw new Invalid();
|
|
1104
|
+
g.state = transition;
|
|
1105
|
+
g.retired = transition === "RETIRED";
|
|
1106
|
+
g.revoked = transition === "REVOKED";
|
|
1107
|
+
g.quarantined = true;
|
|
1108
|
+
g.pendingTransition = transition;
|
|
1109
|
+
event(state, transition === "REVOKED"
|
|
1110
|
+
? "generation_revoked"
|
|
1111
|
+
: "generation_retired", "key_generation", g.ownerId, transition === "REVOKED" ? 2 : 1, time, { generation: id });
|
|
1112
|
+
return {};
|
|
1113
|
+
});
|
|
1114
|
+
},
|
|
1115
|
+
async revoke(challenge) {
|
|
1116
|
+
return operation((state, time) => {
|
|
1117
|
+
const r = lookup(state, challenge, "capc1");
|
|
1118
|
+
if (!["PENDING", "APPROVED", "REVOKED"].includes(r.state))
|
|
1119
|
+
throw new Invalid();
|
|
1120
|
+
r.state = "REVOKED";
|
|
1121
|
+
recordEvent(state, r, "challenge_revoked", 5, time, {
|
|
1122
|
+
reason: "explicit_revocation",
|
|
1123
|
+
});
|
|
1124
|
+
return {};
|
|
1125
|
+
});
|
|
1126
|
+
},
|
|
1127
|
+
async reconcile(recovery = {}) {
|
|
1128
|
+
const result = await operation((state, time) => {
|
|
1129
|
+
for (const { attempt, outcome } of pendingCompletions.values()) {
|
|
1130
|
+
const x = state.executions[attempt.executionAttemptId];
|
|
1131
|
+
const r = state.records[attempt.recordId];
|
|
1132
|
+
if (x &&
|
|
1133
|
+
r &&
|
|
1134
|
+
x.state === "HANDLER_ENTRY_AUTHORIZED" &&
|
|
1135
|
+
x.recordId === r.id &&
|
|
1136
|
+
r.executionAttemptId === x.id) {
|
|
1137
|
+
x.state = "COMPLETION_PENDING";
|
|
1138
|
+
x.completion = outcome;
|
|
1139
|
+
event(state, "execution_audit_completed", "execution_attempt", x.id, 2, time, { ...context(r), completion: outcome, executionAttemptId: x.id });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const r of Object.values(state.records)) {
|
|
1143
|
+
if (["PENDING", "APPROVED"].includes(r.state)) {
|
|
1144
|
+
const refs = [
|
|
1145
|
+
r.challengeHandleGeneration,
|
|
1146
|
+
r.bindingFingerprintGeneration,
|
|
1147
|
+
r.auditReferenceGeneration,
|
|
1148
|
+
...(r.evidenceHandleGeneration
|
|
1149
|
+
? [r.evidenceHandleGeneration]
|
|
1150
|
+
: []),
|
|
1151
|
+
];
|
|
1152
|
+
if (refs.some((id) => state.generations[id]?.state === "REVOKED")) {
|
|
1153
|
+
r.state = "REVOKED";
|
|
1154
|
+
recordEvent(state, r, "challenge_revoked", 5, time, {
|
|
1155
|
+
reason: "generation_revoked",
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
else if (time >= r.expiresAt || r.expiryEventId) {
|
|
1159
|
+
r.state = "EXPIRED";
|
|
1160
|
+
r.expiryEventId = recordEvent(state, r, "challenge_expired", 4, time, { reason: "expired" });
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
const x = r.executionAttemptId
|
|
1164
|
+
? state.executions[r.executionAttemptId]
|
|
1165
|
+
: undefined;
|
|
1166
|
+
if (x?.state === "HANDLER_ENTRY_AUTHORIZED" &&
|
|
1167
|
+
recovery.lostExecutionAttemptIds?.includes(x.id)) {
|
|
1168
|
+
x.state = "COMPLETION_PENDING";
|
|
1169
|
+
x.completion = "ambiguous_after_handler_entry";
|
|
1170
|
+
event(state, "execution_audit_completed", "execution_attempt", x.id, 2, time, {
|
|
1171
|
+
...context(r),
|
|
1172
|
+
completion: x.completion,
|
|
1173
|
+
executionAttemptId: x.id,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
if (time >= r.purgeAt &&
|
|
1177
|
+
!["PENDING", "APPROVED"].includes(r.state) &&
|
|
1178
|
+
(!x || x.state === "COMPLETED") &&
|
|
1179
|
+
!Object.values(state.pendingEvents).some((e) => e.ownerId === r.id || e.ownerId === x?.id)) {
|
|
1180
|
+
delete state.challengeIndex[r.challengeLookup];
|
|
1181
|
+
if (r.evidenceLookup)
|
|
1182
|
+
delete state.evidenceIndex[r.evidenceLookup];
|
|
1183
|
+
delete state.records[r.id];
|
|
1184
|
+
if (x)
|
|
1185
|
+
delete state.executions[x.id];
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return {};
|
|
1189
|
+
});
|
|
1190
|
+
if (result.ok)
|
|
1191
|
+
pendingCompletions.clear();
|
|
1192
|
+
return result;
|
|
1193
|
+
},
|
|
1194
|
+
async health() {
|
|
1195
|
+
try {
|
|
1196
|
+
return await store.atomic((s) => !s.unhealthy);
|
|
1197
|
+
}
|
|
1198
|
+
catch {
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
};
|
|
1203
|
+
return Object.freeze(provider);
|
|
1204
|
+
}
|
|
1205
|
+
//# sourceMappingURL=confirmation.js.map
|