@humain/humain-code-contracts 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +92 -0
- package/dist/index.d.ts +224 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +218 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestration.d.ts +327 -0
- package/dist/orchestration.d.ts.map +1 -0
- package/dist/orchestration.js +307 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/rpc.d.ts +63 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +147 -0
- package/dist/rpc.js.map +1 -0
- package/dist/subagent-progress.d.ts +106 -0
- package/dist/subagent-progress.d.ts.map +1 -0
- package/dist/subagent-progress.js +440 -0
- package/dist/subagent-progress.js.map +1 -0
- package/dist/testing.d.ts +390 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +431 -0
- package/dist/testing.js.map +1 -0
- package/dist/trace-api.d.ts +293 -0
- package/dist/trace-api.d.ts.map +1 -0
- package/dist/trace-api.js +202 -0
- package/dist/trace-api.js.map +1 -0
- package/dist/trace-artifact.d.ts +94 -0
- package/dist/trace-artifact.d.ts.map +1 -0
- package/dist/trace-artifact.js +168 -0
- package/dist/trace-artifact.js.map +1 -0
- package/dist/trace-canonical.d.ts +23 -0
- package/dist/trace-canonical.d.ts.map +1 -0
- package/dist/trace-canonical.js +240 -0
- package/dist/trace-canonical.js.map +1 -0
- package/dist/trace-export.d.ts +94 -0
- package/dist/trace-export.d.ts.map +1 -0
- package/dist/trace-export.js +374 -0
- package/dist/trace-export.js.map +1 -0
- package/dist/trace-http.d.ts +1294 -0
- package/dist/trace-http.d.ts.map +1 -0
- package/dist/trace-http.js +1481 -0
- package/dist/trace-http.js.map +1 -0
- package/dist/trace-payload.d.ts +425 -0
- package/dist/trace-payload.d.ts.map +1 -0
- package/dist/trace-payload.js +1519 -0
- package/dist/trace-payload.js.map +1 -0
- package/dist/trace.d.ts +257 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +1014 -0
- package/dist/trace.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import { DH_ARTIFACT_KINDS, DH_ARTIFACT_MAX_BYTES, DH_ARTIFACT_MEDIA_TYPES, DH_COVERAGE_REASONS, DH_COVERAGE_STATES, DH_EVENT_KINDS, DH_ISSUERS, DH_OUTCOMES, DH_REJECTION_CODES, DhAgentIdSchema, DhArtifactIdSchema, DhAttemptIdSchema, DhEventIdSchema, DhGenerationIdSchema, DhTenantScopeSchema, parseDhArtifact, parseDhEvent, } from "./trace.js";
|
|
4
|
+
import { DH_EXPORT_STATES, parseDhIngestAck } from "./trace-api.js";
|
|
5
|
+
import { assertDhPlainData, DhContractError, dhCanonicalText, dhDigestInput, parseDhRawJson, } from "./trace-canonical.js";
|
|
6
|
+
import { acceptDhExportManifestPage, beginDhExportManifest, finishDhExportManifest, } from "./trace-export.js";
|
|
7
|
+
import { assertDhAppliedControlEvidence } from "./trace-payload.js";
|
|
8
|
+
/*
|
|
9
|
+
* C0 HTTP contract freeze: route table, auth/export/control DTOs, ACK validation and the safe
|
|
10
|
+
* Flow producer types. Everything here is a proposal frozen for consumer alignment, not an
|
|
11
|
+
* existing Code endpoint. This module is re-exported by ./trace.ts, which it also imports, so
|
|
12
|
+
* NOTHING here may read an imported trace.ts/trace-api.ts value at module top level: schemas are
|
|
13
|
+
* built lazily inside functions. Literals that mirror trace.ts constants are asserted equal in
|
|
14
|
+
* test/trace-http-conformance.test.ts.
|
|
15
|
+
*/
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Decisions and limits
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** Schema ids introduced by this freeze. Existing dh.event/1, dh.artifact/1 and dh.ack/1 are unchanged. */
|
|
20
|
+
export const DH_HTTP_SCHEMA_IDS = Object.freeze({
|
|
21
|
+
admissionBinding: "dh.admission-binding/1",
|
|
22
|
+
workloadBinding: "dh.workload-binding/1",
|
|
23
|
+
originDelegation: "dh.origin-delegation/1",
|
|
24
|
+
artifactReceipt: "dh.artifact-receipt/1",
|
|
25
|
+
controlReceipt: "dh.control-receipt/1",
|
|
26
|
+
controlRequest: "dh.control-request/2",
|
|
27
|
+
controlStatus: "dh.control-status/2",
|
|
28
|
+
controlTarget: "dh.control-target/1",
|
|
29
|
+
controlDelivery: "dh.control-delivery/1",
|
|
30
|
+
controlHandshake: "dh.control-handshake/1",
|
|
31
|
+
controllerCredential: "dh.controller-credential/1",
|
|
32
|
+
cursorBinding: "dh.cursor-binding/2",
|
|
33
|
+
snapshotCutoff: "dh.snapshot-cutoff/1",
|
|
34
|
+
generationPage: "dh.generation-page/2",
|
|
35
|
+
agentPage: "dh.agent-page/2",
|
|
36
|
+
eventPage: "dh.event-page/2",
|
|
37
|
+
exportManifestHeader: "dh.export-manifest-header/2",
|
|
38
|
+
exportManifestPage: "dh.export-manifest-page/2",
|
|
39
|
+
producerEnrollment: "dh.producer-enrollment/2",
|
|
40
|
+
producerEnrollmentRequest: "dh.producer-enrollment-request/2",
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Batch version decision record. dh.batch/1-proposal stays the ONLY schema managed ingress accepts.
|
|
44
|
+
* C0 resolves the synthetic-local lane to the proposal literal only. Production promotion remains
|
|
45
|
+
* a separate coordinated decision; it is never accepted alongside the proposal.
|
|
46
|
+
*/
|
|
47
|
+
export const DH_BATCH_VERSION_DECISION = Object.freeze({
|
|
48
|
+
accepted: "dh.batch/1-proposal",
|
|
49
|
+
promotionCandidate: "dh.batch/1",
|
|
50
|
+
status: "resolved-synthetic-local",
|
|
51
|
+
dualVersionIngress: false,
|
|
52
|
+
});
|
|
53
|
+
/** Validation ceiling only; the real access-token TTL is an open policy input. Matches the current client ceiling. */
|
|
54
|
+
export const DH_TOKEN_MAX_EXPIRES_IN_SECONDS = 600;
|
|
55
|
+
export const DH_EXPORT_MANIFEST_MAX_SHARDS = 1024;
|
|
56
|
+
/** Equal to DH_MAX_ACK_IDS (asserted in tests): one shard page is one bounded ACK-sized ID list. */
|
|
57
|
+
export const DH_EXPORT_MANIFEST_SHARD_MAX_IDS = 1000;
|
|
58
|
+
export const DH_GRANT_MAX_PROJECTS = 16;
|
|
59
|
+
/** Provisional bound for continuation text; policy may lower it. */
|
|
60
|
+
export const DH_CONTROL_MESSAGE_MAX_CHARS = 16384;
|
|
61
|
+
export const DH_GRANT_PERMISSIONS = ["review-metadata", "review-content", "export"];
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Shared helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const strict = (properties) => Type.Object(properties, { additionalProperties: false });
|
|
66
|
+
const count = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
|
|
67
|
+
const positive = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
68
|
+
const timestamp = Type.String({
|
|
69
|
+
minLength: 20,
|
|
70
|
+
maxLength: 30,
|
|
71
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?Z$",
|
|
72
|
+
});
|
|
73
|
+
const digest = Type.String({ pattern: "^[a-f0-9]{64}$" });
|
|
74
|
+
const SCOPE_ID = "^[A-Za-z0-9][A-Za-z0-9_.-]{0,255}$";
|
|
75
|
+
const scopeId = Type.String({ minLength: 1, maxLength: 256, pattern: SCOPE_ID });
|
|
76
|
+
const streamIdSchema = Type.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_-]{1,128}$" });
|
|
77
|
+
const refSchema = Type.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_.-]+$" });
|
|
78
|
+
const cursorSchema = Type.String({ minLength: 1, maxLength: 1024, pattern: "^[\\x21-\\x7e]+$" });
|
|
79
|
+
const nextCursorSchema = Type.Union([Type.Null(), cursorSchema]);
|
|
80
|
+
const opaqueToken = Type.String({ minLength: 1, maxLength: 4096, pattern: "^[\\x21-\\x7e]+$" });
|
|
81
|
+
const clientIdSchema = Type.String({ minLength: 1, maxLength: 256, pattern: "^[A-Za-z0-9._~-]+$" });
|
|
82
|
+
const LOOPBACK_REDIRECT = "^http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):[1-9][0-9]{0,4}(?:/[A-Za-z0-9._~/-]*)?$";
|
|
83
|
+
const prefixed = (prefix) => Type.String({ pattern: `^${prefix}_[A-Za-z0-9_-]{8,128}$`, maxLength: prefix.length + 129 });
|
|
84
|
+
const requestIdSchema = () => prefixed("ctl");
|
|
85
|
+
const enrollmentIdSchema = () => prefixed("enr");
|
|
86
|
+
const jobIdSchema = Type.String({ minLength: 1, maxLength: 256, pattern: "^[A-Za-z0-9_-]+$" });
|
|
87
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
88
|
+
const FIELD_PATH = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/;
|
|
89
|
+
const INSTANT_KEYS = new Set([
|
|
90
|
+
"captureCutoff",
|
|
91
|
+
"startedAt",
|
|
92
|
+
"finishedAt",
|
|
93
|
+
"occurredAt",
|
|
94
|
+
"recordedAt",
|
|
95
|
+
"receivedAt",
|
|
96
|
+
"updatedAt",
|
|
97
|
+
"admittedAt",
|
|
98
|
+
"expiresAt",
|
|
99
|
+
"authorizedAt",
|
|
100
|
+
"issuedAt",
|
|
101
|
+
]);
|
|
102
|
+
function isRecord(value) {
|
|
103
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
const INSTANT_PARTS = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/;
|
|
106
|
+
/** Date.parse rolls impossible dates over, so the parsed instant must round-trip to the same calendar fields. */
|
|
107
|
+
function isCalendarInstant(text) {
|
|
108
|
+
const parts = INSTANT_PARTS.exec(text);
|
|
109
|
+
if (parts === null)
|
|
110
|
+
return false;
|
|
111
|
+
const base = text.slice(0, 19);
|
|
112
|
+
const whole = Date.parse(`${base}.000Z`);
|
|
113
|
+
return Number.isFinite(whole) && new Date(whole).toISOString().slice(0, 19) === base;
|
|
114
|
+
}
|
|
115
|
+
function assertInstants(label, root) {
|
|
116
|
+
const stack = [root];
|
|
117
|
+
while (stack.length > 0) {
|
|
118
|
+
const value = stack.pop();
|
|
119
|
+
if (Array.isArray(value))
|
|
120
|
+
stack.push(...value);
|
|
121
|
+
else if (isRecord(value)) {
|
|
122
|
+
for (const [key, child] of Object.entries(value)) {
|
|
123
|
+
if (typeof child === "string" && INSTANT_KEYS.has(key) && !isCalendarInstant(child)) {
|
|
124
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}: ${key} is not a calendar-valid UTC instant`);
|
|
125
|
+
}
|
|
126
|
+
stack.push(child);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function deepFreeze(root) {
|
|
132
|
+
const stack = [root];
|
|
133
|
+
while (stack.length > 0) {
|
|
134
|
+
const value = stack.pop();
|
|
135
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value))
|
|
136
|
+
continue;
|
|
137
|
+
Object.freeze(value);
|
|
138
|
+
for (const child of Object.values(value))
|
|
139
|
+
stack.push(child);
|
|
140
|
+
}
|
|
141
|
+
return root;
|
|
142
|
+
}
|
|
143
|
+
/** Strict parser: plain-JSON gate, schema check, clone, re-check, calendar-valid instants, DTO semantics, freeze. */
|
|
144
|
+
function dtoParser(label, schema, semantic) {
|
|
145
|
+
let validator;
|
|
146
|
+
return (value) => {
|
|
147
|
+
assertDhPlainData(value);
|
|
148
|
+
validator ??= Compile(schema());
|
|
149
|
+
if (!validator.Check(value))
|
|
150
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}`);
|
|
151
|
+
const clone = JSON.parse(JSON.stringify(value));
|
|
152
|
+
if (!validator.Check(clone))
|
|
153
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}`);
|
|
154
|
+
assertInstants(label, clone);
|
|
155
|
+
semantic?.(clone);
|
|
156
|
+
return deepFreeze(clone);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The strict DTO parser (plain-JSON wire gate, schema check, clone, re-check, instants, semantics, freeze)
|
|
161
|
+
* for sibling modules such as trace-payload.ts, so the gate is never re-implemented.
|
|
162
|
+
*/
|
|
163
|
+
export { dtoParser as dhStrictDtoParser };
|
|
164
|
+
/** Replay and duplicate comparisons share the origin JCS profile. */
|
|
165
|
+
const canonicalText = dhCanonicalText;
|
|
166
|
+
function assertUnique(label, ids) {
|
|
167
|
+
if (new Set(ids).size !== ids.length)
|
|
168
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}: duplicate ids`);
|
|
169
|
+
}
|
|
170
|
+
function metadataCoverage() {
|
|
171
|
+
// No `detail`: it can carry transcript text.
|
|
172
|
+
return strict({ state: Type.Enum(DH_COVERAGE_STATES), reason: Type.Optional(Type.Enum(DH_COVERAGE_REASONS)) });
|
|
173
|
+
}
|
|
174
|
+
function assertCoverageReason(label, coverage) {
|
|
175
|
+
if (coverage.state !== "captured" && coverage.reason === undefined) {
|
|
176
|
+
throw new Error(`Invalid ${label}: non-captured coverage requires a reason`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Route table
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
export const DH_ROUTE_AUDIENCES = ["browser-review", "producer", "workload", "control", "records-policy"];
|
|
183
|
+
const S = "/api/v1/environments/{environmentId}/projects/{projectId}";
|
|
184
|
+
const I = `${S}/trace-ingest`;
|
|
185
|
+
const JSON_TYPE = "application/json";
|
|
186
|
+
const FORM_TYPE = "application/x-www-form-urlencoded";
|
|
187
|
+
const BYTES_TYPE = "application/octet-stream";
|
|
188
|
+
function route(method, path, audience, requestContentType, responseContentType, successStatus, requestHeaders, responseSchemaId) {
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
method,
|
|
191
|
+
path,
|
|
192
|
+
audience,
|
|
193
|
+
requestContentType,
|
|
194
|
+
...(requestHeaders ? { requestHeaders: Object.freeze([...requestHeaders]) } : {}),
|
|
195
|
+
responseContentType,
|
|
196
|
+
...(responseSchemaId ? { responseSchemaId } : {}),
|
|
197
|
+
successStatus: Object.freeze([...successStatus]),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const ROUTE_DEFINITIONS = {
|
|
201
|
+
authorizeConsent: route("GET", "/auth/trace-client/authorize", "browser-review", null, "text/html", [200]),
|
|
202
|
+
authorizeSubmit: route("POST", "/auth/trace-client/authorize", "browser-review", FORM_TYPE, null, [303]),
|
|
203
|
+
clientToken: route("POST", "/auth/trace-client/token", "browser-review", FORM_TYPE, JSON_TYPE, [200]),
|
|
204
|
+
clientRevoke: route("POST", "/auth/trace-client/revoke", "browser-review", FORM_TYPE, null, [204]),
|
|
205
|
+
producerEnroll: route("POST", `${S}/producer-enrollments`, "workload", JSON_TYPE, JSON_TYPE, [201], ["authorization"]),
|
|
206
|
+
producerToken: route("POST", "/auth/trace-producer/token", "workload", JSON_TYPE, JSON_TYPE, [200], ["authorization"]),
|
|
207
|
+
producerRevoke: route("POST", "/auth/trace-producer/revoke", "workload", JSON_TYPE, null, [204], ["authorization"]),
|
|
208
|
+
ingestRecords: route("POST", `${I}/streams/{streamId}/records`, "producer", JSON_TYPE, JSON_TYPE, [200], ["x-dh-previous-acked-sequence"], "dh.ack/1"),
|
|
209
|
+
ingestArtifactContent: route("PUT", `${I}/artifacts/{artifactId}/content`, "producer", BYTES_TYPE, JSON_TYPE, [200], ["x-dh-sha256"], "dh.artifact-receipt/1"),
|
|
210
|
+
reviewGenerations: route("GET", `${S}/generations`, "browser-review", null, JSON_TYPE, [200]),
|
|
211
|
+
reviewAgents: route("GET", `${S}/generations/{generationId}/agents`, "browser-review", null, JSON_TYPE, [200]),
|
|
212
|
+
reviewEvents: route("GET", `${S}/generations/{generationId}/events`, "browser-review", null, JSON_TYPE, [200]),
|
|
213
|
+
reviewArtifact: route("GET", `${S}/artifacts/{artifactId}`, "browser-review", null, JSON_TYPE, [200]),
|
|
214
|
+
reviewArtifactContent: route("GET", `${S}/artifacts/{artifactId}/content`, "browser-review", null, BYTES_TYPE, [200]),
|
|
215
|
+
usage: route("GET", `${S}/generations/{generationId}/usage`, "browser-review", null, JSON_TYPE, [200]),
|
|
216
|
+
search: route("GET", `${S}/generations/{generationId}/search`, "browser-review", null, JSON_TYPE, [200]),
|
|
217
|
+
exportCreate: route("POST", `${S}/generations/{generationId}/exports`, "browser-review", JSON_TYPE, JSON_TYPE, [202]),
|
|
218
|
+
exportStatus: route("GET", `${S}/generations/{generationId}/exports/{jobId}`, "browser-review", null, JSON_TYPE, [200]),
|
|
219
|
+
exportManifest: route("GET", `${S}/generations/{generationId}/exports/{jobId}/manifest`, "browser-review", null, JSON_TYPE, [200]),
|
|
220
|
+
exportDownload: route("GET", `${S}/generations/{generationId}/exports/{jobId}/download`, "browser-review", null, JSON_TYPE, [200]),
|
|
221
|
+
exportContent: route("GET", `${S}/generations/{generationId}/exports/{jobId}/content`, "browser-review", null, BYTES_TYPE, [200]),
|
|
222
|
+
controlTarget: route("GET", `${S}/generations/{generationId}/agents/{agentId}/control-target`, "control", null, JSON_TYPE, [200], undefined, "dh.control-target/1"),
|
|
223
|
+
controlCancel: route("POST", `${S}/generations/{generationId}/agents/{agentId}/cancel`, "control", JSON_TYPE, JSON_TYPE, [200, 202]),
|
|
224
|
+
controlRetry: route("POST", `${S}/generations/{generationId}/agents/{agentId}/retry`, "control", JSON_TYPE, JSON_TYPE, [200, 202]),
|
|
225
|
+
controlContinue: route("POST", `${S}/generations/{generationId}/agents/{agentId}/continue`, "control", JSON_TYPE, JSON_TYPE, [200, 202]),
|
|
226
|
+
controlRequest: route("GET", `${S}/generations/{generationId}/agents/{agentId}/control-requests/{requestId}`, "control", null, JSON_TYPE, [200]),
|
|
227
|
+
generationDelete: route("DELETE", `${S}/generations/{generationId}`, "records-policy", null, JSON_TYPE, [202]),
|
|
228
|
+
legalHoldCreate: route("POST", `${S}/generations/{generationId}/legal-holds`, "records-policy", JSON_TYPE, JSON_TYPE, [201]),
|
|
229
|
+
legalHoldRelease: route("DELETE", `${S}/generations/{generationId}/legal-holds/{holdId}`, "records-policy", null, null, [204]),
|
|
230
|
+
};
|
|
231
|
+
export const DH_ROUTES = Object.freeze(ROUTE_DEFINITIONS);
|
|
232
|
+
/** ID segment validators. Only unreserved characters: no separators, dots-only segments, encodings or wildcards. */
|
|
233
|
+
export const DH_PATH_PARAM_PATTERNS = Object.freeze({
|
|
234
|
+
environmentId: new RegExp(SCOPE_ID),
|
|
235
|
+
projectId: new RegExp(SCOPE_ID),
|
|
236
|
+
streamId: /^[A-Za-z0-9_-]{1,128}$/,
|
|
237
|
+
generationId: /^gen_[A-Za-z0-9_-]{8,128}$/,
|
|
238
|
+
agentId: /^agt_[A-Za-z0-9_-]{8,128}$/,
|
|
239
|
+
artifactId: /^art_[A-Za-z0-9_-]{8,128}$/,
|
|
240
|
+
jobId: /^[A-Za-z0-9_-]{1,256}$/,
|
|
241
|
+
requestId: /^ctl_[A-Za-z0-9_-]{8,128}$/,
|
|
242
|
+
holdId: /^[A-Za-z0-9_-]{1,256}$/,
|
|
243
|
+
});
|
|
244
|
+
function routeFor(id) {
|
|
245
|
+
if (!Object.hasOwn(ROUTE_DEFINITIONS, id))
|
|
246
|
+
throw new DhContractError("schema_invalid", "Unknown trace route");
|
|
247
|
+
return ROUTE_DEFINITIONS[id];
|
|
248
|
+
}
|
|
249
|
+
/** Builds a path from the frozen template. Params must be exactly the template's placeholders, each validated then percent-encoded. */
|
|
250
|
+
export function buildDhRoutePath(id, params) {
|
|
251
|
+
const definition = routeFor(id);
|
|
252
|
+
const names = [...definition.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
|
|
253
|
+
if (!isRecord(params))
|
|
254
|
+
throw new Error(`Route ${id} requires exactly the parameters ${names.join(", ") || "(none)"}`);
|
|
255
|
+
assertDhPlainData(params);
|
|
256
|
+
const given = Object.keys(params);
|
|
257
|
+
if (given.length !== names.length || given.some((name) => !names.includes(name))) {
|
|
258
|
+
throw new Error(`Route ${id} requires exactly the parameters ${names.join(", ") || "(none)"}`);
|
|
259
|
+
}
|
|
260
|
+
return definition.path.replace(/\{(\w+)\}/g, (_, name) => {
|
|
261
|
+
const value = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
262
|
+
const pattern = DH_PATH_PARAM_PATTERNS[name];
|
|
263
|
+
if (typeof value !== "string" || pattern === undefined || !pattern.test(value)) {
|
|
264
|
+
throw new Error(`Invalid route parameter ${name}`);
|
|
265
|
+
}
|
|
266
|
+
return encodeURIComponent(value);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Grant permissions a browser-review route needs. Export reads (status, manifest, download, content)
|
|
271
|
+
* are authorized from the SERVER-STORED job, passed as `job` (its `contentIncluded`, e.g. from
|
|
272
|
+
* DhExportJobStatus): export + review-metadata always, plus review-content when the stored job
|
|
273
|
+
* includes content. A caller-supplied `includeContent` is ignored for them and a missing or malformed
|
|
274
|
+
* `job` throws. The server must evaluate this on every read, so a grant that lost review-content after
|
|
275
|
+
* creation can no longer read a content job. Only `exportCreate` (which creates the job) and the
|
|
276
|
+
* event/search views use `includeContent`. Control/producer/records-policy routes use no read-grant permission.
|
|
277
|
+
* `options` (and the `job` inside it) pass the plain-JSON wire gate before any member is read, so an accessor or
|
|
278
|
+
* non-plain object is rejected without executing user code.
|
|
279
|
+
*/
|
|
280
|
+
export function requiredDhPermissions(id, options) {
|
|
281
|
+
assertDhPlainData(options);
|
|
282
|
+
const content = options.includeContent === true;
|
|
283
|
+
switch (id) {
|
|
284
|
+
case "reviewGenerations":
|
|
285
|
+
case "reviewAgents":
|
|
286
|
+
case "usage":
|
|
287
|
+
case "reviewArtifact":
|
|
288
|
+
return ["review-metadata"];
|
|
289
|
+
case "reviewEvents":
|
|
290
|
+
case "search":
|
|
291
|
+
return content ? ["review-content"] : ["review-metadata"];
|
|
292
|
+
case "reviewArtifactContent":
|
|
293
|
+
return ["review-content"];
|
|
294
|
+
case "exportCreate":
|
|
295
|
+
return content ? ["export", "review-content", "review-metadata"] : ["export", "review-metadata"];
|
|
296
|
+
case "exportStatus":
|
|
297
|
+
case "exportManifest":
|
|
298
|
+
case "exportDownload":
|
|
299
|
+
case "exportContent": {
|
|
300
|
+
const stored = options.job?.contentIncluded;
|
|
301
|
+
if (typeof stored !== "boolean")
|
|
302
|
+
throw new Error(`Route ${id} needs the stored export job to derive permissions`);
|
|
303
|
+
return stored ? ["export", "review-content", "review-metadata"] : ["export", "review-metadata"];
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
routeFor(id);
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Credential classes are not interchangeable: a credential is valid only on routes of its own audience class. */
|
|
311
|
+
export function assertDhCredentialForRoute(id, credentialClass) {
|
|
312
|
+
const definition = routeFor(id);
|
|
313
|
+
if (!DH_ROUTE_AUDIENCES.includes(credentialClass)) {
|
|
314
|
+
throw new Error("Unknown credential class");
|
|
315
|
+
}
|
|
316
|
+
if (definition.audience !== credentialClass) {
|
|
317
|
+
throw new DhContractError(definition.audience === "workload" ? "workload_proof_invalid" : "not_authorized", `Credential class is not valid for route ${id}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Immutable admission and caller-supplied registry facts
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
function admissionBindingSchema() {
|
|
324
|
+
return strict({
|
|
325
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.admissionBinding),
|
|
326
|
+
bindingId: prefixed("bnd"),
|
|
327
|
+
generationRootBindingId: prefixed("bnd"),
|
|
328
|
+
mappingRevision: digest,
|
|
329
|
+
tenantId: scopeId,
|
|
330
|
+
environmentId: scopeId,
|
|
331
|
+
projectId: scopeId,
|
|
332
|
+
nativeProjectId: scopeId,
|
|
333
|
+
nativeRunId: scopeId,
|
|
334
|
+
nativeSegmentId: scopeId,
|
|
335
|
+
generationId: DhGenerationIdSchema,
|
|
336
|
+
rootAgentId: DhAgentIdSchema,
|
|
337
|
+
initiatingActorId: scopeId,
|
|
338
|
+
bindingEpoch: refSchema,
|
|
339
|
+
policyRevision: refSchema,
|
|
340
|
+
admittedAt: timestamp,
|
|
341
|
+
environment: Type.Literal("local"),
|
|
342
|
+
syntheticOnly: Type.Literal(true),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
export const parseDhAdmissionBinding = dtoParser("admission binding", admissionBindingSchema);
|
|
346
|
+
/** Pure equality over supplied durable facts, not a registry lookup or authentication. */
|
|
347
|
+
export function assertDhRecordBinding(recordInput, bindingInput, context = {}) {
|
|
348
|
+
const binding = parseDhAdmissionBinding(bindingInput);
|
|
349
|
+
assertDhPlainData(recordInput);
|
|
350
|
+
const record = isRecord(recordInput) && recordInput.schema === "dh.artifact/1"
|
|
351
|
+
? parseDhArtifact(recordInput)
|
|
352
|
+
: parseDhEvent(recordInput);
|
|
353
|
+
assertDhPlainData(context);
|
|
354
|
+
if (context.routeScope &&
|
|
355
|
+
(context.routeScope.environmentId !== binding.environmentId || context.routeScope.projectId !== binding.projectId))
|
|
356
|
+
throw new DhContractError("not_found");
|
|
357
|
+
if (context.tombstoned)
|
|
358
|
+
throw new DhContractError("tombstoned");
|
|
359
|
+
if (record.generationId !== binding.generationId ||
|
|
360
|
+
record.tenantScope.environmentId !== binding.environmentId ||
|
|
361
|
+
record.tenantScope.projectId !== binding.projectId)
|
|
362
|
+
throw new DhContractError("scope_binding_conflict");
|
|
363
|
+
const mapping = context.registryMapping;
|
|
364
|
+
if (mapping === null)
|
|
365
|
+
throw new DhContractError("mapping_missing");
|
|
366
|
+
if (mapping !== undefined) {
|
|
367
|
+
if (mapping.mappingRevision !== binding.mappingRevision)
|
|
368
|
+
throw new DhContractError("mapping_stale");
|
|
369
|
+
for (const key of ["tenantId", "environmentId", "projectId", "nativeProjectId"])
|
|
370
|
+
if (mapping[key] !== binding[key])
|
|
371
|
+
throw new DhContractError("scope_binding_conflict");
|
|
372
|
+
}
|
|
373
|
+
if (context.previousBinding &&
|
|
374
|
+
canonicalText(parseDhAdmissionBinding(context.previousBinding)) !== canonicalText(binding))
|
|
375
|
+
throw new DhContractError("scope_binding_conflict");
|
|
376
|
+
if (context.rootBinding) {
|
|
377
|
+
const root = parseDhAdmissionBinding(context.rootBinding);
|
|
378
|
+
if (root.bindingId !== root.generationRootBindingId || binding.generationRootBindingId !== root.bindingId)
|
|
379
|
+
throw new DhContractError("scope_binding_conflict");
|
|
380
|
+
for (const key of [
|
|
381
|
+
"tenantId",
|
|
382
|
+
"environmentId",
|
|
383
|
+
"projectId",
|
|
384
|
+
"nativeProjectId",
|
|
385
|
+
"generationId",
|
|
386
|
+
"rootAgentId",
|
|
387
|
+
"initiatingActorId",
|
|
388
|
+
])
|
|
389
|
+
if (root[key] !== binding[key])
|
|
390
|
+
throw new DhContractError("scope_binding_conflict");
|
|
391
|
+
}
|
|
392
|
+
if (context.owner && context.owner.bindingId !== binding.bindingId)
|
|
393
|
+
throw new DhContractError("scope_binding_conflict");
|
|
394
|
+
if (context.existingOwner) {
|
|
395
|
+
if (!context.owner ||
|
|
396
|
+
context.owner.bindingId !== context.existingOwner.bindingId ||
|
|
397
|
+
context.owner.originProducerInstanceId !== context.existingOwner.originProducerInstanceId)
|
|
398
|
+
throw new DhContractError("duplicate_conflict");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Browser public-client and producer authentication DTOs
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
function grantScopeSchema() {
|
|
405
|
+
return strict({
|
|
406
|
+
environmentId: scopeId,
|
|
407
|
+
projectIds: Type.Array(scopeId, { minItems: 1, maxItems: DH_GRANT_MAX_PROJECTS, uniqueItems: true }),
|
|
408
|
+
permissions: Type.Array(Type.Enum(DH_GRANT_PERMISSIONS), {
|
|
409
|
+
minItems: 1,
|
|
410
|
+
maxItems: DH_GRANT_PERMISSIONS.length,
|
|
411
|
+
uniqueItems: true,
|
|
412
|
+
}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
function tokenResponseSchema() {
|
|
416
|
+
return strict({
|
|
417
|
+
access_token: opaqueToken,
|
|
418
|
+
token_type: Type.Literal("Bearer"),
|
|
419
|
+
expires_in: Type.Integer({ minimum: 1, maximum: DH_TOKEN_MAX_EXPIRES_IN_SECONDS }),
|
|
420
|
+
refresh_token: opaqueToken,
|
|
421
|
+
grant_scope: grantScopeSchema(),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
export const parseDhTokenResponse = dtoParser("trace token response", tokenResponseSchema);
|
|
425
|
+
/** OAuth errors have no reflecting error_description or non-OAuth fields. */
|
|
426
|
+
export const parseDhTokenError = dtoParser("trace token error", () => strict({
|
|
427
|
+
error: Type.Enum([
|
|
428
|
+
"invalid_grant",
|
|
429
|
+
"invalid_request",
|
|
430
|
+
"invalid_client",
|
|
431
|
+
"unauthorized_client",
|
|
432
|
+
"unsupported_grant_type",
|
|
433
|
+
"invalid_scope",
|
|
434
|
+
"server_error",
|
|
435
|
+
"temporarily_unavailable",
|
|
436
|
+
]),
|
|
437
|
+
}));
|
|
438
|
+
/** Decodes a form body; rejects duplicates except the explicitly repeatable keys, which always become arrays. */
|
|
439
|
+
function parseForm(label, input, repeatable = new Set()) {
|
|
440
|
+
if (typeof input === "string" && input.length > 8192)
|
|
441
|
+
throw new DhContractError("too_large", `Invalid ${label}: too large`);
|
|
442
|
+
if (typeof input !== "string" && !(input instanceof URLSearchParams))
|
|
443
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}`);
|
|
444
|
+
const params = typeof input === "string" ? new URLSearchParams(input) : input;
|
|
445
|
+
const out = Object.create(null);
|
|
446
|
+
for (const [key, value] of params) {
|
|
447
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
448
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}: forbidden key`);
|
|
449
|
+
const existing = out[key];
|
|
450
|
+
if (existing === undefined)
|
|
451
|
+
out[key] = repeatable.has(key) ? [value] : value;
|
|
452
|
+
else if (Array.isArray(existing) && repeatable.has(key))
|
|
453
|
+
existing.push(value);
|
|
454
|
+
else
|
|
455
|
+
throw new DhContractError("schema_invalid", `Invalid ${label}: duplicate form parameter`);
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
function assertLoopback(value) {
|
|
460
|
+
new URL(value); // Throws for ports above 65535; the pattern already pins scheme, host and no query/fragment.
|
|
461
|
+
}
|
|
462
|
+
const redirectUriSchema = () => Type.String({ maxLength: 512, pattern: LOOPBACK_REDIRECT });
|
|
463
|
+
function tokenRequestSchema() {
|
|
464
|
+
return Type.Union([
|
|
465
|
+
strict({
|
|
466
|
+
grant_type: Type.Literal("authorization_code"),
|
|
467
|
+
client_id: clientIdSchema,
|
|
468
|
+
code: Type.String({ minLength: 8, maxLength: 512, pattern: "^[\\x21-\\x7e]+$" }),
|
|
469
|
+
code_verifier: Type.String({ minLength: 43, maxLength: 128, pattern: "^[A-Za-z0-9._~-]+$" }),
|
|
470
|
+
redirect_uri: redirectUriSchema(),
|
|
471
|
+
}),
|
|
472
|
+
strict({ grant_type: Type.Literal("refresh_token"), client_id: clientIdSchema, refresh_token: opaqueToken }),
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
475
|
+
const parseTokenRequestRecord = dtoParser("trace token request", tokenRequestSchema, (value) => {
|
|
476
|
+
if (value.grant_type === "authorization_code")
|
|
477
|
+
assertLoopback(value.redirect_uri);
|
|
478
|
+
});
|
|
479
|
+
export const parseDhTokenRequest = (input) => parseTokenRequestRecord(parseForm("trace token request", input));
|
|
480
|
+
function revokeRequestSchema() {
|
|
481
|
+
return strict({ client_id: clientIdSchema, token: opaqueToken, token_type_hint: Type.Literal("refresh_token") });
|
|
482
|
+
}
|
|
483
|
+
const parseRevokeRecord = dtoParser("trace revoke request", revokeRequestSchema);
|
|
484
|
+
export const parseDhRevokeRequest = (input) => parseRevokeRecord(parseForm("trace revoke request", input));
|
|
485
|
+
function authorizeQuerySchema() {
|
|
486
|
+
return strict({
|
|
487
|
+
response_type: Type.Literal("code"),
|
|
488
|
+
client_id: clientIdSchema,
|
|
489
|
+
redirect_uri: redirectUriSchema(),
|
|
490
|
+
state: Type.String({ minLength: 16, maxLength: 256, pattern: "^[A-Za-z0-9._~-]+$" }),
|
|
491
|
+
code_challenge: Type.String({ pattern: "^[A-Za-z0-9_-]{43}$" }),
|
|
492
|
+
code_challenge_method: Type.Literal("S256"),
|
|
493
|
+
environment_id: scopeId,
|
|
494
|
+
project_id: Type.Array(scopeId, { minItems: 1, maxItems: DH_GRANT_MAX_PROJECTS, uniqueItems: true }),
|
|
495
|
+
permission: Type.Array(Type.Enum(DH_GRANT_PERMISSIONS), {
|
|
496
|
+
minItems: 1,
|
|
497
|
+
maxItems: DH_GRANT_PERMISSIONS.length,
|
|
498
|
+
uniqueItems: true,
|
|
499
|
+
}),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const parseAuthorizeRecord = dtoParser("trace authorize query", authorizeQuerySchema, (value) => assertLoopback(value.redirect_uri));
|
|
503
|
+
export const parseDhAuthorizeQuery = (input) => parseAuthorizeRecord(parseForm("trace authorize query", input, new Set(["project_id", "permission"])));
|
|
504
|
+
const producerSummary = () => strict({
|
|
505
|
+
issuer: Type.Enum(DH_ISSUERS),
|
|
506
|
+
version: Type.String({ minLength: 1, maxLength: 256, pattern: "^[\\x20-\\x7e]+$" }),
|
|
507
|
+
mode: Type.String({ minLength: 1, maxLength: 256, pattern: "^[\\x20-\\x7e]+$" }),
|
|
508
|
+
});
|
|
509
|
+
function producerEnrollmentRequestSchema() {
|
|
510
|
+
return strict({
|
|
511
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.producerEnrollmentRequest),
|
|
512
|
+
bindingId: prefixed("bnd"),
|
|
513
|
+
producerInstanceId: prefixed("prd"),
|
|
514
|
+
generationId: DhGenerationIdSchema,
|
|
515
|
+
originStreamId: streamIdSchema,
|
|
516
|
+
producer: producerSummary(),
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Workload proof travels in the authentication header, never the body; a reviewer grant is never producer proof. */
|
|
520
|
+
export const parseDhProducerEnrollmentRequest = dtoParser("producer enrollment request", producerEnrollmentRequestSchema);
|
|
521
|
+
function producerEnrollmentSchema() {
|
|
522
|
+
return strict({
|
|
523
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.producerEnrollment),
|
|
524
|
+
enrollmentId: enrollmentIdSchema(),
|
|
525
|
+
tenantScope: DhTenantScopeSchema,
|
|
526
|
+
generationId: DhGenerationIdSchema,
|
|
527
|
+
originStreamId: streamIdSchema,
|
|
528
|
+
issuer: Type.Enum(DH_ISSUERS),
|
|
529
|
+
credentialVersion: positive,
|
|
530
|
+
bindingId: prefixed("bnd"),
|
|
531
|
+
producerInstanceId: prefixed("prd"),
|
|
532
|
+
allowedOriginIssuers: Type.Array(Type.Enum(DH_ISSUERS), { minItems: 1, maxItems: 3, uniqueItems: true }),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
export const parseDhProducerEnrollment = dtoParser("producer enrollment", producerEnrollmentSchema);
|
|
536
|
+
export const parseDhProducerTokenRequest = dtoParser("producer token request", () => strict({ enrollmentId: enrollmentIdSchema() }));
|
|
537
|
+
export const parseDhProducerRevokeRequest = dtoParser("producer revoke request", () => strict({ enrollmentId: enrollmentIdSchema() }));
|
|
538
|
+
function producerAudienceSchema() {
|
|
539
|
+
return strict({
|
|
540
|
+
environmentId: scopeId,
|
|
541
|
+
projectId: scopeId,
|
|
542
|
+
generationId: DhGenerationIdSchema,
|
|
543
|
+
originStreamId: streamIdSchema,
|
|
544
|
+
issuer: Type.Enum(DH_ISSUERS),
|
|
545
|
+
enrollmentId: enrollmentIdSchema(),
|
|
546
|
+
credentialVersion: positive,
|
|
547
|
+
bindingId: prefixed("bnd"),
|
|
548
|
+
producerInstanceId: prefixed("prd"),
|
|
549
|
+
allowedOriginIssuers: Type.Array(Type.Enum(DH_ISSUERS), { minItems: 1, maxItems: 3, uniqueItems: true }),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/** No refresh_token and no grant_scope: a producer credential cannot be refreshed into, or mistaken for, a read grant. */
|
|
553
|
+
export const parseDhProducerTokenResponse = dtoParser("producer token response", () => strict({
|
|
554
|
+
access_token: opaqueToken,
|
|
555
|
+
token_type: Type.Literal("Bearer"),
|
|
556
|
+
expires_in: Type.Integer({ minimum: 1, maximum: DH_TOKEN_MAX_EXPIRES_IN_SECONDS }),
|
|
557
|
+
audience: producerAudienceSchema(),
|
|
558
|
+
}));
|
|
559
|
+
/**
|
|
560
|
+
* A producer credential is bound to one environment/project/generation/origin stream AND to its issuer,
|
|
561
|
+
* enrollment and credential version, so a credential from a superseded or other enrollment never passes.
|
|
562
|
+
*/
|
|
563
|
+
export function assertDhProducerAudience(audience, expected) {
|
|
564
|
+
assertDhPlainData(audience);
|
|
565
|
+
assertDhPlainData(expected);
|
|
566
|
+
for (const key of [
|
|
567
|
+
"environmentId",
|
|
568
|
+
"projectId",
|
|
569
|
+
"generationId",
|
|
570
|
+
"originStreamId",
|
|
571
|
+
"issuer",
|
|
572
|
+
"enrollmentId",
|
|
573
|
+
"credentialVersion",
|
|
574
|
+
"bindingId",
|
|
575
|
+
"producerInstanceId",
|
|
576
|
+
]) {
|
|
577
|
+
if (audience[key] !== expected[key])
|
|
578
|
+
throw new DhContractError("producer_revoked", `Producer credential audience mismatch: ${key}`);
|
|
579
|
+
}
|
|
580
|
+
if (canonicalText(audience.allowedOriginIssuers) !== canonicalText(expected.allowedOriginIssuers))
|
|
581
|
+
throw new DhContractError("producer_revoked", "Producer credential audience mismatch: allowedOriginIssuers");
|
|
582
|
+
}
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// Local workload descriptors and delegated origins (no secret DTO fields)
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
export const parseDhWorkloadBinding = dtoParser("workload binding", () => strict({
|
|
587
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.workloadBinding),
|
|
588
|
+
workloadId: prefixed("wld"),
|
|
589
|
+
bindingId: prefixed("bnd"),
|
|
590
|
+
producerInstanceId: prefixed("prd"),
|
|
591
|
+
launchNonce: refSchema,
|
|
592
|
+
issuer: Type.Enum(DH_ISSUERS),
|
|
593
|
+
buildSha256: digest,
|
|
594
|
+
mode: refSchema,
|
|
595
|
+
credentialVersion: positive,
|
|
596
|
+
expiresAt: timestamp,
|
|
597
|
+
}));
|
|
598
|
+
export const parseDhOriginDelegation = dtoParser("origin delegation", () => strict({
|
|
599
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.originDelegation),
|
|
600
|
+
delegationId: prefixed("dlg"),
|
|
601
|
+
bindingId: prefixed("bnd"),
|
|
602
|
+
originProducerInstanceId: prefixed("prd"),
|
|
603
|
+
originStreamId: streamIdSchema,
|
|
604
|
+
originIssuer: Type.Enum(DH_ISSUERS),
|
|
605
|
+
relayProducerInstanceId: prefixed("prd"),
|
|
606
|
+
enrollmentId: enrollmentIdSchema(),
|
|
607
|
+
credentialVersion: positive,
|
|
608
|
+
expiresAt: timestamp,
|
|
609
|
+
}));
|
|
610
|
+
/** Compare to a live server-issued receipt. A syntactically valid receipt grants nothing by itself. */
|
|
611
|
+
export function assertDhOriginDelegation(delegationInput, expected) {
|
|
612
|
+
const delegation = parseDhOriginDelegation(delegationInput);
|
|
613
|
+
if (expected === null || expected === undefined)
|
|
614
|
+
throw new DhContractError("origin_not_delegated");
|
|
615
|
+
if (canonicalText(delegation) !== canonicalText(parseDhOriginDelegation(expected)))
|
|
616
|
+
throw new DhContractError("origin_not_delegated");
|
|
617
|
+
}
|
|
618
|
+
/** Exactly 32 bytes in canonical unpadded base64url; caller handles private custody and live verification. */
|
|
619
|
+
export function parseDhWorkloadAuthorizationHeader(value) {
|
|
620
|
+
if (typeof value !== "string" || !/^DH-Workload [A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value))
|
|
621
|
+
throw new DhContractError("workload_proof_invalid");
|
|
622
|
+
return value.slice("DH-Workload ".length);
|
|
623
|
+
}
|
|
624
|
+
export function parseDhOriginDelegationHeader(value) {
|
|
625
|
+
if (typeof value !== "string" || !/^dlg_[A-Za-z0-9_-]{8,128}$/.test(value))
|
|
626
|
+
throw new DhContractError("origin_not_delegated");
|
|
627
|
+
return value;
|
|
628
|
+
}
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Snapshot paging: event metadata/content pages, artifact metadata, cursor binding
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
function eventMetadataViewSchema() {
|
|
633
|
+
return strict({
|
|
634
|
+
eventId: DhEventIdSchema,
|
|
635
|
+
generationId: DhGenerationIdSchema,
|
|
636
|
+
agentId: DhAgentIdSchema,
|
|
637
|
+
parentAgentId: Type.Optional(DhAgentIdSchema),
|
|
638
|
+
attemptId: Type.Optional(DhAttemptIdSchema),
|
|
639
|
+
retryOfAttemptId: Type.Optional(DhAttemptIdSchema),
|
|
640
|
+
attemptNumber: Type.Optional(positive),
|
|
641
|
+
streamId: streamIdSchema,
|
|
642
|
+
sequence: count,
|
|
643
|
+
kind: Type.Enum(DH_EVENT_KINDS),
|
|
644
|
+
occurredAt: timestamp,
|
|
645
|
+
recordedAt: timestamp,
|
|
646
|
+
outcome: Type.Optional(Type.Enum(DH_OUTCOMES)),
|
|
647
|
+
/** The failure code only. Raw failure messages are content. */
|
|
648
|
+
failureCode: Type.Optional(Type.String({ pattern: "^[a-z][a-z0-9_.-]{0,63}$", maxLength: 64 })),
|
|
649
|
+
coverage: Type.Array(strict({
|
|
650
|
+
path: Type.String({ minLength: 1, maxLength: 256 }),
|
|
651
|
+
state: Type.Enum(DH_COVERAGE_STATES),
|
|
652
|
+
reason: Type.Optional(Type.Enum(DH_COVERAGE_REASONS)),
|
|
653
|
+
}), { maxItems: 64 }),
|
|
654
|
+
artifactIds: Type.Array(DhArtifactIdSchema, { maxItems: 32, uniqueItems: true }),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
function assertEventMetadataView(view) {
|
|
658
|
+
const paths = view.coverage.map((entry) => entry.path);
|
|
659
|
+
assertUnique("event metadata coverage", paths);
|
|
660
|
+
for (const entry of view.coverage) {
|
|
661
|
+
if (!FIELD_PATH.test(entry.path) || entry.path.split(".").some((segment) => FORBIDDEN_KEYS.has(segment))) {
|
|
662
|
+
throw new Error("Invalid event metadata coverage: path is not a valid field path");
|
|
663
|
+
}
|
|
664
|
+
assertCoverageReason("event metadata coverage", entry);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
export const parseDhEventMetadataView = dtoParser("event metadata view", eventMetadataViewSchema, assertEventMetadataView);
|
|
668
|
+
function pageSchema(included, item) {
|
|
669
|
+
return strict({
|
|
670
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.eventPage),
|
|
671
|
+
cursorBinding: dhCursorBindingSchema(),
|
|
672
|
+
nextCursor: nextCursorSchema,
|
|
673
|
+
captureCutoff: timestamp,
|
|
674
|
+
contentIncluded: included,
|
|
675
|
+
items: Type.Array(item, { maxItems: 100 }),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const parseEventMetadataPage = dtoParser("event metadata page", () => pageSchema(Type.Literal(false), eventMetadataViewSchema()), (page) => {
|
|
679
|
+
assertDhReadPageBinding(page, "events", "metadata");
|
|
680
|
+
for (const item of page.items)
|
|
681
|
+
assertEventMetadataView(item);
|
|
682
|
+
assertUnique("event metadata page", page.items.map((item) => item.eventId));
|
|
683
|
+
});
|
|
684
|
+
const parseEventContentEnvelope = dtoParser("event content page", () => pageSchema(Type.Literal(true), Type.Unknown()), (page) => assertDhReadPageBinding(page, "events", "content"));
|
|
685
|
+
/** The metadata view never carries payload, raw failure messages, coverage detail or redaction paths; content pages are full dh.event/1. */
|
|
686
|
+
export function parseDhEventPage(value) {
|
|
687
|
+
assertDhPlainData(value);
|
|
688
|
+
if (isRecord(value) && value.contentIncluded === true) {
|
|
689
|
+
const envelope = parseEventContentEnvelope(value);
|
|
690
|
+
const items = Array.from(envelope.items, parseDhEvent);
|
|
691
|
+
assertUnique("event content page", items.map((item) => item.eventId));
|
|
692
|
+
assertDhReadPageBinding({ ...envelope, items }, "events", "content");
|
|
693
|
+
return deepFreeze({
|
|
694
|
+
schema: envelope.schema,
|
|
695
|
+
cursorBinding: envelope.cursorBinding,
|
|
696
|
+
nextCursor: envelope.nextCursor,
|
|
697
|
+
captureCutoff: envelope.captureCutoff,
|
|
698
|
+
contentIncluded: true,
|
|
699
|
+
items,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return parseEventMetadataPage(value);
|
|
703
|
+
}
|
|
704
|
+
export const DH_ARTIFACT_AVAILABILITY = ["available", "pending", "unavailable"];
|
|
705
|
+
function artifactMetadataViewSchema() {
|
|
706
|
+
return strict({
|
|
707
|
+
artifactId: DhArtifactIdSchema,
|
|
708
|
+
generationId: DhGenerationIdSchema,
|
|
709
|
+
agentId: DhAgentIdSchema,
|
|
710
|
+
attemptId: Type.Optional(DhAttemptIdSchema),
|
|
711
|
+
eventId: DhEventIdSchema,
|
|
712
|
+
kind: Type.Enum(DH_ARTIFACT_KINDS),
|
|
713
|
+
mediaType: Type.Enum(DH_ARTIFACT_MEDIA_TYPES),
|
|
714
|
+
sizeBytes: Type.Integer({ minimum: 0, maximum: DH_ARTIFACT_MAX_BYTES }),
|
|
715
|
+
sha256: digest,
|
|
716
|
+
/** available = bytes stored; pending = metadata acknowledged, bytes not yet; unavailable = never readable (lost, withheld or purged). */
|
|
717
|
+
availability: Type.Enum(DH_ARTIFACT_AVAILABILITY),
|
|
718
|
+
coverage: metadataCoverage(),
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
/** Excludes logicalPath (sensitive file names), redaction paths and coverage.detail. Bytes are a separate route and grant. */
|
|
722
|
+
export const parseDhArtifactMetadataView = dtoParser("artifact metadata view", artifactMetadataViewSchema, (view) => assertCoverageReason("artifact metadata view", view.coverage));
|
|
723
|
+
export const DH_CURSOR_RESOURCES = ["generations", "agents", "events", "manifest"];
|
|
724
|
+
function snapshotCutoffSchema() {
|
|
725
|
+
return strict({
|
|
726
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.snapshotCutoff),
|
|
727
|
+
snapshotId: prefixed("snp"),
|
|
728
|
+
receiptOrdinal: count,
|
|
729
|
+
captureCutoff: timestamp,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
export const parseDhSnapshotCutoff = dtoParser("snapshot cutoff", snapshotCutoffSchema);
|
|
733
|
+
/** Internal lazy schema seam shared by existing read-page parsers. */
|
|
734
|
+
export function dhCursorBindingSchema() {
|
|
735
|
+
return strict({
|
|
736
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.cursorBinding),
|
|
737
|
+
environmentId: scopeId,
|
|
738
|
+
projectId: scopeId,
|
|
739
|
+
resource: Type.Enum(DH_CURSOR_RESOURCES),
|
|
740
|
+
generationId: Type.Optional(DhGenerationIdSchema),
|
|
741
|
+
bindingId: Type.Optional(prefixed("bnd")),
|
|
742
|
+
filterSha256: digest,
|
|
743
|
+
view: Type.Union([Type.Literal("metadata"), Type.Literal("content")]),
|
|
744
|
+
cutoff: snapshotCutoffSchema(),
|
|
745
|
+
mappingRevision: digest,
|
|
746
|
+
authorizationRevision: refSchema,
|
|
747
|
+
principalId: scopeId,
|
|
748
|
+
grantId: scopeId,
|
|
749
|
+
sessionId: scopeId,
|
|
750
|
+
pageSize: Type.Integer({ minimum: 1, maximum: 1000 }),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/** What an opaque cursor is authenticated against (server-side); clients compare it across continuation pages. */
|
|
754
|
+
export const parseDhCursorBinding = dtoParser("cursor binding", dhCursorBindingSchema, (binding) => {
|
|
755
|
+
const projectList = binding.resource === "generations";
|
|
756
|
+
if (projectList
|
|
757
|
+
? binding.generationId !== undefined || binding.bindingId !== undefined
|
|
758
|
+
: binding.generationId === undefined || binding.bindingId === undefined)
|
|
759
|
+
throw new DhContractError("schema_invalid");
|
|
760
|
+
if (binding.resource !== "manifest" && binding.pageSize > 100)
|
|
761
|
+
throw new DhContractError("schema_invalid");
|
|
762
|
+
});
|
|
763
|
+
export function assertDhReadPageBinding(page, resource, view = "metadata") {
|
|
764
|
+
const binding = parseDhCursorBinding(page.cursorBinding);
|
|
765
|
+
if (binding.resource !== resource ||
|
|
766
|
+
binding.view !== view ||
|
|
767
|
+
binding.cutoff.captureCutoff !== page.captureCutoff ||
|
|
768
|
+
page.items.length > binding.pageSize)
|
|
769
|
+
throw new DhContractError("cursor_binding_mismatch");
|
|
770
|
+
for (const item of page.items) {
|
|
771
|
+
if (!isRecord(item))
|
|
772
|
+
continue;
|
|
773
|
+
if (resource !== "generations" && item.generationId !== binding.generationId)
|
|
774
|
+
throw new DhContractError("cursor_binding_mismatch");
|
|
775
|
+
if (isRecord(item.tenantScope) &&
|
|
776
|
+
(item.tenantScope.environmentId !== binding.environmentId || item.tenantScope.projectId !== binding.projectId))
|
|
777
|
+
throw new DhContractError("cursor_binding_mismatch");
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/** Pure supplied-context checks; opaque storage and authentication remain server responsibilities. */
|
|
781
|
+
export function assertDhCursorContext(context) {
|
|
782
|
+
assertDhPlainData(context);
|
|
783
|
+
if (!isCalendarInstant(context.now) || !isCalendarInstant(context.expiresAt))
|
|
784
|
+
throw new DhContractError("schema_invalid");
|
|
785
|
+
if (!context.registered)
|
|
786
|
+
throw new DhContractError("cursor_invalid");
|
|
787
|
+
if (Date.parse(context.now) >= Date.parse(context.expiresAt))
|
|
788
|
+
throw new DhContractError("cursor_expired");
|
|
789
|
+
}
|
|
790
|
+
export function assertDhCursorProgress(previous, next, seen = new Set()) {
|
|
791
|
+
if (next !== null &&
|
|
792
|
+
(typeof next !== "string" || !/^[\x21-\x7e]{1,1024}$/.test(next) || next === previous || seen.has(next)))
|
|
793
|
+
throw new DhContractError("cursor_invalid");
|
|
794
|
+
}
|
|
795
|
+
/** Every field, including the cutoff and view, must be identical across pages of one snapshot. */
|
|
796
|
+
export function assertDhCursorBinding(expected, actual) {
|
|
797
|
+
const a = parseDhCursorBinding(expected);
|
|
798
|
+
const b = parseDhCursorBinding(actual);
|
|
799
|
+
for (const key of ["principalId", "grantId", "sessionId", "authorizationRevision"])
|
|
800
|
+
if (a[key] !== b[key])
|
|
801
|
+
throw new DhContractError("authorization_changed");
|
|
802
|
+
if (canonicalText(a) !== canonicalText(b))
|
|
803
|
+
throw new DhContractError("cursor_binding_mismatch");
|
|
804
|
+
}
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
// Exports: create request, job status, sharded manifest
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
export const parseDhExportCreateRequest = dtoParser("export create request", () => strict({ includeContent: Type.Boolean(), acknowledgeNonRevocable: Type.Literal(true) }));
|
|
809
|
+
/** GET exports/{jobId}. `DhExportJob` (jobId+state) stays the POST 202 handle. */
|
|
810
|
+
export const parseDhExportJobStatus = dtoParser("export job status", () => strict({
|
|
811
|
+
jobId: jobIdSchema,
|
|
812
|
+
generationId: DhGenerationIdSchema,
|
|
813
|
+
state: Type.Enum(DH_EXPORT_STATES),
|
|
814
|
+
contentIncluded: Type.Boolean(),
|
|
815
|
+
captureCutoff: Type.Optional(timestamp),
|
|
816
|
+
}));
|
|
817
|
+
function exportContextSchema() {
|
|
818
|
+
return {
|
|
819
|
+
jobId: jobIdSchema,
|
|
820
|
+
generationId: DhGenerationIdSchema,
|
|
821
|
+
tenantScope: DhTenantScopeSchema,
|
|
822
|
+
bindingId: prefixed("bnd"),
|
|
823
|
+
cutoff: snapshotCutoffSchema(),
|
|
824
|
+
captureCutoff: timestamp,
|
|
825
|
+
contentIncluded: Type.Boolean(),
|
|
826
|
+
policyRevision: refSchema,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function exportManifestHeaderSchema() {
|
|
830
|
+
return strict({
|
|
831
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.exportManifestHeader),
|
|
832
|
+
...exportContextSchema(),
|
|
833
|
+
canonicalProfile: Type.Literal("dh.canonical-jcs/1"),
|
|
834
|
+
state: Type.Literal("sealed"),
|
|
835
|
+
eventCount: count,
|
|
836
|
+
artifactCount: count,
|
|
837
|
+
streamCount: count,
|
|
838
|
+
omittedArtifactCount: count,
|
|
839
|
+
shardCount: Type.Integer({ minimum: 0, maximum: 1024 }),
|
|
840
|
+
shards: Type.Array(strict({
|
|
841
|
+
index: Type.Integer({ minimum: 0, maximum: 1023 }),
|
|
842
|
+
recordCount: Type.Integer({ minimum: 1, maximum: 1000 }),
|
|
843
|
+
sha256: digest,
|
|
844
|
+
}), { maxItems: 1024 }),
|
|
845
|
+
manifestSha256: digest,
|
|
846
|
+
coverage: metadataCoverage(),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function assertManifestSize(value) {
|
|
850
|
+
assertDhPlainData(value);
|
|
851
|
+
if (isRecord(value) &&
|
|
852
|
+
((typeof value.shardCount === "number" && value.shardCount > 1024) ||
|
|
853
|
+
(Array.isArray(value.shards) && value.shards.length > 1024) ||
|
|
854
|
+
(Array.isArray(value.entries) && value.entries.length > 1000)))
|
|
855
|
+
throw new DhContractError("too_large");
|
|
856
|
+
if (new TextEncoder().encode(canonicalText(value)).length > 1048576)
|
|
857
|
+
throw new DhContractError("too_large");
|
|
858
|
+
}
|
|
859
|
+
const parseManifestHeader = dtoParser("export manifest header", exportManifestHeaderSchema, (header) => {
|
|
860
|
+
const total = header.eventCount + header.artifactCount + header.streamCount;
|
|
861
|
+
if (!Number.isSafeInteger(total) ||
|
|
862
|
+
header.shards.length !== header.shardCount ||
|
|
863
|
+
header.shards.some((shard, index) => shard.index !== index) ||
|
|
864
|
+
total !== header.shards.reduce((sum, shard) => sum + shard.recordCount, 0) ||
|
|
865
|
+
header.omittedArtifactCount > header.artifactCount ||
|
|
866
|
+
header.captureCutoff !== header.cutoff.captureCutoff)
|
|
867
|
+
throw new DhContractError("manifest_invalid");
|
|
868
|
+
assertCoverageReason("export manifest header", header.coverage);
|
|
869
|
+
});
|
|
870
|
+
export function parseDhExportManifestHeader(value) {
|
|
871
|
+
assertManifestSize(value);
|
|
872
|
+
return parseManifestHeader(value);
|
|
873
|
+
}
|
|
874
|
+
function manifestEntrySchema() {
|
|
875
|
+
const owner = { bindingId: prefixed("bnd"), originProducerInstanceId: prefixed("prd") };
|
|
876
|
+
const record = {
|
|
877
|
+
...owner,
|
|
878
|
+
originStreamId: streamIdSchema,
|
|
879
|
+
representation: Type.Enum(["metadata", "content"]),
|
|
880
|
+
recordSha256: digest,
|
|
881
|
+
};
|
|
882
|
+
return Type.Union([
|
|
883
|
+
strict({ kind: Type.Literal("event"), id: DhEventIdSchema, ...record }),
|
|
884
|
+
strict({
|
|
885
|
+
kind: Type.Literal("artifact"),
|
|
886
|
+
id: DhArtifactIdSchema,
|
|
887
|
+
...record,
|
|
888
|
+
sha256: digest,
|
|
889
|
+
sizeBytes: Type.Integer({ minimum: 0, maximum: 67108864 }),
|
|
890
|
+
bytes: Type.Enum(["included", "omitted", "unavailable"]),
|
|
891
|
+
reason: Type.Optional(Type.Enum(["policy_redacted", "producer_lost", "size_limit", "not_captured_by_version"])),
|
|
892
|
+
}),
|
|
893
|
+
strict({
|
|
894
|
+
kind: Type.Literal("stream"),
|
|
895
|
+
id: streamIdSchema,
|
|
896
|
+
...owner,
|
|
897
|
+
prefixThroughSequence: Type.Integer({ minimum: -1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
898
|
+
state: Type.Enum(["open", "closed", "lost", "disputed"]),
|
|
899
|
+
}),
|
|
900
|
+
]);
|
|
901
|
+
}
|
|
902
|
+
function exportManifestPageSchema() {
|
|
903
|
+
return strict({
|
|
904
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.exportManifestPage),
|
|
905
|
+
...exportContextSchema(),
|
|
906
|
+
shardIndex: Type.Integer({ minimum: 0, maximum: 1023 }),
|
|
907
|
+
sha256: digest,
|
|
908
|
+
entries: Type.Array(manifestEntrySchema(), { minItems: 1, maxItems: 1000 }),
|
|
909
|
+
nextCursor: nextCursorSchema,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
const parseManifestPage = dtoParser("export manifest page", exportManifestPageSchema, (page) => {
|
|
913
|
+
if (page.captureCutoff !== page.cutoff.captureCutoff)
|
|
914
|
+
throw new DhContractError("manifest_integrity");
|
|
915
|
+
let previous = "";
|
|
916
|
+
for (const entry of page.entries) {
|
|
917
|
+
const key = `${entry.kind}\0${entry.id}`;
|
|
918
|
+
if (key <= previous)
|
|
919
|
+
throw new DhContractError("manifest_invalid");
|
|
920
|
+
previous = key;
|
|
921
|
+
if (entry.kind !== "stream" && entry.representation !== (page.contentIncluded ? "content" : "metadata"))
|
|
922
|
+
throw new DhContractError("manifest_invalid");
|
|
923
|
+
if (entry.kind === "artifact" &&
|
|
924
|
+
((entry.bytes === "included") !== (entry.reason === undefined) ||
|
|
925
|
+
(!page.contentIncluded && entry.bytes === "included")))
|
|
926
|
+
throw new DhContractError("manifest_invalid");
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
export function parseDhExportManifestPage(value) {
|
|
930
|
+
assertManifestSize(value);
|
|
931
|
+
return parseManifestPage(value);
|
|
932
|
+
}
|
|
933
|
+
/** Navigation is outside the immutable sealed header. */
|
|
934
|
+
export const parseDhExportManifestResponse = dtoParser("export manifest response", () => strict({
|
|
935
|
+
header: exportManifestHeaderSchema(),
|
|
936
|
+
firstShardCursor: nextCursorSchema,
|
|
937
|
+
cursorBinding: dhCursorBindingSchema(),
|
|
938
|
+
}), (response) => {
|
|
939
|
+
const header = parseDhExportManifestHeader(response.header), binding = parseDhCursorBinding(response.cursorBinding);
|
|
940
|
+
if ((header.shardCount === 0) !== (response.firstShardCursor === null))
|
|
941
|
+
throw new DhContractError("manifest_invalid");
|
|
942
|
+
if (binding.resource !== "manifest" ||
|
|
943
|
+
binding.generationId !== header.generationId ||
|
|
944
|
+
binding.bindingId !== header.bindingId ||
|
|
945
|
+
binding.environmentId !== header.tenantScope.environmentId ||
|
|
946
|
+
binding.projectId !== header.tenantScope.projectId ||
|
|
947
|
+
binding.view !== (header.contentIncluded ? "content" : "metadata") ||
|
|
948
|
+
canonicalText(binding.cutoff) !== canonicalText(header.cutoff))
|
|
949
|
+
throw new DhContractError("manifest_integrity");
|
|
950
|
+
});
|
|
951
|
+
/** JCS shard body; hash its domain-separated bytes, never navigation cursors. */
|
|
952
|
+
export function dhManifestShardCanonicalText(page) {
|
|
953
|
+
const { sha256: _hash, nextCursor: _cursor, ...body } = parseDhExportManifestPage(page);
|
|
954
|
+
return canonicalText(body);
|
|
955
|
+
}
|
|
956
|
+
/** Convenience over the same bounded incremental algorithm. */
|
|
957
|
+
export function validateDhExportManifest(headerInput, pageInputs, options) {
|
|
958
|
+
const state = beginDhExportManifest(headerInput, options);
|
|
959
|
+
if (pageInputs.length !== state.header.shardCount)
|
|
960
|
+
throw new DhContractError("manifest_invalid");
|
|
961
|
+
const pages = Array.from(pageInputs, (page) => acceptDhExportManifestPage(state, page));
|
|
962
|
+
return { header: finishDhExportManifest(state), pages };
|
|
963
|
+
}
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// Controls
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
export const DH_CONTROL_ACTIONS = ["cancel", "retry", "continue"];
|
|
968
|
+
/** Acceptance, delivery and application are not termination: terminationConfirmed is a separate observation. */
|
|
969
|
+
export const DH_CONTROL_STATES = [
|
|
970
|
+
"requested",
|
|
971
|
+
"accepted",
|
|
972
|
+
"delivered",
|
|
973
|
+
"applied",
|
|
974
|
+
"rejected",
|
|
975
|
+
"unsupported",
|
|
976
|
+
"unknown",
|
|
977
|
+
];
|
|
978
|
+
export const DH_CONTROL_REJECT_REASONS = [
|
|
979
|
+
"not_authorized",
|
|
980
|
+
"scope_mismatch",
|
|
981
|
+
"stale_epoch",
|
|
982
|
+
"target_settled",
|
|
983
|
+
"not_eligible",
|
|
984
|
+
"policy_denied",
|
|
985
|
+
"budget_exhausted",
|
|
986
|
+
"workspace_unavailable",
|
|
987
|
+
];
|
|
988
|
+
const controlRoutes = { controlCancel: "cancel", controlRetry: "retry", controlContinue: "continue" };
|
|
989
|
+
function controlRequestSchema() {
|
|
990
|
+
const fenced = (action, payload) => strict({
|
|
991
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controlRequest),
|
|
992
|
+
requestId: requestIdSchema(),
|
|
993
|
+
idempotencyKey: Type.String({ pattern: "^[A-Za-z0-9_-]{16,128}$" }),
|
|
994
|
+
generationId: DhGenerationIdSchema,
|
|
995
|
+
agentId: DhAgentIdSchema,
|
|
996
|
+
attemptId: DhAttemptIdSchema,
|
|
997
|
+
bindingId: prefixed("bnd"),
|
|
998
|
+
bindingEpoch: refSchema,
|
|
999
|
+
sessionEpoch: refSchema,
|
|
1000
|
+
capabilityRevision: digest,
|
|
1001
|
+
action: Type.Literal(action),
|
|
1002
|
+
payload,
|
|
1003
|
+
});
|
|
1004
|
+
return Type.Union([
|
|
1005
|
+
fenced("cancel", strict({ scope: Type.Union([Type.Literal("selected"), Type.Literal("whole-run")]) })),
|
|
1006
|
+
fenced("retry", strict({
|
|
1007
|
+
sourceAttemptId: DhAttemptIdSchema,
|
|
1008
|
+
/** new-attempt: same agent, new att_ (Flow/CLI worker). new-agent: new agent + cross-agent relation (Code native retry). */
|
|
1009
|
+
relation: Type.Union([Type.Literal("new-attempt"), Type.Literal("new-agent")]),
|
|
1010
|
+
/** Literal on purpose: a retry cannot drop model/context/workspace policy or reset cumulative spend. */
|
|
1011
|
+
policy: strict({
|
|
1012
|
+
model: Type.Literal("retain"),
|
|
1013
|
+
context: Type.Literal("retain"),
|
|
1014
|
+
workspace: Type.Literal("retain"),
|
|
1015
|
+
spend: Type.Literal("cumulative"),
|
|
1016
|
+
}),
|
|
1017
|
+
})),
|
|
1018
|
+
fenced("continue", strict({ message: Type.String({ minLength: 1, maxLength: DH_CONTROL_MESSAGE_MAX_CHARS }) })),
|
|
1019
|
+
]);
|
|
1020
|
+
}
|
|
1021
|
+
export const parseDhControlRequest = dtoParser("control request", controlRequestSchema, (request) => {
|
|
1022
|
+
if (new TextEncoder().encode(canonicalText(request)).length > 65536)
|
|
1023
|
+
throw new DhContractError("too_large");
|
|
1024
|
+
if (request.action === "retry" && request.payload.sourceAttemptId !== request.attemptId)
|
|
1025
|
+
throw new DhContractError("schema_invalid");
|
|
1026
|
+
});
|
|
1027
|
+
function expectAction(value, action) {
|
|
1028
|
+
const request = parseDhControlRequest(value);
|
|
1029
|
+
if (request.action !== action)
|
|
1030
|
+
throw new Error(`Expected a ${action} control request`);
|
|
1031
|
+
return request;
|
|
1032
|
+
}
|
|
1033
|
+
export const parseDhCancelRequest = (value) => expectAction(value, "cancel");
|
|
1034
|
+
export const parseDhRetryRequest = (value) => expectAction(value, "retry");
|
|
1035
|
+
export const parseDhContinueRequest = (value) => expectAction(value, "continue");
|
|
1036
|
+
/** A different generation/agent is a scope error; a replaced attempt or binding epoch is stale. Never apply a stale fence. */
|
|
1037
|
+
export function checkDhControlFence(request, live) {
|
|
1038
|
+
assertDhPlainData(request);
|
|
1039
|
+
assertDhPlainData(live);
|
|
1040
|
+
if (request.generationId !== live.generationId || request.agentId !== live.agentId)
|
|
1041
|
+
return "scope_mismatch";
|
|
1042
|
+
if (request.attemptId !== live.attemptId ||
|
|
1043
|
+
request.bindingId !== live.bindingId ||
|
|
1044
|
+
request.bindingEpoch !== live.bindingEpoch ||
|
|
1045
|
+
request.sessionEpoch !== live.sessionEpoch)
|
|
1046
|
+
return "stale_epoch";
|
|
1047
|
+
if (request.capabilityRevision !== live.capabilityRevision)
|
|
1048
|
+
return "policy_denied";
|
|
1049
|
+
return "ok";
|
|
1050
|
+
}
|
|
1051
|
+
/** The POST body must match the action-specific route and the path's generation/agent. */
|
|
1052
|
+
export function assertDhControlRequestForRoute(id, params, request) {
|
|
1053
|
+
const action = Object.hasOwn(controlRoutes, id) ? controlRoutes[id] : undefined;
|
|
1054
|
+
if (action === undefined)
|
|
1055
|
+
throw new DhContractError("schema_invalid", "Route is not a control action route");
|
|
1056
|
+
buildDhRoutePath(id, params);
|
|
1057
|
+
const parsed = expectAction(request, action);
|
|
1058
|
+
if (parsed.generationId !== params.generationId || parsed.agentId !== params.agentId) {
|
|
1059
|
+
throw new Error("Control request does not match the route generation or agent");
|
|
1060
|
+
}
|
|
1061
|
+
return parsed;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Idempotency: entries with the same actor, environment/project, generation, agent, action and key are
|
|
1065
|
+
* one request. Then a byte-equal canonical body (request id, attempt, epoch, payload) is a replay (`same`);
|
|
1066
|
+
* anything else under that key is a `conflict` (HTTP 409, no second effect). Other keys/actors/scopes/targets are `distinct`.
|
|
1067
|
+
*/
|
|
1068
|
+
export function compareDhControlReplay(stored, incoming) {
|
|
1069
|
+
assertDhPlainData(stored);
|
|
1070
|
+
assertDhPlainData(incoming);
|
|
1071
|
+
if (![
|
|
1072
|
+
stored.actor,
|
|
1073
|
+
incoming.actor,
|
|
1074
|
+
stored.authorityId,
|
|
1075
|
+
incoming.authorityId,
|
|
1076
|
+
stored.tenantId,
|
|
1077
|
+
incoming.tenantId,
|
|
1078
|
+
].every((value) => typeof value === "string" && new RegExp(SCOPE_ID).test(value)))
|
|
1079
|
+
throw new DhContractError("schema_invalid");
|
|
1080
|
+
const a = parseDhControlRequest(stored.request);
|
|
1081
|
+
const b = parseDhControlRequest(incoming.request);
|
|
1082
|
+
const lookup = (entry, request) => canonicalText([
|
|
1083
|
+
entry.actor,
|
|
1084
|
+
entry.authorityId,
|
|
1085
|
+
entry.tenantId,
|
|
1086
|
+
entry.scope.environmentId,
|
|
1087
|
+
entry.scope.projectId,
|
|
1088
|
+
request.generationId,
|
|
1089
|
+
request.agentId,
|
|
1090
|
+
request.action,
|
|
1091
|
+
request.idempotencyKey,
|
|
1092
|
+
]);
|
|
1093
|
+
if (lookup(stored, a) !== lookup(incoming, b))
|
|
1094
|
+
return "distinct";
|
|
1095
|
+
return canonicalText(a) === canonicalText(b) ? "same" : "conflict";
|
|
1096
|
+
}
|
|
1097
|
+
function assertRejectedReason(label, value) {
|
|
1098
|
+
if ((value.state === "rejected") !== (value.reason !== undefined)) {
|
|
1099
|
+
throw new Error(`Invalid ${label}: a reason is required for, and only for, state rejected`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const controlStateSchema = () => Type.Enum(DH_CONTROL_STATES);
|
|
1103
|
+
const controlActionSchema = () => Type.Enum(DH_CONTROL_ACTIONS);
|
|
1104
|
+
const rejectReasonSchema = () => Type.Enum(DH_CONTROL_REJECT_REASONS);
|
|
1105
|
+
/** POST response: a correlated handle, not an effect. */
|
|
1106
|
+
export const parseDhControlReceipt = dtoParser("control receipt", () => strict({
|
|
1107
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controlReceipt),
|
|
1108
|
+
requestId: requestIdSchema(),
|
|
1109
|
+
action: controlActionSchema(),
|
|
1110
|
+
state: controlStateSchema(),
|
|
1111
|
+
reason: Type.Optional(rejectReasonSchema()),
|
|
1112
|
+
}), (receipt) => assertRejectedReason("control receipt", receipt));
|
|
1113
|
+
export const parseDhControlRequestStatus = dtoParser("control request status", () => strict({
|
|
1114
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controlStatus),
|
|
1115
|
+
requestId: requestIdSchema(),
|
|
1116
|
+
action: controlActionSchema(),
|
|
1117
|
+
generationId: DhGenerationIdSchema,
|
|
1118
|
+
agentId: DhAgentIdSchema,
|
|
1119
|
+
attemptId: DhAttemptIdSchema,
|
|
1120
|
+
bindingId: prefixed("bnd"),
|
|
1121
|
+
bindingEpoch: refSchema,
|
|
1122
|
+
sessionEpoch: refSchema,
|
|
1123
|
+
capabilityRevision: digest,
|
|
1124
|
+
requestSha256: digest,
|
|
1125
|
+
state: controlStateSchema(),
|
|
1126
|
+
reason: Type.Optional(rejectReasonSchema()),
|
|
1127
|
+
/** Cancel only: an observed fact, independent of `state`. Only an applied cancel can confirm it. */
|
|
1128
|
+
terminationConfirmed: Type.Optional(Type.Boolean()),
|
|
1129
|
+
observed: Type.Optional(strict({
|
|
1130
|
+
startedAgentId: Type.Optional(DhAgentIdSchema),
|
|
1131
|
+
startedAttemptId: Type.Optional(DhAttemptIdSchema),
|
|
1132
|
+
consumedByEventId: Type.Optional(DhEventIdSchema),
|
|
1133
|
+
})),
|
|
1134
|
+
updatedAt: timestamp,
|
|
1135
|
+
}), (status) => {
|
|
1136
|
+
assertRejectedReason("control request status", status);
|
|
1137
|
+
if (status.action === "cancel") {
|
|
1138
|
+
if (status.terminationConfirmed === undefined) {
|
|
1139
|
+
throw new Error("Invalid control request status: cancel requires terminationConfirmed");
|
|
1140
|
+
}
|
|
1141
|
+
if (status.terminationConfirmed && status.state !== "applied") {
|
|
1142
|
+
throw new Error("Invalid control request status: termination can only be confirmed after an applied cancel");
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
else if (status.terminationConfirmed !== undefined) {
|
|
1146
|
+
throw new Error("Invalid control request status: terminationConfirmed applies to cancel only");
|
|
1147
|
+
}
|
|
1148
|
+
const observed = status.observed;
|
|
1149
|
+
if (status.state === "applied" &&
|
|
1150
|
+
((status.action === "retry" &&
|
|
1151
|
+
(observed?.startedAgentId === undefined || observed.startedAttemptId === undefined)) ||
|
|
1152
|
+
(status.action === "continue" && observed?.consumedByEventId === undefined)))
|
|
1153
|
+
throw new DhContractError("schema_invalid");
|
|
1154
|
+
if (observed === undefined)
|
|
1155
|
+
return;
|
|
1156
|
+
const keys = Object.keys(observed);
|
|
1157
|
+
const allowed = status.action === "retry"
|
|
1158
|
+
? ["startedAgentId", "startedAttemptId"]
|
|
1159
|
+
: status.action === "continue"
|
|
1160
|
+
? ["consumedByEventId"]
|
|
1161
|
+
: [];
|
|
1162
|
+
if (status.state !== "applied" || keys.length === 0 || keys.some((key) => !allowed.includes(key))) {
|
|
1163
|
+
throw new Error("Invalid control request status: observed outcome does not fit the action or state");
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
function controlTargetProperties() {
|
|
1167
|
+
return {
|
|
1168
|
+
generationId: DhGenerationIdSchema,
|
|
1169
|
+
agentId: DhAgentIdSchema,
|
|
1170
|
+
attemptId: DhAttemptIdSchema,
|
|
1171
|
+
bindingId: prefixed("bnd"),
|
|
1172
|
+
bindingEpoch: refSchema,
|
|
1173
|
+
sessionEpoch: refSchema,
|
|
1174
|
+
capabilityRevision: digest,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
export const parseDhControlTarget = dtoParser("control target", () => strict({
|
|
1178
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controlTarget),
|
|
1179
|
+
...controlTargetProperties(),
|
|
1180
|
+
updatedAt: timestamp,
|
|
1181
|
+
}));
|
|
1182
|
+
export const parseDhControlHandshake = dtoParser("control handshake", () => strict({
|
|
1183
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controlHandshake),
|
|
1184
|
+
bindingId: prefixed("bnd"),
|
|
1185
|
+
sessionEpoch: refSchema,
|
|
1186
|
+
token: Type.String({ pattern: "^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" }),
|
|
1187
|
+
}));
|
|
1188
|
+
export const parseDhControlDelivery = dtoParser("control delivery", () => strict({
|
|
1189
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controlDelivery),
|
|
1190
|
+
request: controlRequestSchema(),
|
|
1191
|
+
tenantScope: DhTenantScopeSchema,
|
|
1192
|
+
actorId: scopeId,
|
|
1193
|
+
authorityId: scopeId,
|
|
1194
|
+
authorizationRevision: refSchema,
|
|
1195
|
+
authorizedAt: timestamp,
|
|
1196
|
+
expiresAt: timestamp,
|
|
1197
|
+
requestSha256: digest,
|
|
1198
|
+
}), (delivery) => {
|
|
1199
|
+
parseDhControlRequest(delivery.request);
|
|
1200
|
+
const lifetime = Date.parse(delivery.expiresAt) - Date.parse(delivery.authorizedAt);
|
|
1201
|
+
if (lifetime <= 0 || lifetime > 30000)
|
|
1202
|
+
throw new DhContractError("schema_invalid");
|
|
1203
|
+
});
|
|
1204
|
+
export function assertDhControlDelivery(input, context, options) {
|
|
1205
|
+
const delivery = parseDhControlDelivery(input);
|
|
1206
|
+
assertDhPlainData(context);
|
|
1207
|
+
if (!isCalendarInstant(context.now))
|
|
1208
|
+
throw new DhContractError("schema_invalid");
|
|
1209
|
+
if (context.authorized !== true ||
|
|
1210
|
+
context.actorId !== delivery.actorId ||
|
|
1211
|
+
context.authorityId !== delivery.authorityId ||
|
|
1212
|
+
context.authorizationRevision !== delivery.authorizationRevision ||
|
|
1213
|
+
Date.parse(context.now) < Date.parse(delivery.authorizedAt) ||
|
|
1214
|
+
Date.parse(context.now) >= Date.parse(delivery.expiresAt))
|
|
1215
|
+
throw new DhContractError("not_authorized");
|
|
1216
|
+
if (canonicalText(context.scope) !== canonicalText(delivery.tenantScope))
|
|
1217
|
+
throw new DhContractError("scope_mismatch");
|
|
1218
|
+
const fence = checkDhControlFence(delivery.request, context.live);
|
|
1219
|
+
if (fence !== "ok")
|
|
1220
|
+
throw new DhContractError(fence);
|
|
1221
|
+
if (typeof options?.sha256Hex !== "function" ||
|
|
1222
|
+
options.sha256Hex(dhDigestInput("dh.control-request/2", delivery.request)) !== delivery.requestSha256)
|
|
1223
|
+
throw new DhContractError("control_integrity");
|
|
1224
|
+
}
|
|
1225
|
+
/** Non-secret registry descriptor only. The bearer is held privately and never serialized here. */
|
|
1226
|
+
export const parseDhControllerCredential = dtoParser("controller credential", () => strict({
|
|
1227
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.controllerCredential),
|
|
1228
|
+
authorityId: scopeId,
|
|
1229
|
+
actorId: scopeId,
|
|
1230
|
+
bindingId: prefixed("bnd"),
|
|
1231
|
+
targets: Type.Array(strict({ generationId: DhGenerationIdSchema, agentId: DhAgentIdSchema, attemptId: DhAttemptIdSchema }), { minItems: 1, maxItems: 128, uniqueItems: true }),
|
|
1232
|
+
actions: strict({
|
|
1233
|
+
cancelSelected: Type.Boolean(),
|
|
1234
|
+
cancelWholeRun: Type.Boolean(),
|
|
1235
|
+
continueLive: Type.Boolean(),
|
|
1236
|
+
retryFresh: Type.Boolean(),
|
|
1237
|
+
}),
|
|
1238
|
+
statusRead: Type.Boolean(),
|
|
1239
|
+
discoverTarget: Type.Boolean(),
|
|
1240
|
+
sessionVersion: count,
|
|
1241
|
+
credentialVersion: positive,
|
|
1242
|
+
issuedAt: timestamp,
|
|
1243
|
+
expiresAt: timestamp,
|
|
1244
|
+
}), (credential) => {
|
|
1245
|
+
const lifetime = Date.parse(credential.expiresAt) - Date.parse(credential.issuedAt);
|
|
1246
|
+
if (lifetime <= 0 || lifetime > 300000)
|
|
1247
|
+
throw new DhContractError("schema_invalid");
|
|
1248
|
+
});
|
|
1249
|
+
export function assertDhControllerAction(input, targetInput, action, context) {
|
|
1250
|
+
const credential = parseDhControllerCredential(input), target = parseDhControlTarget(targetInput);
|
|
1251
|
+
assertDhPlainData(context);
|
|
1252
|
+
if (!isCalendarInstant(context.now))
|
|
1253
|
+
throw new DhContractError("schema_invalid");
|
|
1254
|
+
if (context.membershipActive !== true ||
|
|
1255
|
+
context.liveVersion !== credential.credentialVersion ||
|
|
1256
|
+
context.sessionVersion !== credential.sessionVersion ||
|
|
1257
|
+
Date.parse(context.now) < Date.parse(credential.issuedAt) ||
|
|
1258
|
+
Date.parse(context.now) >= Date.parse(credential.expiresAt) ||
|
|
1259
|
+
credential.bindingId !== target.bindingId ||
|
|
1260
|
+
credential.actions[action] !== true ||
|
|
1261
|
+
!credential.targets.some((t) => t.generationId === target.generationId && t.agentId === target.agentId && t.attemptId === target.attemptId))
|
|
1262
|
+
throw new DhContractError("not_authorized");
|
|
1263
|
+
}
|
|
1264
|
+
export function assertDhControlStatusEvidence(input, events) {
|
|
1265
|
+
const status = parseDhControlRequestStatus(input);
|
|
1266
|
+
if (status.state !== "applied" || status.action === "cancel")
|
|
1267
|
+
return;
|
|
1268
|
+
assertDhAppliedControlEvidence(status, status.action, {
|
|
1269
|
+
startedAgentId: status.observed?.startedAgentId,
|
|
1270
|
+
startedAttemptId: status.observed?.startedAttemptId,
|
|
1271
|
+
consumingEventId: status.observed?.consumedByEventId,
|
|
1272
|
+
}, events);
|
|
1273
|
+
}
|
|
1274
|
+
/** Socket/fd3 message framing only; no I/O or transport authority is implemented. */
|
|
1275
|
+
export function parseDhControlFrame(bytes) {
|
|
1276
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 5)
|
|
1277
|
+
throw new DhContractError("schema_invalid");
|
|
1278
|
+
const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, false);
|
|
1279
|
+
if (length > 65536)
|
|
1280
|
+
throw new DhContractError("too_large");
|
|
1281
|
+
if (length !== bytes.length - 4)
|
|
1282
|
+
throw new DhContractError("schema_invalid");
|
|
1283
|
+
const value = parseDhRawJson(bytes.subarray(4), { maxBytes: 65536 });
|
|
1284
|
+
return isRecord(value) && value.schema === DH_HTTP_SCHEMA_IDS.controlHandshake
|
|
1285
|
+
? parseDhControlHandshake(value)
|
|
1286
|
+
: parseDhControlDelivery(value);
|
|
1287
|
+
}
|
|
1288
|
+
export function dhControlFrameBytes(input) {
|
|
1289
|
+
assertDhPlainData(input);
|
|
1290
|
+
const value = isRecord(input) && input.schema === DH_HTTP_SCHEMA_IDS.controlHandshake
|
|
1291
|
+
? parseDhControlHandshake(input)
|
|
1292
|
+
: parseDhControlDelivery(input);
|
|
1293
|
+
const payload = new TextEncoder().encode(canonicalText(value));
|
|
1294
|
+
if (payload.length > 65536)
|
|
1295
|
+
throw new DhContractError("too_large");
|
|
1296
|
+
const bytes = new Uint8Array(4 + payload.length);
|
|
1297
|
+
new DataView(bytes.buffer).setUint32(0, payload.length, false);
|
|
1298
|
+
bytes.set(payload, 4);
|
|
1299
|
+
return bytes;
|
|
1300
|
+
}
|
|
1301
|
+
// ---------------------------------------------------------------------------
|
|
1302
|
+
// ACK semantics
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
/** POST-local reply context only: never an assertion of the collector's durable prefix. */
|
|
1305
|
+
export function parseDhPreviousAckedSequenceHeader(value) {
|
|
1306
|
+
if (typeof value !== "string" || !/^(?:-1|0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
1307
|
+
throw new DhContractError("schema_invalid");
|
|
1308
|
+
return Number(value);
|
|
1309
|
+
}
|
|
1310
|
+
const parseAckSentView = dtoParser("sent ACK view", () => strict({
|
|
1311
|
+
streamId: streamIdSchema,
|
|
1312
|
+
events: Type.Array(strict({ eventId: DhEventIdSchema, sequence: count }), { maxItems: 500 }),
|
|
1313
|
+
artifacts: Type.Array(strict({ artifactId: DhArtifactIdSchema }), { maxItems: 500 }),
|
|
1314
|
+
}));
|
|
1315
|
+
/** Validate at the transport boundary before invoking the unchanged prefix checker. Full batches are allowed. */
|
|
1316
|
+
export function assertDhAckSentBatch(sent) {
|
|
1317
|
+
try {
|
|
1318
|
+
assertDhPlainData(sent);
|
|
1319
|
+
const view = parseAckSentView({
|
|
1320
|
+
streamId: sent.streamId,
|
|
1321
|
+
events: sent.events.map(({ eventId, sequence }) => ({ eventId, sequence })),
|
|
1322
|
+
artifacts: sent.artifacts.map(({ artifactId }) => ({ artifactId })),
|
|
1323
|
+
});
|
|
1324
|
+
if (view.events.length + view.artifacts.length > 500 ||
|
|
1325
|
+
new Set(view.events.map((event) => event.eventId)).size !== view.events.length ||
|
|
1326
|
+
new Set(view.events.map((event) => event.sequence)).size !== view.events.length ||
|
|
1327
|
+
new Set(view.artifacts.map((artifact) => artifact.artifactId)).size !== view.artifacts.length)
|
|
1328
|
+
throw new DhContractError("ack_invalid");
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
throw new DhContractError("ack_invalid");
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/** Value of `previousAckedSequence` when no event has been acknowledged yet (the uploader cursor's initial `-1`). */
|
|
1335
|
+
export const DH_ACK_NO_PREVIOUS_SEQUENCE = -1;
|
|
1336
|
+
/**
|
|
1337
|
+
* Validates a dh.ack/1 against the batch that was actually sent. The ACK must name the same stream and
|
|
1338
|
+
* only sent ids; accepted and rejected are disjoint; accepted events are exactly the first k sent events
|
|
1339
|
+
* by sequence (k may be 0).
|
|
1340
|
+
*
|
|
1341
|
+
* `previousAckedSequence` is REQUIRED: the stream's current acknowledged watermark, or
|
|
1342
|
+
* DH_ACK_NO_PREVIOUS_SEQUENCE (-1) when nothing is acknowledged yet. The accepted prefix must be gap-free
|
|
1343
|
+
* and start at previous + 1, so `[0,10,20]` cannot be acknowledged as one prefix. For k > 0,
|
|
1344
|
+
* ackedThroughSequence is the prefix's last sequence. For k = 0 it must repeat the previous watermark
|
|
1345
|
+
* (0 when previous is -1, because dh.ack/1 cannot carry a negative value and the empty accepted list
|
|
1346
|
+
* disambiguates), so an empty ACK can neither advance nor regress the stream.
|
|
1347
|
+
*/
|
|
1348
|
+
export function validateDhBatchAck(sent, ackInput, options) {
|
|
1349
|
+
const previous = options?.previousAckedSequence;
|
|
1350
|
+
if (typeof previous !== "number" || !Number.isSafeInteger(previous) || previous < DH_ACK_NO_PREVIOUS_SEQUENCE) {
|
|
1351
|
+
throw new Error("Invalid trace acknowledgement check: previousAckedSequence must be an integer >= -1");
|
|
1352
|
+
}
|
|
1353
|
+
const ack = parseDhIngestAck(ackInput);
|
|
1354
|
+
if (ack.streamId !== sent.streamId)
|
|
1355
|
+
throw new Error("Invalid trace acknowledgement: stream does not match the sent batch");
|
|
1356
|
+
const events = [...sent.events].sort((a, b) => a.sequence - b.sequence);
|
|
1357
|
+
events.forEach((event, index) => {
|
|
1358
|
+
const previous = events[index - 1];
|
|
1359
|
+
if (previous !== undefined && previous.sequence >= event.sequence)
|
|
1360
|
+
throw new Error("Sent batch repeats a sequence");
|
|
1361
|
+
});
|
|
1362
|
+
const sentEvents = new Set(events.map((event) => event.eventId));
|
|
1363
|
+
const sentArtifacts = new Set(sent.artifacts.map((artifact) => artifact.artifactId));
|
|
1364
|
+
if (sentEvents.size !== events.length || sentArtifacts.size !== sent.artifacts.length) {
|
|
1365
|
+
throw new Error("Sent batch repeats an id");
|
|
1366
|
+
}
|
|
1367
|
+
for (const id of ack.accepted.eventIds)
|
|
1368
|
+
if (!sentEvents.has(id))
|
|
1369
|
+
throw new Error("Trace acknowledgement accepts an event that was not sent");
|
|
1370
|
+
for (const id of ack.accepted.artifactIds) {
|
|
1371
|
+
if (!sentArtifacts.has(id))
|
|
1372
|
+
throw new Error("Trace acknowledgement accepts an artifact that was not sent");
|
|
1373
|
+
}
|
|
1374
|
+
for (const entry of ack.rejected) {
|
|
1375
|
+
if (!sentEvents.has(entry.id) && !sentArtifacts.has(entry.id))
|
|
1376
|
+
throw new Error("Trace acknowledgement rejects an id that was not sent");
|
|
1377
|
+
}
|
|
1378
|
+
const prefix = events.slice(0, ack.accepted.eventIds.length);
|
|
1379
|
+
const accepted = new Set(ack.accepted.eventIds);
|
|
1380
|
+
if (prefix.some((event) => !accepted.has(event.eventId))) {
|
|
1381
|
+
throw new Error("Trace acknowledgement accepted events are not the contiguous sent prefix");
|
|
1382
|
+
}
|
|
1383
|
+
let eventPrefix = { kind: "none" };
|
|
1384
|
+
const last = prefix[prefix.length - 1];
|
|
1385
|
+
if (last === undefined) {
|
|
1386
|
+
const unchanged = Math.max(previous, 0);
|
|
1387
|
+
if (ack.ackedThroughSequence !== unchanged) {
|
|
1388
|
+
throw new Error("Trace acknowledgement with no accepted events must repeat the previous watermark");
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
if (ack.ackedThroughSequence !== last.sequence)
|
|
1393
|
+
throw new Error("Trace acknowledgement ackedThroughSequence does not end the accepted prefix");
|
|
1394
|
+
prefix.forEach((event, index) => {
|
|
1395
|
+
if (event.sequence !== previous + 1 + index)
|
|
1396
|
+
throw new Error("Trace acknowledgement prefix is not gap-free from the previous watermark");
|
|
1397
|
+
});
|
|
1398
|
+
eventPrefix = { kind: "prefix", throughSequence: last.sequence, eventIds: prefix.map((event) => event.eventId) };
|
|
1399
|
+
}
|
|
1400
|
+
const rejected = ack.rejected.map((entry) => ({ id: entry.id, code: entry.code, retryable: entry.retryable }));
|
|
1401
|
+
return deepFreeze({
|
|
1402
|
+
ack,
|
|
1403
|
+
eventPrefix,
|
|
1404
|
+
eventRejections: rejected.filter((entry) => sentEvents.has(entry.id)),
|
|
1405
|
+
artifactMetadata: {
|
|
1406
|
+
accepted: [...ack.accepted.artifactIds],
|
|
1407
|
+
rejected: rejected.filter((entry) => sentArtifacts.has(entry.id)),
|
|
1408
|
+
},
|
|
1409
|
+
bytesAcknowledged: false,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
function artifactReceiptSchema() {
|
|
1413
|
+
return strict({
|
|
1414
|
+
schema: Type.Literal(DH_HTTP_SCHEMA_IDS.artifactReceipt),
|
|
1415
|
+
artifactId: DhArtifactIdSchema,
|
|
1416
|
+
sha256: digest,
|
|
1417
|
+
sizeBytes: Type.Optional(Type.Integer({ minimum: 0, maximum: DH_ARTIFACT_MAX_BYTES })),
|
|
1418
|
+
state: Type.Union([Type.Literal("stored"), Type.Literal("rejected")]),
|
|
1419
|
+
rejection: Type.Optional(strict({ code: Type.Enum(DH_REJECTION_CODES), retryable: Type.Boolean() })),
|
|
1420
|
+
receivedAt: timestamp,
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Response to PUT artifacts/{artifactId}/content. Names only the requested artifact and carries no
|
|
1425
|
+
* sequence, event id or watermark, so it cannot advance an event cursor or acknowledge metadata.
|
|
1426
|
+
*/
|
|
1427
|
+
export const parseDhArtifactReceipt = dtoParser("artifact receipt", artifactReceiptSchema, (receipt) => {
|
|
1428
|
+
if (receipt.state === "stored" && (receipt.sizeBytes === undefined || receipt.rejection !== undefined)) {
|
|
1429
|
+
throw new Error("Invalid artifact receipt: stored requires sizeBytes and no rejection");
|
|
1430
|
+
}
|
|
1431
|
+
if (receipt.state === "rejected" && receipt.rejection === undefined) {
|
|
1432
|
+
throw new Error("Invalid artifact receipt: rejected requires a rejection");
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
export function validateDhArtifactReceipt(requested, receiptInput) {
|
|
1436
|
+
const receipt = parseDhArtifactReceipt(receiptInput);
|
|
1437
|
+
if (receipt.artifactId !== requested.artifactId)
|
|
1438
|
+
throw new DhContractError("receipt_invalid", "Artifact receipt names a different artifact");
|
|
1439
|
+
if (receipt.sha256 !== requested.sha256)
|
|
1440
|
+
throw new DhContractError("receipt_invalid", "Artifact receipt hash differs from the uploaded bytes");
|
|
1441
|
+
if (requested.sizeBytes !== undefined &&
|
|
1442
|
+
receipt.sizeBytes !== undefined &&
|
|
1443
|
+
receipt.sizeBytes !== requested.sizeBytes) {
|
|
1444
|
+
throw new DhContractError("receipt_invalid", "Artifact receipt size differs from the uploaded bytes");
|
|
1445
|
+
}
|
|
1446
|
+
return receipt;
|
|
1447
|
+
}
|
|
1448
|
+
/** Same id, same canonical bytes is an idempotent redelivery; the same id with different bytes is a conflict and never overwrites. */
|
|
1449
|
+
export function classifyDhDuplicate(existing, incoming) {
|
|
1450
|
+
return canonicalText(existing) === canonicalText(incoming) ? "identical" : "conflict";
|
|
1451
|
+
}
|
|
1452
|
+
// ---------------------------------------------------------------------------
|
|
1453
|
+
// Supported runtimes and capabilities
|
|
1454
|
+
// ---------------------------------------------------------------------------
|
|
1455
|
+
export const DH_CONTROL_SUPPORT = ["required", "unsupported"];
|
|
1456
|
+
const allRequired = () => ({
|
|
1457
|
+
backgroundStatus: "required",
|
|
1458
|
+
cancelSelected: "required",
|
|
1459
|
+
cancelWholeRun: "required",
|
|
1460
|
+
continueLive: "required",
|
|
1461
|
+
retryFresh: "required",
|
|
1462
|
+
});
|
|
1463
|
+
const cliRuntime = () => ({
|
|
1464
|
+
controls: allRequired(),
|
|
1465
|
+
modes: ["interactive", "print", "json", "rpc", "sdk", "child", "agent-exec-node"],
|
|
1466
|
+
unsupportedModes: ["agent-exec-workflow", "experimental-transport"],
|
|
1467
|
+
});
|
|
1468
|
+
/**
|
|
1469
|
+
* "required" means the implementation and a positive qualification check are mandatory for full
|
|
1470
|
+
* completion; an `unsupported` reply never satisfies it. "unsupportedModes" cannot claim full capture.
|
|
1471
|
+
*/
|
|
1472
|
+
export const DH_SUPPORTED_RUNTIMES = deepFreeze({
|
|
1473
|
+
"cli-node": cliRuntime(),
|
|
1474
|
+
"cli-bun": cliRuntime(),
|
|
1475
|
+
flow: {
|
|
1476
|
+
controls: allRequired(),
|
|
1477
|
+
modes: ["single", "parallel", "chain", "hierarchy"],
|
|
1478
|
+
unsupportedModes: [],
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
//# sourceMappingURL=trace-http.js.map
|