@suveren/gateway 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/suveren-gateway.js +5 -1
- package/dist/control-plane/index.mjs +4 -0
- package/dist/mcp-server/chunk-ZRRZFGRL.mjs +2412 -0
- package/dist/mcp-server/http.mjs +390 -645
- package/dist/mcp-server/report-verify-cli.mjs +64 -7
- package/dist/ui/assets/index-DeQPO4Sj.js +111 -0
- package/dist/ui/assets/index-kObsFuwK.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/profiles/index.json +2 -1
- package/profiles/reporting/0.2.profile.json +98 -0
- package/dist/mcp-server/chunk-JUFH4SKS.mjs +0 -639
- package/dist/ui/assets/index-78vuGrVE.js +0 -111
- package/dist/ui/assets/index-Dfh38n-b.css +0 -1
|
@@ -0,0 +1,2412 @@
|
|
|
1
|
+
// src/lib/as-pairing.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
function pairingPath(dataDir) {
|
|
6
|
+
return join(dataDir, "as-pairing.json");
|
|
7
|
+
}
|
|
8
|
+
function readPairing(dataDir) {
|
|
9
|
+
const path = pairingPath(dataDir);
|
|
10
|
+
if (!existsSync(path)) return null;
|
|
11
|
+
try {
|
|
12
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
13
|
+
if (typeof data.asUrl !== "string" || typeof data.publicKeyHex !== "string") return null;
|
|
14
|
+
return {
|
|
15
|
+
asUrl: data.asUrl,
|
|
16
|
+
publicKeyHex: data.publicKeyHex,
|
|
17
|
+
pairedAt: data.pairedAt ?? "",
|
|
18
|
+
...typeof data.tlsSpkiPinHex === "string" ? { tlsSpkiPinHex: data.tlsSpkiPinHex } : {}
|
|
19
|
+
};
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function fingerprintOf(publicKeyHex) {
|
|
25
|
+
const raw = Buffer.from(publicKeyHex, "hex");
|
|
26
|
+
const digest = createHash("sha256").update(raw).digest("hex").toUpperCase();
|
|
27
|
+
return digest.match(/.{1,4}/g)?.join(":") ?? digest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/lib/report/sanitize.ts
|
|
31
|
+
import sanitizeHtmlLib from "sanitize-html";
|
|
32
|
+
import { parseDocument } from "htmlparser2";
|
|
33
|
+
|
|
34
|
+
// src/lib/report-brief.ts
|
|
35
|
+
var REPORT_ELEMENTS = [
|
|
36
|
+
"sv-ticket",
|
|
37
|
+
"sv-approval",
|
|
38
|
+
"sv-mandate",
|
|
39
|
+
"sv-record",
|
|
40
|
+
"sv-case",
|
|
41
|
+
"sv-metric"
|
|
42
|
+
];
|
|
43
|
+
var REPORT_BLOCKS = ["sv-ai", "sv-row", "sv-glossary", "sv-term"];
|
|
44
|
+
var REPORT_BRIEF = `How to write a report
|
|
45
|
+
|
|
46
|
+
Purpose: regular reports on what you did in your reporting window \u2014 this week, this test, this month \u2014 for managers who are not technical. Plain words, short sentences, the result first. Each write replaces the previous report.
|
|
47
|
+
|
|
48
|
+
Your window: the reporting mandate sets how many days back you may report. list_tickets returns it ("window"), and write_report repeats it. Tickets and records from before it cannot be used.
|
|
49
|
+
|
|
50
|
+
The format \u2014 only these blocks, one after another. Anything else is dropped (write_report tells you how much):
|
|
51
|
+
|
|
52
|
+
1. <sv-ai>\u2026</sv-ai> \u2014 your own content: headings, text, tables, inline SVG charts, your assessment. Any HTML inside, styled with inline style="\u2026" attributes only (no <style> blocks, no JavaScript, no external files or links). The gateway draws it in a grey frame labelled "AI analysis \u2014 not verified".
|
|
53
|
+
2. Verified elements \u2014 you give a reference, the gateway checks it and draws the box. You never write their content:
|
|
54
|
+
- <sv-ticket ref="TICKET_ID" variant="compact"></sv-ticket> \u2014 one action: action + time. variant="full" shows every signed field (incl. its mandate and approval) and the public check link.
|
|
55
|
+
- <sv-approval ticket="TICKET_ID"></sv-approval> \u2014 when the approval was asked and decided, and by whom.
|
|
56
|
+
- <sv-mandate ticket="TICKET_ID"></sv-mandate> \u2014 the authority the action ran under: limits, mode, intent.
|
|
57
|
+
- <sv-record system="email|crm|erp" ref="RECORD_ID"></sv-record> \u2014 an email, CRM entry, quote or order.
|
|
58
|
+
- <sv-case start="email:MESSAGE_ID" goal="ticket:TICKET_ID" steps="TICKET_ID TICKET_ID"></sv-case> \u2014 one business case from its start email to the ticket that completed it.
|
|
59
|
+
- <sv-metric kind="KIND" cases="all"></sv-metric> \u2014 a number the gateway computes over your cases. KIND: completed, median-time, average-time, without-approval, approvals, median-approval-wait, tickets, refusals. cases is "all" or case ids, e.g. cases="C1 C3".
|
|
60
|
+
3. <sv-row>\u2026</sv-row> \u2014 puts verified elements side by side (they stack on a phone). Only verified elements inside.
|
|
61
|
+
4. <sv-glossary lang="de"><sv-term key="erp__create_quote">Angebot erstellt</sv-term></sv-glossary> \u2014 optional, once per report. Translates field names and fixed words that appear in the verified boxes (action names, profile, mode, field names such as value_max). Words only: never numbers, amounts, times or ids; plain text, at most 60 characters. The reader sees it small above the raw value, only when they switch translation on. Unknown keys are ignored.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
<sv-ai><h1>Week 41: three requests answered</h1><p>One quote needed an approval.</p></sv-ai>
|
|
65
|
+
<sv-row><sv-metric kind="completed" cases="all"></sv-metric><sv-metric kind="approvals" cases="all"></sv-metric></sv-row>
|
|
66
|
+
<sv-ai><p>The quote for Huber went out 9 minutes after the request.</p></sv-ai>
|
|
67
|
+
<sv-ticket ref="rcpt_123" variant="full"></sv-ticket>
|
|
68
|
+
|
|
69
|
+
Rules:
|
|
70
|
+
1. Nothing verified inside sv-ai: an sv-* element there is removed, and class names starting with "sv-" are removed from your HTML. Do not imitate the green boxes \u2014 your content always sits in the grey frame.
|
|
71
|
+
2. Verified boxes show only signed field names and raw values. Explain them in plain words in the sv-ai block next to them.
|
|
72
|
+
3. Use sv-metric for every headline number. Numbers you compute yourself stay "AI analysis \u2014 not verified".
|
|
73
|
+
4. A wrong or unknown reference is shown as "not verifiable". Do not guess IDs \u2014 read them first (list_tickets, get_ticket, list_cases, get_records).
|
|
74
|
+
5. Never claim more than the boxes prove. Say plainly what went wrong or waited for a person.
|
|
75
|
+
|
|
76
|
+
Cases and coverage apply only when test data is loaded (list_cases shows it):
|
|
77
|
+
- Define every case you worked on with sv-case. The gateway shows how many of the loaded cases your report covers, names the missing ones, and lists every ticket from the window that your report does not reference \u2014 so name every step.
|
|
78
|
+
- A case's goal must be what actually completed it \u2014 for an email reply, the reply to the start email. A case is timed from when its email reached the test (the test data may date its emails earlier \u2014 the gateway uses the later of the two).
|
|
79
|
+
|
|
80
|
+
Layout: it must read well on a phone, in a narrow side panel (about 360 pixels) and on a large screen. Inside sv-ai use fluid widths, wrapping flex/grid, tables that scroll, and SVG charts with a viewBox.
|
|
81
|
+
|
|
82
|
+
Suggested structure: the result in one sentence (sv-ai) \u2192 3\u20134 sv-metric in an sv-row \u2192 each case (sv-case, then the tickets and approvals that explain it, each with a short sv-ai caption) \u2192 what was stopped or waited for a person \u2192 your assessment (sv-ai).`;
|
|
83
|
+
|
|
84
|
+
// src/lib/report/sanitize.ts
|
|
85
|
+
var STRUCTURE_TAGS = new Set(REPORT_BLOCKS);
|
|
86
|
+
var VERIFIED_KINDS = new Set(REPORT_ELEMENTS);
|
|
87
|
+
var MAX_GLOSS_CHARS = 60;
|
|
88
|
+
var CHECK_MARK_RE = /[✓✔✅☑☒✗✘❌❎\u{1f5f8}]/u;
|
|
89
|
+
var FORBIDDEN_IN_AI = /* @__PURE__ */ new Set([
|
|
90
|
+
"script",
|
|
91
|
+
"iframe",
|
|
92
|
+
"object",
|
|
93
|
+
"embed",
|
|
94
|
+
"link",
|
|
95
|
+
"base",
|
|
96
|
+
"meta",
|
|
97
|
+
"style",
|
|
98
|
+
"frame",
|
|
99
|
+
"frameset",
|
|
100
|
+
"portal",
|
|
101
|
+
"fencedframe",
|
|
102
|
+
"applet",
|
|
103
|
+
"plaintext",
|
|
104
|
+
"xmp",
|
|
105
|
+
"listing",
|
|
106
|
+
"noembed",
|
|
107
|
+
"noframes",
|
|
108
|
+
"dialog",
|
|
109
|
+
"select"
|
|
110
|
+
]);
|
|
111
|
+
function isUrlAttribute(name) {
|
|
112
|
+
const n = name.toLowerCase();
|
|
113
|
+
return n === "href" || n === "src" || n === "srcset" || n === "action" || n === "formaction" || n === "poster" || n === "background" || n === "ping" || n === "data" || n === "xlink:href" || n.endsWith(":href");
|
|
114
|
+
}
|
|
115
|
+
function isAllowedUrl(value) {
|
|
116
|
+
const v = value.trim();
|
|
117
|
+
if (v.startsWith("#")) return true;
|
|
118
|
+
if (/^data:image\//i.test(v)) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function cleanInlineStyle(value) {
|
|
122
|
+
return value.split(";").filter((decl) => {
|
|
123
|
+
if (/@import/i.test(decl)) return false;
|
|
124
|
+
if (/image-set\s*\(/i.test(decl)) return false;
|
|
125
|
+
const urls = [...decl.matchAll(/url\s*\(\s*(['"]?)([^'")]*)/gi)];
|
|
126
|
+
return urls.every((m) => /^data:image\//i.test(m[2].trim()));
|
|
127
|
+
}).join(";").trim();
|
|
128
|
+
}
|
|
129
|
+
function stripGatewayClasses(value) {
|
|
130
|
+
return value.split(/\s+/).filter((c) => c && !/^sv-/i.test(c)).join(" ");
|
|
131
|
+
}
|
|
132
|
+
function escapeAttr(value) {
|
|
133
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
134
|
+
}
|
|
135
|
+
function escapeText(value) {
|
|
136
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
137
|
+
}
|
|
138
|
+
function textOf(node) {
|
|
139
|
+
if (node.type === "text") return node.data ?? "";
|
|
140
|
+
return (node.children ?? []).map(textOf).join("");
|
|
141
|
+
}
|
|
142
|
+
function countTags(node, pred) {
|
|
143
|
+
let n = 0;
|
|
144
|
+
for (const c of node.children ?? []) {
|
|
145
|
+
if (c.type === "tag" || c.type === "script" || c.type === "style") {
|
|
146
|
+
if (pred(c.name ?? "")) {
|
|
147
|
+
n++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
n += countTags(c, pred);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
function isElement(node) {
|
|
156
|
+
return node.type === "tag" || node.type === "script" || node.type === "style";
|
|
157
|
+
}
|
|
158
|
+
function serializeVerified(node) {
|
|
159
|
+
const attrs = Object.entries(node.attribs ?? {}).filter(([k]) => /^[a-z][a-z0-9_-]*$/i.test(k) && !/^on/i.test(k)).map(([k, v]) => ` ${k.toLowerCase()}="${escapeAttr(v)}"`).join("");
|
|
160
|
+
return `<${node.name}${attrs}></${node.name}>`;
|
|
161
|
+
}
|
|
162
|
+
function sanitizeAiContent(inner) {
|
|
163
|
+
return sanitizeHtmlLib(inner, {
|
|
164
|
+
// Deny-by-name, not allow-by-name: the AI writes free HTML structure and
|
|
165
|
+
// inline SVG; `transformTags['*']` below is the ONLY attribute filter.
|
|
166
|
+
// `allowVulnerableTags` silences sanitize-html's notice about style/script
|
|
167
|
+
// — both are still removed by `exclusiveFilter`.
|
|
168
|
+
allowedTags: false,
|
|
169
|
+
allowedAttributes: false,
|
|
170
|
+
allowVulnerableTags: true,
|
|
171
|
+
allowedSchemes: [...sanitizeHtmlLib.defaults.allowedSchemes, "data"],
|
|
172
|
+
// Keep camelCase SVG attributes (viewBox, …); every check below folds case.
|
|
173
|
+
parser: { lowerCaseAttributeNames: false },
|
|
174
|
+
exclusiveFilter: (frame) => {
|
|
175
|
+
const tag = frame.tag.toLowerCase();
|
|
176
|
+
if (FORBIDDEN_IN_AI.has(tag)) return true;
|
|
177
|
+
if (tag.startsWith("sv-")) return true;
|
|
178
|
+
return false;
|
|
179
|
+
},
|
|
180
|
+
transformTags: {
|
|
181
|
+
"*": (tagName, attribs) => {
|
|
182
|
+
const lowerTag = tagName.toLowerCase();
|
|
183
|
+
if (lowerTag === "html" || lowerTag === "head" || lowerTag === "body" || lowerTag === "title") {
|
|
184
|
+
return { tagName: "div", attribs: {} };
|
|
185
|
+
}
|
|
186
|
+
const kept = {};
|
|
187
|
+
for (const [name, value] of Object.entries(attribs)) {
|
|
188
|
+
const lower = name.toLowerCase();
|
|
189
|
+
if (lower.startsWith("on")) continue;
|
|
190
|
+
if (isUrlAttribute(name) && !isAllowedUrl(value)) continue;
|
|
191
|
+
if (lower.startsWith("data-sv")) continue;
|
|
192
|
+
if (lower === "popover" || lower === "popovertarget" || lower === "popovertargetaction") continue;
|
|
193
|
+
if (lower === "id" && /^sv-/i.test(value.trim())) continue;
|
|
194
|
+
if (lower === "class") {
|
|
195
|
+
const classes = stripGatewayClasses(value);
|
|
196
|
+
if (classes) kept[name] = classes;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (lower === "style") {
|
|
200
|
+
const style = cleanInlineStyle(value);
|
|
201
|
+
if (style) kept[name] = style;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
kept[name] = value;
|
|
205
|
+
}
|
|
206
|
+
return { tagName, attribs: kept };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function sanitizeReport(html) {
|
|
212
|
+
const notes = {
|
|
213
|
+
droppedBlocks: 0,
|
|
214
|
+
droppedSvInsideAi: 0,
|
|
215
|
+
droppedStyles: 0,
|
|
216
|
+
extraGlossaries: 0,
|
|
217
|
+
rejectedTerms: []
|
|
218
|
+
};
|
|
219
|
+
const doc = parseDocument(html, {
|
|
220
|
+
withStartIndices: true,
|
|
221
|
+
withEndIndices: true,
|
|
222
|
+
recognizeSelfClosing: true,
|
|
223
|
+
decodeEntities: true
|
|
224
|
+
});
|
|
225
|
+
const out = [];
|
|
226
|
+
let glossarySeen = false;
|
|
227
|
+
const isBlank = (n) => n.type === "text" && !(n.data ?? "").trim();
|
|
228
|
+
const isIgnorable = (n) => isBlank(n) || n.type === "comment" || n.type === "directive" || n.type === "cdata";
|
|
229
|
+
function emitVerified(node, sink, inRow) {
|
|
230
|
+
sink.push(serializeVerified(node));
|
|
231
|
+
for (const c of node.children ?? []) processAt(c, sink, inRow);
|
|
232
|
+
}
|
|
233
|
+
function emitAi(node) {
|
|
234
|
+
notes.droppedSvInsideAi += countTags(node, (n) => n.startsWith("sv-"));
|
|
235
|
+
notes.droppedStyles += countTags(node, (n) => n === "style");
|
|
236
|
+
const children = node.children ?? [];
|
|
237
|
+
let inner = "";
|
|
238
|
+
if (children.length > 0) {
|
|
239
|
+
const start = children[0].startIndex;
|
|
240
|
+
const end = children[children.length - 1].endIndex;
|
|
241
|
+
if (start !== null && end !== null) inner = html.slice(start, end + 1);
|
|
242
|
+
}
|
|
243
|
+
out.push(`<sv-ai>${sanitizeAiContent(inner)}</sv-ai>`);
|
|
244
|
+
}
|
|
245
|
+
function emitGlossary(node) {
|
|
246
|
+
if (glossarySeen) {
|
|
247
|
+
notes.extraGlossaries++;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
glossarySeen = true;
|
|
251
|
+
const lang = (node.attribs?.lang ?? "").trim().slice(0, 16);
|
|
252
|
+
const seen = /* @__PURE__ */ new Set();
|
|
253
|
+
const terms = [];
|
|
254
|
+
const visit = (n) => {
|
|
255
|
+
for (const c of n.children ?? []) {
|
|
256
|
+
if (!isElement(c)) continue;
|
|
257
|
+
if (c.name !== "sv-term") {
|
|
258
|
+
visit(c);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const key2 = (c.attribs?.key ?? "").trim();
|
|
262
|
+
const text = textOf(c).replace(/\s+/g, " ").trim();
|
|
263
|
+
if (!key2) continue;
|
|
264
|
+
if (seen.has(key2)) {
|
|
265
|
+
notes.rejectedTerms.push({ key: key2, reason: "duplicate key \u2014 the first entry is used" });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!text) {
|
|
269
|
+
notes.rejectedTerms.push({ key: key2, reason: "empty translation" });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (text.length > MAX_GLOSS_CHARS) {
|
|
273
|
+
notes.rejectedTerms.push({ key: key2, reason: `longer than ${MAX_GLOSS_CHARS} characters` });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (CHECK_MARK_RE.test(text)) {
|
|
277
|
+
notes.rejectedTerms.push({ key: key2, reason: "contains a check mark" });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
seen.add(key2);
|
|
281
|
+
terms.push(`<sv-term key="${escapeAttr(key2)}">${escapeText(text)}</sv-term>`);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
visit(node);
|
|
285
|
+
out.push(`<sv-glossary${lang ? ` lang="${escapeAttr(lang)}"` : ""}>${terms.join("")}</sv-glossary>`);
|
|
286
|
+
}
|
|
287
|
+
function emitRow(node) {
|
|
288
|
+
const items = [];
|
|
289
|
+
for (const c of node.children ?? []) processAt(c, items, true);
|
|
290
|
+
if (items.length > 0) out.push(`<sv-row>${items.join("")}</sv-row>`);
|
|
291
|
+
}
|
|
292
|
+
function processAt(node, sink, inRow) {
|
|
293
|
+
if (isIgnorable(node)) return;
|
|
294
|
+
if (!isElement(node)) {
|
|
295
|
+
notes.droppedBlocks++;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const name = node.name ?? "";
|
|
299
|
+
if (!inRow) {
|
|
300
|
+
if (name === "html" || name === "body") {
|
|
301
|
+
for (const c of node.children ?? []) processAt(c, sink, false);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (name === "head") {
|
|
305
|
+
notes.droppedStyles += countTags(node, (n) => n === "style");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (name === "sv-ai") {
|
|
309
|
+
emitAi(node);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (name === "sv-row") {
|
|
313
|
+
emitRow(node);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (name === "sv-glossary") {
|
|
317
|
+
emitGlossary(node);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (name.startsWith("sv-") && !STRUCTURE_TAGS.has(name)) {
|
|
322
|
+
emitVerified(node, sink, inRow);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (name === "style") notes.droppedStyles++;
|
|
326
|
+
else notes.droppedBlocks++;
|
|
327
|
+
}
|
|
328
|
+
for (const c of doc.children ?? []) processAt(c, out, false);
|
|
329
|
+
return { html: out.join("\n"), notes };
|
|
330
|
+
}
|
|
331
|
+
function sanitizeReportHtml(html) {
|
|
332
|
+
return sanitizeReport(html).html;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/lib/report/render-report.ts
|
|
336
|
+
import { parseDocument as parseDocument2 } from "htmlparser2";
|
|
337
|
+
|
|
338
|
+
// src/lib/report/format.ts
|
|
339
|
+
import { getProfile } from "@hap/core";
|
|
340
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
341
|
+
var drawingZone;
|
|
342
|
+
function withDrawingZone(tz, fn) {
|
|
343
|
+
const prev = drawingZone;
|
|
344
|
+
drawingZone = tz;
|
|
345
|
+
try {
|
|
346
|
+
return fn();
|
|
347
|
+
} finally {
|
|
348
|
+
drawingZone = prev;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function zonedParts(epochSeconds) {
|
|
352
|
+
const d = new Date(epochSeconds * 1e3);
|
|
353
|
+
if (drawingZone === void 0) {
|
|
354
|
+
return {
|
|
355
|
+
year: d.getFullYear(),
|
|
356
|
+
month: d.getMonth() + 1,
|
|
357
|
+
day: d.getDate(),
|
|
358
|
+
hour: d.getHours(),
|
|
359
|
+
minute: d.getMinutes(),
|
|
360
|
+
second: d.getSeconds(),
|
|
361
|
+
offsetMin: -d.getTimezoneOffset()
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
365
|
+
timeZone: drawingZone,
|
|
366
|
+
hourCycle: "h23",
|
|
367
|
+
year: "numeric",
|
|
368
|
+
month: "numeric",
|
|
369
|
+
day: "numeric",
|
|
370
|
+
hour: "numeric",
|
|
371
|
+
minute: "numeric",
|
|
372
|
+
second: "numeric"
|
|
373
|
+
}).formatToParts(d);
|
|
374
|
+
const get = (t) => Number(parts.find((x) => x.type === t)?.value ?? NaN);
|
|
375
|
+
const p = { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute"), second: get("second") };
|
|
376
|
+
const wallMs = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
|
377
|
+
const offsetMin = Math.round((wallMs - Math.floor(d.getTime() / 1e3) * 1e3) / 6e4);
|
|
378
|
+
return { ...p, offsetMin };
|
|
379
|
+
}
|
|
380
|
+
function offsetLabel(offsetMin) {
|
|
381
|
+
if (offsetMin === 0) return "UTC";
|
|
382
|
+
const sign = offsetMin > 0 ? "+" : "-";
|
|
383
|
+
const abs = Math.abs(offsetMin);
|
|
384
|
+
const mm = abs % 60;
|
|
385
|
+
return `UTC${sign}${Math.floor(abs / 60)}${mm ? ":" + String(mm).padStart(2, "0") : ""}`;
|
|
386
|
+
}
|
|
387
|
+
function formatDateTime(value) {
|
|
388
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
389
|
+
if (!Number.isFinite(n)) return "unknown time";
|
|
390
|
+
const z = zonedParts(n);
|
|
391
|
+
const hh = String(z.hour).padStart(2, "0");
|
|
392
|
+
const mm = String(z.minute).padStart(2, "0");
|
|
393
|
+
return `${z.day} ${MONTHS[z.month - 1]}, ${hh}:${mm}`;
|
|
394
|
+
}
|
|
395
|
+
function formatDuration(value) {
|
|
396
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
397
|
+
if (!Number.isFinite(n) || n < 0) return "unknown duration";
|
|
398
|
+
const mins = Math.round(n / 60);
|
|
399
|
+
if (mins < 60) return `${mins} min`;
|
|
400
|
+
const hrs = Math.floor(mins / 60);
|
|
401
|
+
const rem = mins % 60;
|
|
402
|
+
return `${hrs} h ${String(rem).padStart(2, "0")} min`;
|
|
403
|
+
}
|
|
404
|
+
function groupThousands(n) {
|
|
405
|
+
const sign = n < 0 ? "-" : "";
|
|
406
|
+
const [intPart, frac] = Math.abs(n).toString().split(".");
|
|
407
|
+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
|
408
|
+
return sign + grouped + (frac ? `.${frac}` : "");
|
|
409
|
+
}
|
|
410
|
+
var CURRENCY_SYMBOLS = { EUR: "\u20AC", USD: "$", GBP: "\xA3" };
|
|
411
|
+
function formatCurrency(amount, currencyCode) {
|
|
412
|
+
const n = typeof amount === "number" ? amount : Number(amount);
|
|
413
|
+
if (!Number.isFinite(n)) return String(amount ?? "");
|
|
414
|
+
if (!currencyCode) return groupThousands(n);
|
|
415
|
+
const code = currencyCode.toUpperCase();
|
|
416
|
+
const symbol = CURRENCY_SYMBOLS[code];
|
|
417
|
+
return symbol ? `${symbol} ${groupThousands(n)}` : `${code} ${groupThousands(n)}`;
|
|
418
|
+
}
|
|
419
|
+
function formatBoundValue(unit, value, fallbackCurrency) {
|
|
420
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
421
|
+
if (!Number.isFinite(n)) return String(value ?? "");
|
|
422
|
+
if (!unit || unit === "count") {
|
|
423
|
+
return fallbackCurrency ? formatCurrency(n, fallbackCurrency) : groupThousands(n);
|
|
424
|
+
}
|
|
425
|
+
if (unit === "minutes") return `${groupThousands(n)} min`;
|
|
426
|
+
if (unit === "hours") return `${groupThousands(n)} h`;
|
|
427
|
+
if (unit === "days") return `${groupThousands(n)} d`;
|
|
428
|
+
if (unit === "percent") return `${groupThousands(n)}%`;
|
|
429
|
+
if (unit.startsWith("currency:")) return formatCurrency(n, unit.slice("currency:".length));
|
|
430
|
+
return `${groupThousands(n)} ${unit}`;
|
|
431
|
+
}
|
|
432
|
+
function humanizeKey(key2) {
|
|
433
|
+
return key2.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
434
|
+
}
|
|
435
|
+
function formatBoundLabel(profileId, key2, value, fallbackCurrency) {
|
|
436
|
+
const profile = profileId ? getProfile(profileId) : void 0;
|
|
437
|
+
const field = profile?.boundsSchema?.fields?.[key2];
|
|
438
|
+
const displayName = field?.displayName ?? humanizeKey(key2);
|
|
439
|
+
return `${displayName}: ${formatBoundValue(field?.unit, value, fallbackCurrency)}`;
|
|
440
|
+
}
|
|
441
|
+
var UNDISCLOSED_OWNER_LABEL = "Owner (name not disclosed)";
|
|
442
|
+
function formatOwnerLabel(did) {
|
|
443
|
+
if (!did) return "Unknown owner";
|
|
444
|
+
return UNDISCLOSED_OWNER_LABEL;
|
|
445
|
+
}
|
|
446
|
+
function profileShortLabel(profileId) {
|
|
447
|
+
if (!profileId) return "Unknown profile";
|
|
448
|
+
const name = profileId.split("@")[0].split("/").pop();
|
|
449
|
+
if (!name) return profileId;
|
|
450
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
451
|
+
}
|
|
452
|
+
var ACTION_LABELS = {
|
|
453
|
+
create_quote: "Quote created",
|
|
454
|
+
update_quote: "Quote updated",
|
|
455
|
+
send_quote: "Quote sent",
|
|
456
|
+
convert_quote_to_order: "Order placed",
|
|
457
|
+
create_order: "Order created",
|
|
458
|
+
update_order: "Order updated",
|
|
459
|
+
send_message: "Reply sent",
|
|
460
|
+
send_email: "Reply sent",
|
|
461
|
+
create_contact: "Contact created",
|
|
462
|
+
update_contact: "Contact updated",
|
|
463
|
+
create_deal: "Deal created",
|
|
464
|
+
update_deal: "Deal updated",
|
|
465
|
+
create_task: "Task created",
|
|
466
|
+
update_task: "Task updated",
|
|
467
|
+
log_activity: "Activity logged",
|
|
468
|
+
create_record: "Record created",
|
|
469
|
+
update_record: "Record updated",
|
|
470
|
+
delete_record: "Record deleted",
|
|
471
|
+
create_event: "Event created",
|
|
472
|
+
update_event: "Event updated",
|
|
473
|
+
delete_event: "Event deleted",
|
|
474
|
+
publish_post: "Post published"
|
|
475
|
+
};
|
|
476
|
+
var IRREGULAR_PAST = {
|
|
477
|
+
write: "written",
|
|
478
|
+
rewrite: "rewritten",
|
|
479
|
+
send: "sent",
|
|
480
|
+
resend: "resent",
|
|
481
|
+
run: "run",
|
|
482
|
+
rerun: "rerun",
|
|
483
|
+
set: "set",
|
|
484
|
+
reset: "reset",
|
|
485
|
+
put: "put",
|
|
486
|
+
make: "made",
|
|
487
|
+
build: "built",
|
|
488
|
+
rebuild: "rebuilt",
|
|
489
|
+
begin: "begun",
|
|
490
|
+
take: "taken",
|
|
491
|
+
give: "given",
|
|
492
|
+
do: "done",
|
|
493
|
+
redo: "redone",
|
|
494
|
+
read: "read",
|
|
495
|
+
find: "found",
|
|
496
|
+
get: "retrieved",
|
|
497
|
+
buy: "bought",
|
|
498
|
+
pay: "paid",
|
|
499
|
+
sell: "sold",
|
|
500
|
+
hold: "held",
|
|
501
|
+
keep: "kept",
|
|
502
|
+
leave: "left",
|
|
503
|
+
draw: "drawn",
|
|
504
|
+
withdraw: "withdrawn",
|
|
505
|
+
forbid: "forbidden",
|
|
506
|
+
choose: "chosen",
|
|
507
|
+
speak: "spoken",
|
|
508
|
+
meet: "met",
|
|
509
|
+
lead: "led",
|
|
510
|
+
split: "split",
|
|
511
|
+
cut: "cut",
|
|
512
|
+
upload: "uploaded",
|
|
513
|
+
forward: "forwarded",
|
|
514
|
+
bring: "brought",
|
|
515
|
+
tell: "told",
|
|
516
|
+
show: "shown",
|
|
517
|
+
drive: "driven",
|
|
518
|
+
break: "broken"
|
|
519
|
+
};
|
|
520
|
+
var DOUBLE_FINAL = /* @__PURE__ */ new Set(["stop", "plan", "ship", "tag", "log", "submit", "drop", "flag", "pin", "unpin", "map", "wrap", "chat", "admit", "commit", "permit", "refer", "transfer"]);
|
|
521
|
+
function pastTense(verb) {
|
|
522
|
+
if (!verb) return verb;
|
|
523
|
+
const lower = verb.toLowerCase();
|
|
524
|
+
if (IRREGULAR_PAST[lower]) return IRREGULAR_PAST[lower];
|
|
525
|
+
if (DOUBLE_FINAL.has(lower)) return `${verb}${verb.slice(-1)}ed`;
|
|
526
|
+
if (/e$/.test(verb)) return `${verb}d`;
|
|
527
|
+
if (/[^aeiou]y$/i.test(verb)) return `${verb.slice(0, -1)}ied`;
|
|
528
|
+
return `${verb}ed`;
|
|
529
|
+
}
|
|
530
|
+
function capitalize(s) {
|
|
531
|
+
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
532
|
+
}
|
|
533
|
+
function formatActionLabel(action) {
|
|
534
|
+
const raw = String(action ?? "").trim();
|
|
535
|
+
if (!raw) return "Action";
|
|
536
|
+
const withoutSystem = raw.includes("__") ? raw.slice(raw.indexOf("__") + 2) : raw;
|
|
537
|
+
const known = ACTION_LABELS[withoutSystem];
|
|
538
|
+
if (known) return known;
|
|
539
|
+
const parts = withoutSystem.split("_").filter(Boolean);
|
|
540
|
+
if (parts.length === 0) return capitalize(raw);
|
|
541
|
+
const [verb, ...rest] = parts;
|
|
542
|
+
if (rest.length === 0) return capitalize(pastTense(verb));
|
|
543
|
+
return `${capitalize(rest.join(" "))} ${pastTense(verb)}`;
|
|
544
|
+
}
|
|
545
|
+
function formatTimestamp(value) {
|
|
546
|
+
const n = typeof value === "number" ? value : parseTimestampSecondsLoose(value);
|
|
547
|
+
if (n === void 0 || !Number.isFinite(n)) return String(value ?? "");
|
|
548
|
+
const z = zonedParts(n);
|
|
549
|
+
const p = (x) => String(x).padStart(2, "0");
|
|
550
|
+
return `${z.year}-${p(z.month)}-${p(z.day)} ${p(z.hour)}:${p(z.minute)}:${p(z.second)} ${offsetLabel(z.offsetMin)}`;
|
|
551
|
+
}
|
|
552
|
+
function parseTimestampSecondsLoose(value) {
|
|
553
|
+
if (typeof value !== "string" || !value.trim()) return void 0;
|
|
554
|
+
if (/^\d+(\.\d+)?$/.test(value.trim())) return Number(value);
|
|
555
|
+
const iso = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? `${value.replace(" ", "T")}Z` : value;
|
|
556
|
+
const ms = Date.parse(iso);
|
|
557
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1e3) : void 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/lib/report/render-report.ts
|
|
561
|
+
function escapeHtml(value) {
|
|
562
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
563
|
+
}
|
|
564
|
+
function isGlossableWord(value) {
|
|
565
|
+
const v = value.trim();
|
|
566
|
+
if (!v || v.length > 120) return false;
|
|
567
|
+
if (!/[A-Za-z]/.test(v)) return false;
|
|
568
|
+
if (/\d{4,}/.test(v)) return false;
|
|
569
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(v)) return false;
|
|
570
|
+
if (/^(rcpt|authz|tkt|msg|q|o)[_-]/i.test(v)) return false;
|
|
571
|
+
if (/@.+\./.test(v) && !/@\d/.test(v)) return false;
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
function glossed(ctx, raw, cls) {
|
|
575
|
+
const base = `<span class="${cls}">${escapeHtml(raw)}</span>`;
|
|
576
|
+
if (ctx.mode === "off") return base;
|
|
577
|
+
const gloss = ctx.terms.get(raw);
|
|
578
|
+
if (gloss === void 0) return base;
|
|
579
|
+
ctx.used.add(raw);
|
|
580
|
+
return `<ruby class="sv-gloss">${base}<rt>${escapeHtml(gloss)}</rt></ruby>`;
|
|
581
|
+
}
|
|
582
|
+
function key(ctx, name) {
|
|
583
|
+
return glossed(ctx, name, "sv-k");
|
|
584
|
+
}
|
|
585
|
+
function word(ctx, value) {
|
|
586
|
+
const s = String(value ?? "");
|
|
587
|
+
if (!isGlossableWord(s)) return val(value);
|
|
588
|
+
return glossed(ctx, s, "sv-v");
|
|
589
|
+
}
|
|
590
|
+
function val(value) {
|
|
591
|
+
return `<span class="sv-v">${escapeHtml(value)}</span>`;
|
|
592
|
+
}
|
|
593
|
+
function ts(value) {
|
|
594
|
+
return val(formatTimestamp(value));
|
|
595
|
+
}
|
|
596
|
+
function kv(k, v, extra = "") {
|
|
597
|
+
return `<div class="sv-kv${extra}">${k}${v}</div>`;
|
|
598
|
+
}
|
|
599
|
+
function scalar(value) {
|
|
600
|
+
if (value === null || value === void 0 || value === "") return void 0;
|
|
601
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
602
|
+
return String(value);
|
|
603
|
+
}
|
|
604
|
+
function rawNumber(value) {
|
|
605
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
606
|
+
if (!Number.isFinite(n)) return String(value ?? "");
|
|
607
|
+
if (Number.isInteger(n)) return String(n);
|
|
608
|
+
return String(Math.round(n * 1e4) / 1e4);
|
|
609
|
+
}
|
|
610
|
+
function formula(text) {
|
|
611
|
+
return `<span class="sv-f">= ${escapeHtml(text)}</span>`;
|
|
612
|
+
}
|
|
613
|
+
var TIME_KEY_RE = /(^|_|\.)(at|timestamp)$|At$|_at$/;
|
|
614
|
+
var SOURCE_BY_KIND = {
|
|
615
|
+
"sv-ticket": "signed",
|
|
616
|
+
"sv-mandate": "signed",
|
|
617
|
+
"sv-approval": "archive",
|
|
618
|
+
"sv-record": "database",
|
|
619
|
+
"sv-case": "computed",
|
|
620
|
+
"sv-metric": "computed"
|
|
621
|
+
};
|
|
622
|
+
function seal(status, source) {
|
|
623
|
+
if (status === "verified") {
|
|
624
|
+
return `<span class="sv-badge sv-badge-ok sv-seal" data-sv-source="${source}">\u2713 verified \xB7 ${source}</span>`;
|
|
625
|
+
}
|
|
626
|
+
if (status === "warning") {
|
|
627
|
+
return `<span class="sv-badge sv-badge-warn sv-seal" data-sv-source="${source}">\u26A0 verified \xB7 ${source} \xB7 with a note</span>`;
|
|
628
|
+
}
|
|
629
|
+
return `<span class="sv-badge sv-badge-bad">\u2717 not verifiable</span>`;
|
|
630
|
+
}
|
|
631
|
+
function card(el, body, opts = {}) {
|
|
632
|
+
const source = SOURCE_BY_KIND[el.kind] ?? "computed";
|
|
633
|
+
const note = el.status === "warning" && el.reason ? `<div class="sv-note">${escapeHtml(el.reason)}</div>` : "";
|
|
634
|
+
if (opts.compact) {
|
|
635
|
+
return `<div class="sv-el sv-el-${el.status} sv-compact" data-sv-id="${escapeHtml(el.id)}"><div class="sv-vline"><div class="sv-kv-row">${body}</div>${seal(el.status, source)}</div>${note}</div>`;
|
|
636
|
+
}
|
|
637
|
+
return `<div class="sv-el sv-el-${el.status}" data-sv-id="${escapeHtml(el.id)}"><div class="sv-vhead">${opts.head ?? "<span></span>"}${seal(el.status, source)}</div>` + note + body + `</div>`;
|
|
638
|
+
}
|
|
639
|
+
function unverifiableCard(el) {
|
|
640
|
+
return `<div class="sv-el sv-el-unverifiable" data-sv-id="${escapeHtml(el.id)}"><div class="sv-vhead"><span class="sv-k">${escapeHtml(el.kind)}</span>${seal("unverifiable", "computed")}</div><div class="sv-reason">${escapeHtml(el.reason ?? "This reference could not be checked.")}</div></div>`;
|
|
641
|
+
}
|
|
642
|
+
function checkLink(href) {
|
|
643
|
+
return typeof href === "string" && href ? `<div class="sv-link"><a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">Check on suveren.ai \u2197</a></div>` : "";
|
|
644
|
+
}
|
|
645
|
+
function contextRows(ctx, entries, indent = false) {
|
|
646
|
+
return Object.entries(entries).map(([k, v]) => {
|
|
647
|
+
const s = scalar(v);
|
|
648
|
+
if (s === void 0) return "";
|
|
649
|
+
return kv(key(ctx, k), typeof v === "string" ? word(ctx, v) : val(s), indent ? " sv-in" : "");
|
|
650
|
+
}).join("");
|
|
651
|
+
}
|
|
652
|
+
function mandateRows(ctx, m, indent) {
|
|
653
|
+
const rows = [];
|
|
654
|
+
const rawLimits = m.rawLimits ?? {};
|
|
655
|
+
rows.push(contextRows(ctx, rawLimits, indent));
|
|
656
|
+
if (typeof m.mode === "string") rows.push(kv(key(ctx, "commitment_mode"), word(ctx, m.mode), indent ? " sv-in" : ""));
|
|
657
|
+
for (const o of Array.isArray(m.owners) ? m.owners : []) {
|
|
658
|
+
rows.push(kv(key(ctx, "owner"), val(o), indent ? " sv-in" : ""));
|
|
659
|
+
}
|
|
660
|
+
return rows.join("");
|
|
661
|
+
}
|
|
662
|
+
function approvalRows(ctx, a, prefix, indent) {
|
|
663
|
+
const cls = indent ? " sv-in" : "";
|
|
664
|
+
const rows = [];
|
|
665
|
+
if (a.createdAt !== void 0) rows.push(kv(key(ctx, `${prefix}createdAt`), ts(a.createdAt), cls));
|
|
666
|
+
if (a.decidedAt !== void 0) rows.push(kv(key(ctx, `${prefix}decidedAt`), ts(a.decidedAt), cls));
|
|
667
|
+
if (typeof a.whoLabel === "string") rows.push(kv(key(ctx, `${prefix}committedBy`), val(a.whoLabel), cls));
|
|
668
|
+
return rows.join("");
|
|
669
|
+
}
|
|
670
|
+
function group(ctx, name, rows) {
|
|
671
|
+
if (!rows) return "";
|
|
672
|
+
return `<div class="sv-group"><div class="sv-group-label">${key(ctx, name)}</div>${rows}</div>`;
|
|
673
|
+
}
|
|
674
|
+
function renderTicket(ctx, el) {
|
|
675
|
+
if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
|
|
676
|
+
const d = el.data;
|
|
677
|
+
const variant = (el.attrs.variant ?? "").trim().toLowerCase();
|
|
678
|
+
if (variant !== "full") {
|
|
679
|
+
return card(el, kv(key(ctx, "action"), word(ctx, d.action)) + kv(key(ctx, "timestamp"), ts(d.time)), { compact: true });
|
|
680
|
+
}
|
|
681
|
+
const body = kv(key(ctx, "action"), word(ctx, d.action)) + (d.actionType !== void 0 ? kv(key(ctx, "actionType"), word(ctx, d.actionType)) : "") + kv(key(ctx, "profileId"), word(ctx, d.profile)) + contextRows(ctx, d.executionContext ?? {}) + (d.mandate ? group(ctx, "mandate", mandateRows(ctx, d.mandate, true)) : "") + (d.approval ? group(ctx, "approval", approvalRows(ctx, d.approval, "", true)) : "") + kv(key(ctx, "timestamp"), ts(d.time)) + kv(key(ctx, "ticket"), val(d.ticketId)) + checkLink(d.checkUrl);
|
|
682
|
+
return card(el, body);
|
|
683
|
+
}
|
|
684
|
+
function renderApproval(ctx, el) {
|
|
685
|
+
if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
|
|
686
|
+
const d = el.data;
|
|
687
|
+
const wait = typeof d.waitSeconds === "number" ? kv(key(ctx, "wait_s"), val(rawNumber(d.waitSeconds)) + formula("decidedAt \u2212 createdAt")) : "";
|
|
688
|
+
const status = typeof d.status === "string" ? kv(key(ctx, "status"), word(ctx, d.status)) : "";
|
|
689
|
+
return card(el, approvalRows(ctx, d, "", false) + status + wait);
|
|
690
|
+
}
|
|
691
|
+
function renderMandate(ctx, el) {
|
|
692
|
+
if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
|
|
693
|
+
const d = el.data;
|
|
694
|
+
const body = kv(key(ctx, "profileId"), word(ctx, d.profile)) + mandateRows(ctx, d, false) + (typeof d.intent === "string" && d.intent ? kv(key(ctx, "intent"), `<span class="sv-v sv-text">${escapeHtml(d.intent)}</span>`, " sv-block") : "");
|
|
695
|
+
return card(el, body);
|
|
696
|
+
}
|
|
697
|
+
var RECORD_SKIP = /* @__PURE__ */ new Set(["kind", "folder", "causingReceiptId", "body", "lines", "cc_json"]);
|
|
698
|
+
var RECORD_WORD_FIELDS = /* @__PURE__ */ new Set(["status", "tool", "currency"]);
|
|
699
|
+
function renderRecord(ctx, el) {
|
|
700
|
+
if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
|
|
701
|
+
const d = el.data;
|
|
702
|
+
const rows = Object.entries(d).filter(([k]) => !RECORD_SKIP.has(k)).map(([k, v]) => {
|
|
703
|
+
const s = scalar(v);
|
|
704
|
+
if (s === void 0) return "";
|
|
705
|
+
if (TIME_KEY_RE.test(k)) return kv(key(ctx, k), ts(v));
|
|
706
|
+
if (RECORD_WORD_FIELDS.has(k)) return kv(key(ctx, k), word(ctx, s));
|
|
707
|
+
return kv(key(ctx, k), val(s));
|
|
708
|
+
}).join("");
|
|
709
|
+
return card(el, rows);
|
|
710
|
+
}
|
|
711
|
+
function step(tag, rows, cls = "") {
|
|
712
|
+
return `<div class="sv-step${cls}"><span class="sv-step-tag">${escapeHtml(tag)}</span>${rows}</div>`;
|
|
713
|
+
}
|
|
714
|
+
function stepKv(k, v) {
|
|
715
|
+
return `<div class="sv-kv sv-kv-col">${k}${v}</div>`;
|
|
716
|
+
}
|
|
717
|
+
function renderCase(ctx, el) {
|
|
718
|
+
if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
|
|
719
|
+
const d = el.data;
|
|
720
|
+
const steps = Array.isArray(d.steps) ? d.steps : [];
|
|
721
|
+
const goal = d.goal;
|
|
722
|
+
const approvalsByTicket = /* @__PURE__ */ new Map();
|
|
723
|
+
for (const a of Array.isArray(d.approvals) ? d.approvals : []) approvalsByTicket.set(a.ticketId, a);
|
|
724
|
+
const start = d.start;
|
|
725
|
+
const items = [];
|
|
726
|
+
if (start) {
|
|
727
|
+
const rows = stepKv(key(ctx, "received_at"), ts(start.receivedAt ?? start.emailTime)) + (start.subject !== void 0 ? stepKv(key(ctx, "subject"), val(start.subject)) : "") + (start.sender !== void 0 ? stepKv(key(ctx, "from_email"), val(start.sender)) : "") + (start.basis === "loaded" && start.loadedAt !== void 0 ? stepKv(key(ctx, "simulation_load.loaded_at"), ts(start.loadedAt)) : "");
|
|
728
|
+
items.push({ time: start.time, html: step("database", rows) });
|
|
729
|
+
}
|
|
730
|
+
const ticketNodes = [
|
|
731
|
+
...steps.map((s) => ({ node: s, isGoal: false })),
|
|
732
|
+
...goal ? [{ node: goal, isGoal: true }] : []
|
|
733
|
+
];
|
|
734
|
+
for (const { node, isGoal } of ticketNodes) {
|
|
735
|
+
const approval = approvalsByTicket.get(node.ticketId);
|
|
736
|
+
if (approval) {
|
|
737
|
+
const t = Math.min(approval.decidedAt ?? approval.createdAt ?? node.time, node.time);
|
|
738
|
+
const rows2 = (approval.createdAt !== void 0 ? stepKv(key(ctx, "approval.createdAt"), ts(approval.createdAt)) : "") + (approval.decidedAt !== void 0 ? stepKv(key(ctx, "approval.decidedAt"), ts(approval.decidedAt)) : "") + (approval.whoLabel !== void 0 ? stepKv(key(ctx, "approval.committedBy"), val(approval.whoLabel)) : "");
|
|
739
|
+
items.push({ time: t, html: step("archive", rows2) });
|
|
740
|
+
}
|
|
741
|
+
const rows = stepKv(key(ctx, "action"), word(ctx, node.action)) + stepKv(key(ctx, "timestamp"), ts(node.time));
|
|
742
|
+
items.push({ time: node.time, html: step(isGoal ? "signed \xB7 goal" : "signed", rows, isGoal ? " sv-step-goal" : "") });
|
|
743
|
+
}
|
|
744
|
+
items.sort((a, b) => a.time - b.time);
|
|
745
|
+
const duration = typeof d.totalDurationSeconds === "number" ? kv(key(ctx, "duration_s"), val(rawNumber(d.totalDurationSeconds)) + formula("goal.timestamp \u2212 max(start.received_at, simulation_load.loaded_at)")) : `<div class="sv-note">${escapeHtml(String(d.timeUnverifiableReason ?? "duration_s not verifiable"))}</div>`;
|
|
746
|
+
const head = kv(key(ctx, "case_id"), val(d.caseId));
|
|
747
|
+
return card(el, duration + `<div class="sv-tl">${items.map((i) => i.html).join("")}</div>`, { head });
|
|
748
|
+
}
|
|
749
|
+
var METRIC_FIELDS = {
|
|
750
|
+
completed: { name: "cases_completed", formula: "count(case.goal verified)" },
|
|
751
|
+
"median-time": { name: "median_duration_s", formula: "median(case.duration_s)" },
|
|
752
|
+
"average-time": { name: "average_duration_s", formula: "mean(case.duration_s)" },
|
|
753
|
+
"without-approval": { name: "without_approval_ratio", formula: "count(case.approvals = 0) / count(case)" },
|
|
754
|
+
approvals: { name: "approvals", formula: "count(case.approval)" },
|
|
755
|
+
"median-approval-wait": { name: "median_approval_wait_s", formula: "median(approval.decidedAt \u2212 approval.createdAt)" },
|
|
756
|
+
tickets: { name: "tickets", formula: "count(distinct case.goal, case.steps)" },
|
|
757
|
+
refusals: { name: "refusals", formula: "count(refusal.at in [case.start, case.goal])" }
|
|
758
|
+
};
|
|
759
|
+
function renderMetric(ctx, el) {
|
|
760
|
+
if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
|
|
761
|
+
const d = el.data;
|
|
762
|
+
const kind = String(d.kind ?? "");
|
|
763
|
+
const f = METRIC_FIELDS[kind] ?? { name: kind, formula: "" };
|
|
764
|
+
const caseIds = Array.isArray(d.caseIds) ? d.caseIds.map(String) : [];
|
|
765
|
+
const body = `<div class="sv-kv sv-metric">${key(ctx, f.name)}${val(rawNumber(d.value))}${f.formula ? formula(f.formula) : ""}</div>` + (caseIds.length > 0 ? kv(key(ctx, "cases"), val(caseIds.join(" "))) : "");
|
|
766
|
+
return card(el, body);
|
|
767
|
+
}
|
|
768
|
+
function renderElement(ctx, el) {
|
|
769
|
+
switch (el.kind) {
|
|
770
|
+
case "sv-ticket":
|
|
771
|
+
return renderTicket(ctx, el);
|
|
772
|
+
case "sv-approval":
|
|
773
|
+
return renderApproval(ctx, el);
|
|
774
|
+
case "sv-mandate":
|
|
775
|
+
return renderMandate(ctx, el);
|
|
776
|
+
case "sv-record":
|
|
777
|
+
return renderRecord(ctx, el);
|
|
778
|
+
case "sv-case":
|
|
779
|
+
return renderCase(ctx, el);
|
|
780
|
+
case "sv-metric":
|
|
781
|
+
return renderMetric(ctx, el);
|
|
782
|
+
default:
|
|
783
|
+
return unverifiableCard(el);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function pick(d, keys) {
|
|
787
|
+
const out = {};
|
|
788
|
+
if (!d) return out;
|
|
789
|
+
for (const k of keys) if (d[k] !== void 0) out[k] = d[k];
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
function drawnElement(el) {
|
|
793
|
+
const base = {
|
|
794
|
+
id: el.id,
|
|
795
|
+
kind: el.kind,
|
|
796
|
+
attrs: el.attrs,
|
|
797
|
+
status: el.status,
|
|
798
|
+
...el.reason !== void 0 ? { reason: el.reason } : {}
|
|
799
|
+
};
|
|
800
|
+
if (el.status === "unverifiable" || !el.data) return base;
|
|
801
|
+
const d = el.data;
|
|
802
|
+
let data;
|
|
803
|
+
switch (el.kind) {
|
|
804
|
+
case "sv-ticket": {
|
|
805
|
+
const full = (el.attrs.variant ?? "").trim().toLowerCase() === "full";
|
|
806
|
+
if (!full) {
|
|
807
|
+
data = pick(d, ["action", "time"]);
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
data = pick(d, ["ticketId", "action", "actionType", "profile", "executionContext", "time", "checkUrl"]);
|
|
811
|
+
if (d.mandate) data.mandate = pick(d.mandate, ["rawLimits", "mode", "owners"]);
|
|
812
|
+
if (d.approval) data.approval = pick(d.approval, ["createdAt", "decidedAt", "whoLabel"]);
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case "sv-approval":
|
|
816
|
+
data = pick(d, ["createdAt", "decidedAt", "whoLabel", "status", "waitSeconds"]);
|
|
817
|
+
break;
|
|
818
|
+
case "sv-mandate":
|
|
819
|
+
data = pick(d, ["profile", "rawLimits", "mode", "owners", "intent"]);
|
|
820
|
+
break;
|
|
821
|
+
case "sv-record":
|
|
822
|
+
data = pick(d, Object.keys(d).filter((k) => !RECORD_SKIP.has(k)));
|
|
823
|
+
break;
|
|
824
|
+
case "sv-case": {
|
|
825
|
+
data = pick(d, ["caseId", "totalDurationSeconds", "timeUnverifiableReason"]);
|
|
826
|
+
if (d.start) data.start = pick(d.start, ["time", "emailTime", "basis", "subject", "sender", "receivedAt", "loadedAt"]);
|
|
827
|
+
if (d.goal) data.goal = pick(d.goal, ["ticketId", "time", "action"]);
|
|
828
|
+
data.steps = (Array.isArray(d.steps) ? d.steps : []).map((x) => pick(x, ["ticketId", "time", "action"]));
|
|
829
|
+
data.approvals = (Array.isArray(d.approvals) ? d.approvals : []).map((x) => pick(x, ["ticketId", "whoLabel", "createdAt", "decidedAt"]));
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
case "sv-metric":
|
|
833
|
+
data = pick(d, ["kind", "value", "caseIds"]);
|
|
834
|
+
break;
|
|
835
|
+
default:
|
|
836
|
+
return base;
|
|
837
|
+
}
|
|
838
|
+
return { ...base, data };
|
|
839
|
+
}
|
|
840
|
+
function line(key2, value) {
|
|
841
|
+
return { key: key2, value };
|
|
842
|
+
}
|
|
843
|
+
function ticketFields(d) {
|
|
844
|
+
const out = [line("action", String(d.action ?? ""))];
|
|
845
|
+
if (d.actionType !== void 0) out.push(line("actionType", String(d.actionType)));
|
|
846
|
+
if (d.profile !== void 0) out.push(line("profileId", String(d.profile)));
|
|
847
|
+
for (const [k, v] of Object.entries(d.executionContext ?? {})) {
|
|
848
|
+
const sv = scalar(v);
|
|
849
|
+
if (sv !== void 0) out.push(line(k, sv));
|
|
850
|
+
}
|
|
851
|
+
out.push(line("timestamp", formatTimestamp(d.time)));
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
function approvalFields(d) {
|
|
855
|
+
const out = [];
|
|
856
|
+
if (d.createdAt !== void 0) out.push(line("createdAt", formatTimestamp(d.createdAt)));
|
|
857
|
+
if (d.decidedAt !== void 0) out.push(line("decidedAt", formatTimestamp(d.decidedAt)));
|
|
858
|
+
if (typeof d.whoLabel === "string") out.push(line("committedBy", d.whoLabel));
|
|
859
|
+
if (typeof d.status === "string") out.push(line("status", d.status));
|
|
860
|
+
if (typeof d.waitSeconds === "number") out.push(line("wait_s", `${rawNumber(d.waitSeconds)} = decidedAt \u2212 createdAt`));
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
function mandateFields(d) {
|
|
864
|
+
const out = [];
|
|
865
|
+
if (d.profile !== void 0) out.push(line("profileId", String(d.profile)));
|
|
866
|
+
for (const [k, v] of Object.entries(d.rawLimits ?? {})) {
|
|
867
|
+
const sv = scalar(v);
|
|
868
|
+
if (sv !== void 0) out.push(line(k, sv));
|
|
869
|
+
}
|
|
870
|
+
if (typeof d.mode === "string") out.push(line("commitment_mode", d.mode));
|
|
871
|
+
for (const o of Array.isArray(d.owners) ? d.owners : []) out.push(line("owner", String(o)));
|
|
872
|
+
if (typeof d.intent === "string" && d.intent) out.push(line("intent", d.intent));
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
var fmt = (lines) => lines.map((l) => `${l.key} ${l.value}`).join(" \xB7 ");
|
|
876
|
+
function checkedValueLine(el) {
|
|
877
|
+
const d = el.data ?? {};
|
|
878
|
+
switch (el.kind) {
|
|
879
|
+
case "sv-ticket":
|
|
880
|
+
return fmt([line("action", String(d.action ?? "")), line("timestamp", formatTimestamp(d.time))]);
|
|
881
|
+
case "sv-approval": {
|
|
882
|
+
const a = approvalFields(d);
|
|
883
|
+
const pick2 = a.filter((l) => l.key === "wait_s" || l.key === "committedBy");
|
|
884
|
+
return fmt(pick2.length > 0 ? pick2 : a);
|
|
885
|
+
}
|
|
886
|
+
case "sv-mandate":
|
|
887
|
+
return fmt(mandateFields(d).filter((l) => l.key === "profileId" || l.key === "commitment_mode"));
|
|
888
|
+
case "sv-record": {
|
|
889
|
+
const k = ["number", "id", "subject"].find((x) => scalar(d[x]) !== void 0);
|
|
890
|
+
return k ? fmt([line(k, String(scalar(d[k])))]) : el.id;
|
|
891
|
+
}
|
|
892
|
+
case "sv-case": {
|
|
893
|
+
const out = [line("case_id", String(d.caseId ?? ""))];
|
|
894
|
+
if (typeof d.totalDurationSeconds === "number") out.push(line("duration_s", rawNumber(d.totalDurationSeconds)));
|
|
895
|
+
return fmt(out);
|
|
896
|
+
}
|
|
897
|
+
case "sv-metric": {
|
|
898
|
+
const kind = String(d.kind ?? "");
|
|
899
|
+
return fmt([line(METRIC_FIELDS[kind]?.name ?? kind, rawNumber(d.value))]);
|
|
900
|
+
}
|
|
901
|
+
default:
|
|
902
|
+
return el.id;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
var AI_ANALYSIS_LABEL = "AI analysis \u2014 not verified";
|
|
906
|
+
var VERIFIED_LEGEND_TEXT = "Green = verified by the gateway \u2014 raw signed fields only";
|
|
907
|
+
var AI_LEGEND_TEXT = "Grey dashed = the AI's own analysis, not verified";
|
|
908
|
+
var GLOSS_LEGEND_TEXT = "= translation by the AI, not verified";
|
|
909
|
+
var MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
910
|
+
var DRAWN_ELEMENT_STYLES = `
|
|
911
|
+
.sv-report { font:14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; color:#111; max-width:900px; }
|
|
912
|
+
.sv-legend { display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; font-size:12px; color:#555; border-bottom:1px solid #e5e5e5; padding:0 0 8px; margin:0 0 10px; }
|
|
913
|
+
.sv-legend-chip { display:inline-flex; align-items:center; gap:6px; }
|
|
914
|
+
.sv-sw { width:11px; height:11px; border-radius:3px; display:inline-block; flex:none; }
|
|
915
|
+
.sv-sw-v { background:#ecfdf3; border:2px solid #15803d; }
|
|
916
|
+
.sv-sw-a { border:2px dashed #9a9a9a; }
|
|
917
|
+
.sv-gloss-swatch { font-style:italic; color:#666; border-bottom:1px dotted #666; }
|
|
918
|
+
.sv-el { border:2px solid #15803d; border-radius:10px; background:#fff; padding:12px 14px; margin:12px 0; min-width:0; }
|
|
919
|
+
.sv-el.sv-compact { padding:8px 12px; }
|
|
920
|
+
.sv-el-warning { border-color:#b45309; }
|
|
921
|
+
.sv-el-unverifiable { border:2px solid #b91c1c; background:#fff; }
|
|
922
|
+
.sv-vhead { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:4px; }
|
|
923
|
+
.sv-vline { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
924
|
+
.sv-kv-row { display:flex; gap:4px 16px; flex-wrap:wrap; }
|
|
925
|
+
.sv-kv { font-family:${MONO}; font-size:12.5px; display:flex; gap:7px; flex-wrap:wrap; align-items:flex-end; margin:3px 0; line-height:1.5; min-width:0; }
|
|
926
|
+
.sv-kv.sv-in { padding-left:14px; }
|
|
927
|
+
.sv-kv.sv-block { flex-direction:column; align-items:flex-start; gap:0; }
|
|
928
|
+
.sv-k { color:#666; }
|
|
929
|
+
.sv-v { color:#111; font-weight:600; word-break:break-word; overflow-wrap:anywhere; }
|
|
930
|
+
.sv-text { font-weight:400; white-space:pre-wrap; }
|
|
931
|
+
.sv-f { color:#444; font-weight:400; }
|
|
932
|
+
.sv-metric { font-size:13px; }
|
|
933
|
+
.sv-group { margin:6px 0 2px; }
|
|
934
|
+
.sv-group-label { font-family:${MONO}; font-size:11px; color:#666; }
|
|
935
|
+
.sv-badge { display:inline-flex; align-items:center; gap:4px; white-space:nowrap; font:600 10.5px/1.6 system-ui, sans-serif; border-radius:999px; padding:1px 8px; }
|
|
936
|
+
.sv-badge-ok { color:#15803d; background:#ecfdf3; }
|
|
937
|
+
.sv-badge-warn { color:#b45309; background:#fff7e6; }
|
|
938
|
+
.sv-badge-bad { color:#b91c1c; background:#fdecea; }
|
|
939
|
+
.sv-note, .sv-reason { color:#b45309; font-size:12px; margin:2px 0 4px; }
|
|
940
|
+
.sv-reason { color:#b91c1c; }
|
|
941
|
+
.sv-link { margin-top:4px; }
|
|
942
|
+
.sv-link a { color:#1d4ed8; text-decoration:none; font-size:12px; }
|
|
943
|
+
.sv-row { display:grid; grid-template-columns:repeat(auto-fit, minmax(190px, 1fr)); gap:10px; margin:12px 0; }
|
|
944
|
+
.sv-row > .sv-el { margin:0; }
|
|
945
|
+
.sv-tl { display:flex; gap:16px; overflow-x:auto; padding:10px 2px 4px; margin-top:6px; }
|
|
946
|
+
.sv-step { width:180px; min-width:180px; flex:none; border:1px solid #e5e5e5; border-radius:8px; padding:9px 9px 7px; position:relative; background:#fafafa; }
|
|
947
|
+
.sv-step-goal { border-color:#15803d; }
|
|
948
|
+
.sv-step .sv-kv-col { flex-direction:column; gap:0; align-items:flex-start; margin:1px 0; font-size:11px; }
|
|
949
|
+
.sv-step .sv-kv-col .sv-k { font-size:9.5px; }
|
|
950
|
+
.sv-step:not(:last-child)::after { content:"\\2192"; position:absolute; right:-14px; top:38%; color:#888; font-size:12px; }
|
|
951
|
+
.sv-step-tag { position:absolute; top:-8px; right:8px; background:#fff; border:1px solid #e5e5e5; color:#666; font:9px/1.5 system-ui, sans-serif; padding:0 6px; border-radius:999px; }
|
|
952
|
+
.sv-ai-wrap { margin:14px 0 12px; }
|
|
953
|
+
.sv-ai-label { display:inline-flex; align-items:center; background:#eee; color:#555; font:600 10.5px/1.6 system-ui, sans-serif; padding:1px 8px; border-radius:999px; margin:0 0 6px 10px; }
|
|
954
|
+
.sv-ai-block { position:relative; isolation:isolate; contain:paint; overflow:hidden; border:2px dashed #9a9a9a; border-radius:10px; background:#fafafa; padding:14px; }
|
|
955
|
+
.sv-ai-content { font-size:13.5px; overflow-wrap:anywhere; }
|
|
956
|
+
.sv-ai-content img, .sv-ai-content svg { max-width:100%; height:auto; }
|
|
957
|
+
ruby.sv-gloss { ruby-position:over; }
|
|
958
|
+
ruby.sv-gloss rt { font:italic 400 14.5px/1.4 system-ui, sans-serif; color:#666; border-bottom:1px dotted #666; }
|
|
959
|
+
.sv-gloss-toggle-mode ruby.sv-gloss rt { display:none; }
|
|
960
|
+
@media (max-width: 480px) {
|
|
961
|
+
.sv-tl { flex-direction:column; overflow:visible; }
|
|
962
|
+
.sv-step { width:auto; min-width:0; margin-bottom:6px; }
|
|
963
|
+
.sv-step:not(:last-child)::after { content:"\\2193"; right:auto; left:10px; top:auto; bottom:-17px; }
|
|
964
|
+
}
|
|
965
|
+
`;
|
|
966
|
+
var GLOSS_ON_STYLES = `
|
|
967
|
+
ruby.sv-gloss { font-size:11.5px; }
|
|
968
|
+
`;
|
|
969
|
+
function reportLegend(showGloss) {
|
|
970
|
+
return `<div class="sv-legend" role="note"><span class="sv-legend-chip"><span class="sv-sw sv-sw-v"></span>${escapeHtml(VERIFIED_LEGEND_TEXT)}</span><span class="sv-legend-chip"><span class="sv-sw sv-sw-a"></span>${escapeHtml(AI_LEGEND_TEXT)}</span>` + (showGloss ? `<span class="sv-legend-chip"><span class="sv-gloss-swatch">Abc</span> ${escapeHtml(GLOSS_LEGEND_TEXT)}</span>` : "") + `</div>`;
|
|
971
|
+
}
|
|
972
|
+
function walkTopLevel(sanitized) {
|
|
973
|
+
const doc = parseDocument2(sanitized, {
|
|
974
|
+
withStartIndices: true,
|
|
975
|
+
withEndIndices: true,
|
|
976
|
+
recognizeSelfClosing: true,
|
|
977
|
+
decodeEntities: true
|
|
978
|
+
});
|
|
979
|
+
return { html: sanitized, nodes: (doc.children ?? []).filter((n) => n.type === "tag") };
|
|
980
|
+
}
|
|
981
|
+
function readGlossary(walk) {
|
|
982
|
+
const g = walk.nodes.find((n) => n.name === "sv-glossary");
|
|
983
|
+
if (!g) return void 0;
|
|
984
|
+
const terms = /* @__PURE__ */ new Map();
|
|
985
|
+
for (const t of g.children ?? []) {
|
|
986
|
+
if (t.type !== "tag" || t.name !== "sv-term") continue;
|
|
987
|
+
const k = t.attribs?.key ?? "";
|
|
988
|
+
const text = (t.children ?? []).map((c) => c.data ?? "").join("");
|
|
989
|
+
if (k && !terms.has(k)) terms.set(k, text);
|
|
990
|
+
}
|
|
991
|
+
return { lang: g.attribs?.lang ?? "", terms };
|
|
992
|
+
}
|
|
993
|
+
function innerOf(walk, node) {
|
|
994
|
+
const children = node.children ?? [];
|
|
995
|
+
if (children.length === 0) return "";
|
|
996
|
+
const start = children[0].startIndex;
|
|
997
|
+
const end = children[children.length - 1].endIndex;
|
|
998
|
+
return start !== null && end !== null ? walk.html.slice(start, end + 1) : "";
|
|
999
|
+
}
|
|
1000
|
+
function drawBody(walk, elements, ctx) {
|
|
1001
|
+
const byId = new Map(elements.map((e) => [e.id, e]));
|
|
1002
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1003
|
+
const nextEl = (node) => {
|
|
1004
|
+
const kind = node.name ?? "";
|
|
1005
|
+
const n = seen.get(kind) ?? 0;
|
|
1006
|
+
seen.set(kind, n + 1);
|
|
1007
|
+
const id = `${kind}-${n}`;
|
|
1008
|
+
const el = byId.get(id) ?? { id, kind, attrs: node.attribs ?? {}, status: "unverifiable", reason: "No verification result for this element." };
|
|
1009
|
+
return renderElement(ctx, el);
|
|
1010
|
+
};
|
|
1011
|
+
const parts = [];
|
|
1012
|
+
for (const node of walk.nodes) {
|
|
1013
|
+
const name = node.name ?? "";
|
|
1014
|
+
if (name === "sv-ai") {
|
|
1015
|
+
parts.push(
|
|
1016
|
+
`<div class="sv-ai-wrap"><span class="sv-ai-label">${escapeHtml(AI_ANALYSIS_LABEL)}</span><div class="sv-ai-block"><div class="sv-ai-content">${innerOf(walk, node)}</div></div></div>`
|
|
1017
|
+
);
|
|
1018
|
+
} else if (name === "sv-row") {
|
|
1019
|
+
const cells = (node.children ?? []).filter((c) => c.type === "tag" && (c.name ?? "").startsWith("sv-") && !STRUCTURE_TAGS.has(c.name ?? ""));
|
|
1020
|
+
parts.push(`<div class="sv-row">${cells.map(nextEl).join("")}</div>`);
|
|
1021
|
+
} else if (name === "sv-glossary") {
|
|
1022
|
+
continue;
|
|
1023
|
+
} else if (name.startsWith("sv-")) {
|
|
1024
|
+
parts.push(nextEl(node));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return parts.join("\n");
|
|
1028
|
+
}
|
|
1029
|
+
var NOT_IN_BOXES = "not a field name or fixed word in your verified boxes (numbers, timestamps and ids are never translated)";
|
|
1030
|
+
function glossaryUsage(rawHtml, elements) {
|
|
1031
|
+
const sanitized = sanitizeReport(rawHtml);
|
|
1032
|
+
const walk = walkTopLevel(sanitized.html);
|
|
1033
|
+
const g = readGlossary(walk);
|
|
1034
|
+
if (!g && sanitized.notes.rejectedTerms.length === 0) return void 0;
|
|
1035
|
+
const ctx = { mode: "on", terms: g?.terms ?? /* @__PURE__ */ new Map(), used: /* @__PURE__ */ new Set() };
|
|
1036
|
+
drawBody(walk, elements, ctx);
|
|
1037
|
+
const rejected = [...sanitized.notes.rejectedTerms];
|
|
1038
|
+
for (const k of ctx.terms.keys()) if (!ctx.used.has(k)) rejected.push({ key: k, reason: NOT_IN_BOXES });
|
|
1039
|
+
return { lang: g?.lang ?? "", applied: [...ctx.used], rejected };
|
|
1040
|
+
}
|
|
1041
|
+
function renderReportHtml(rawHtml, elements, opts = {}) {
|
|
1042
|
+
const mode = opts.gloss ?? "off";
|
|
1043
|
+
const walk = walkTopLevel(sanitizeReport(rawHtml).html);
|
|
1044
|
+
const g = readGlossary(walk);
|
|
1045
|
+
const ctx = { mode, terms: g?.terms ?? /* @__PURE__ */ new Map(), used: /* @__PURE__ */ new Set() };
|
|
1046
|
+
const body = drawBody(walk, elements, ctx);
|
|
1047
|
+
const showGloss = mode !== "off" && ctx.used.size > 0;
|
|
1048
|
+
void showGloss;
|
|
1049
|
+
const styles = `<style data-sv-drawn-styles="1">${DRAWN_ELEMENT_STYLES}${mode === "on" ? GLOSS_ON_STYLES : ""}</style>`;
|
|
1050
|
+
const langAttr = showGloss && g?.lang ? ` data-sv-gloss-lang="${escapeHtml(g.lang)}"` : "";
|
|
1051
|
+
const cls = mode === "toggle" ? "sv-report sv-gloss-toggle-mode" : "sv-report";
|
|
1052
|
+
return `${styles}<div class="${cls}"${langAttr}>${body}</div>`;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/lib/report/export-report.ts
|
|
1056
|
+
import { decodeAttestationBlob, getProfile as getProfile2 } from "@hap/core";
|
|
1057
|
+
function processTimeZone() {
|
|
1058
|
+
try {
|
|
1059
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1060
|
+
if (tz) return tz;
|
|
1061
|
+
} catch {
|
|
1062
|
+
}
|
|
1063
|
+
return "UTC";
|
|
1064
|
+
}
|
|
1065
|
+
function canonicalOrder(profileId, bounds) {
|
|
1066
|
+
const keyOrder = getProfile2(profileId)?.boundsSchema?.keyOrder;
|
|
1067
|
+
if (!Array.isArray(keyOrder)) return bounds;
|
|
1068
|
+
const out = {};
|
|
1069
|
+
for (const k of keyOrder) if (bounds[k] !== void 0) out[k] = bounds[k];
|
|
1070
|
+
for (const [k, v] of Object.entries(bounds)) if (!(k in out)) out[k] = v;
|
|
1071
|
+
return out;
|
|
1072
|
+
}
|
|
1073
|
+
function disclosedNames(blobs) {
|
|
1074
|
+
const names = /* @__PURE__ */ new Map();
|
|
1075
|
+
for (const blob of blobs) {
|
|
1076
|
+
try {
|
|
1077
|
+
for (const sub of decodeAttestationBlob(blob).payload.subjects ?? []) {
|
|
1078
|
+
if (sub.assurance === "high" && sub.disclose?.name) {
|
|
1079
|
+
if (!names.has(sub.did)) names.set(sub.did, /* @__PURE__ */ new Set());
|
|
1080
|
+
names.get(sub.did).add(sub.disclose.name);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
} catch {
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return names;
|
|
1087
|
+
}
|
|
1088
|
+
function firstOwnerDids(auth) {
|
|
1089
|
+
const blob = auth?.attestations[0]?.blob;
|
|
1090
|
+
if (!blob) return [];
|
|
1091
|
+
try {
|
|
1092
|
+
return decodeAttestationBlob(blob).payload.resolved_owners ?? [];
|
|
1093
|
+
} catch {
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function receiptId(receipt) {
|
|
1098
|
+
return typeof receipt.id === "string" ? receipt.id : "";
|
|
1099
|
+
}
|
|
1100
|
+
function buildExportBundle(params) {
|
|
1101
|
+
const { stored, archive, authorityServer, gatewayVersion, now = Math.floor(Date.now() / 1e3) } = params;
|
|
1102
|
+
const timeZone = params.timeZone ?? processTimeZone();
|
|
1103
|
+
const elements = stored.result.elements.map(drawnElement);
|
|
1104
|
+
const proof = {
|
|
1105
|
+
...stored.result.proof,
|
|
1106
|
+
verifiedValues: withDrawingZone(timeZone, () => elements.filter((e) => e.status !== "unverifiable").map((e) => ({ elementId: e.id, kind: e.kind, summary: checkedValueLine(e) })))
|
|
1107
|
+
};
|
|
1108
|
+
const coverage = stored.result.coverage;
|
|
1109
|
+
const ticketIdSet = /* @__PURE__ */ new Set([...proof.ticketsReferenced, ...coverage.ticketsInPeriod]);
|
|
1110
|
+
const entries = archive.getReceipts().filter((r) => ticketIdSet.has(receiptId(r.receipt)));
|
|
1111
|
+
const tickets = entries.map((r) => r.receipt);
|
|
1112
|
+
const placed = stored.result.elements.filter((e) => e.status !== "unverifiable");
|
|
1113
|
+
const mandateCardTickets = new Set(
|
|
1114
|
+
placed.filter((e) => e.kind === "sv-mandate" && e.attrs.ticket).map((e) => e.attrs.ticket)
|
|
1115
|
+
);
|
|
1116
|
+
const fullTicketTickets = new Set(
|
|
1117
|
+
placed.filter((e) => e.kind === "sv-ticket" && e.attrs.ref && e.data?.mandate).map((e) => e.attrs.ref)
|
|
1118
|
+
);
|
|
1119
|
+
const authOf = (set) => new Set(entries.filter((r) => set.has(receiptId(r.receipt))).map((r) => r.authorizationId));
|
|
1120
|
+
const withIntent = authOf(mandateCardTickets);
|
|
1121
|
+
const withoutIntent = authOf(fullTicketTickets);
|
|
1122
|
+
const authorizations = {};
|
|
1123
|
+
for (const a of archive.getAuthorizations()) {
|
|
1124
|
+
if (!withIntent.has(a.authorizationId) && !withoutIntent.has(a.authorizationId)) continue;
|
|
1125
|
+
const out = {
|
|
1126
|
+
authorizationId: a.authorizationId,
|
|
1127
|
+
profileId: a.profileId,
|
|
1128
|
+
...a.boundsHash !== void 0 ? { boundsHash: a.boundsHash } : {},
|
|
1129
|
+
...a.contextHash !== void 0 ? { contextHash: a.contextHash } : {},
|
|
1130
|
+
...a.bounds !== void 0 ? { bounds: canonicalOrder(a.profileId, a.bounds) } : {},
|
|
1131
|
+
...withIntent.has(a.authorizationId) && a.intent !== void 0 ? { intent: a.intent } : {},
|
|
1132
|
+
attestations: a.attestations,
|
|
1133
|
+
archivedAt: a.archivedAt
|
|
1134
|
+
};
|
|
1135
|
+
authorizations[a.authorizationId] = out;
|
|
1136
|
+
}
|
|
1137
|
+
const bundledBlobs = Object.values(authorizations).flatMap((a) => a.attestations.map((x) => x.blob));
|
|
1138
|
+
const known = disclosedNames(bundledBlobs);
|
|
1139
|
+
const identityAttestations = [];
|
|
1140
|
+
const archiveBlobs = archive.getAuthorizations().flatMap((a) => a.attestations.map((x) => x.blob));
|
|
1141
|
+
const ticketAuth = new Map(entries.map((r) => [receiptId(r.receipt), r.authorizationId]));
|
|
1142
|
+
for (const el of elements) {
|
|
1143
|
+
const owners = el.kind === "sv-mandate" ? el.data?.owners : el.kind === "sv-ticket" ? el.data?.mandate?.owners : void 0;
|
|
1144
|
+
if (!Array.isArray(owners)) continue;
|
|
1145
|
+
const ref = el.kind === "sv-mandate" ? el.attrs.ticket : el.attrs.ref;
|
|
1146
|
+
const dids = firstOwnerDids(authorizations[ticketAuth.get(ref ?? "") ?? ""]);
|
|
1147
|
+
owners.forEach((label, i) => {
|
|
1148
|
+
const did = dids[i];
|
|
1149
|
+
if (!did || typeof label !== "string" || label === UNDISCLOSED_OWNER_LABEL) return;
|
|
1150
|
+
if (known.get(did)?.has(label)) return;
|
|
1151
|
+
const blob = archiveBlobs.find((b) => disclosedNames([b]).get(did)?.has(label));
|
|
1152
|
+
if (!blob) return;
|
|
1153
|
+
identityAttestations.push(blob);
|
|
1154
|
+
for (const [d, set] of disclosedNames([blob])) {
|
|
1155
|
+
if (!known.has(d)) known.set(d, /* @__PURE__ */ new Set());
|
|
1156
|
+
for (const n of set) known.get(d).add(n);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
format: "suveren-report-export",
|
|
1162
|
+
version: 2,
|
|
1163
|
+
exportedAt: now,
|
|
1164
|
+
gatewayVersion,
|
|
1165
|
+
report: { html: stored.html, savedAt: stored.savedAt, checkedAt: stored.checkedAt },
|
|
1166
|
+
proof,
|
|
1167
|
+
coverage,
|
|
1168
|
+
authorityServer,
|
|
1169
|
+
tickets,
|
|
1170
|
+
authorizations,
|
|
1171
|
+
elements,
|
|
1172
|
+
timeZone,
|
|
1173
|
+
identityAttestations
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function countLine(count, noneText = "none in this report") {
|
|
1177
|
+
return count > 0 ? `${count} \u2713` : noneText;
|
|
1178
|
+
}
|
|
1179
|
+
function renderProofCoveragePanels(proof, coverage) {
|
|
1180
|
+
const casesLine = coverage.emailExportError ? "unknown \u2014 the email simulator could not be read" : `${coverage.coveredCases.length} of ${coverage.loadedCases.length}`;
|
|
1181
|
+
const periodNote = coverage.periodStart === null ? `<p class="sv-export-note">${coverage.emailExportError ? "Test period start unknown \u2014 the email simulator could not be read. All saved tickets were counted." : "Test period start unknown \u2014 all saved tickets were counted."}</p>` : "";
|
|
1182
|
+
const missingParts = [];
|
|
1183
|
+
if (coverage.missingCases.length > 0) missingParts.push(`cases ${coverage.missingCases.map(escapeHtml).join(", ")}`);
|
|
1184
|
+
if (coverage.ticketsNotReferenced.length > 0) missingParts.push(`${coverage.ticketsNotReferenced.length} ticket${coverage.ticketsNotReferenced.length === 1 ? "" : "s"}`);
|
|
1185
|
+
const missingLine = missingParts.length > 0 ? `<p class="sv-export-missing">Not in the report: ${missingParts.join(" \xB7 ")}</p>` : "";
|
|
1186
|
+
const checkedValues = proof.verifiedValues.length > 0 ? `<div class="sv-export-card"><div class="sv-export-card-title">Checked values</div>${proof.verifiedValues.map((v) => `<div class="sv-export-row"><span>${escapeHtml(v.summary)}</span></div>`).join("")}</div>` : "";
|
|
1187
|
+
return `<aside class="sv-export-side"><div class="sv-export-card"><div class="sv-export-card-title">Proof</div><div class="sv-export-row"><span>Tickets referenced</span><b>${proof.ticketsReferenced.length}</b></div><div class="sv-export-row"><span>Signatures valid</span><b>${escapeHtml(countLine(proof.signaturesValid))}</b></div><div class="sv-export-row"><span>Records checked</span><b>${escapeHtml(countLine(proof.recordsChecked))}</b></div><div class="sv-export-row"><span>Not verifiable</span><b>${proof.unverifiableCount}</b></div></div><div class="sv-export-card"><div class="sv-export-card-title">Coverage</div><div class="sv-export-row"><span>Cases</span><b>${casesLine}</b></div><div class="sv-export-row"><span>Tickets in test period</span><b>${coverage.ticketsReferenced.length} of ${coverage.ticketsInPeriod.length}</b></div>` + periodNote + missingLine + `</div>` + checkedValues + `</aside>`;
|
|
1188
|
+
}
|
|
1189
|
+
var GLOSS_ON_TOGGLED = GLOSS_ON_STYLES.trim().split("\n").filter(Boolean).map((rule) => `#sv-gloss-toggle:checked ~ .sv-export-layout ${rule}`).join("\n");
|
|
1190
|
+
var EXPORT_PAGE_STYLES = `
|
|
1191
|
+
.sv-export-header { font:13px/1.5 system-ui, sans-serif; background:#f6f6f4; border-bottom:1px solid #e5e5e5; padding:14px 20px; }
|
|
1192
|
+
.sv-export-meta { margin:0 0 6px 0; font-weight:600; }
|
|
1193
|
+
.sv-export-howto { margin:0; color:#444; }
|
|
1194
|
+
.sv-export-howto code { background:#eee; border-radius:4px; padding:1px 5px; font-size:12px; }
|
|
1195
|
+
.sv-export-layout { display:flex; gap:20px; align-items:flex-start; padding:20px; font:13px/1.5 system-ui, sans-serif; }
|
|
1196
|
+
.sv-export-main { flex:1 1 auto; min-width:0; }
|
|
1197
|
+
.sv-export-side { flex:0 0 260px; }
|
|
1198
|
+
.sv-export-card { border:1px solid #e5e5e5; border-radius:10px; padding:10px 12px; margin-bottom:12px; background:#fff; }
|
|
1199
|
+
.sv-export-card-title { font-weight:700; margin-bottom:6px; }
|
|
1200
|
+
.sv-export-row { display:flex; justify-content:space-between; gap:10px; margin:2px 0; }
|
|
1201
|
+
.sv-export-note, .sv-export-missing { color:#b45309; font-size:12px; margin:4px 0 0 0; }
|
|
1202
|
+
.sv-export-legend { margin:8px 0 0 0; }
|
|
1203
|
+
.sv-export-legend .sv-legend { border-bottom:0; padding:0; margin:0; }
|
|
1204
|
+
.sv-toggle-input { position:absolute; opacity:0; width:1px; height:1px; }
|
|
1205
|
+
.sv-translate { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 0 0; }
|
|
1206
|
+
.sv-toggle-switch { display:inline-flex; cursor:pointer; }
|
|
1207
|
+
.sv-toggle-track { width:34px; height:18px; border-radius:999px; background:#d4d4d4; position:relative; flex:none; transition:background .15s ease; }
|
|
1208
|
+
.sv-toggle-thumb { position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; box-shadow:0 1px 2px rgba(0,0,0,.25); transition:transform .15s ease; }
|
|
1209
|
+
.sv-toggle-text { font-size:13px; font-weight:600; cursor:pointer; }
|
|
1210
|
+
#sv-gloss-toggle:checked ~ .sv-export-header .sv-toggle-track { background:#111; }
|
|
1211
|
+
#sv-gloss-toggle:checked ~ .sv-export-header .sv-toggle-thumb { transform:translateX(16px); }
|
|
1212
|
+
#sv-gloss-toggle:focus-visible ~ .sv-export-header .sv-toggle-track { outline:2px solid #1d4ed8; outline-offset:2px; }
|
|
1213
|
+
#sv-gloss-toggle:checked ~ .sv-export-layout .sv-gloss-toggle-mode ruby.sv-gloss rt { display:ruby-text; }
|
|
1214
|
+
${GLOSS_ON_TOGGLED}
|
|
1215
|
+
@media (max-width: 720px) {
|
|
1216
|
+
.sv-export-layout { flex-direction:column; }
|
|
1217
|
+
.sv-export-side { flex:1 1 auto; width:100%; }
|
|
1218
|
+
}
|
|
1219
|
+
`;
|
|
1220
|
+
function formatDateTimeWithYear(value) {
|
|
1221
|
+
const base = formatDateTime(value);
|
|
1222
|
+
const year = zonedParts(value).year;
|
|
1223
|
+
return base.replace(",", ` ${year},`);
|
|
1224
|
+
}
|
|
1225
|
+
function utcOffsetLabel(value) {
|
|
1226
|
+
return offsetLabel(zonedParts(value).offsetMin);
|
|
1227
|
+
}
|
|
1228
|
+
function renderExportBody(bundle) {
|
|
1229
|
+
return renderReportHtml(bundle.report.html, bundle.elements, { gloss: "toggle" });
|
|
1230
|
+
}
|
|
1231
|
+
function buildExportDocument(params) {
|
|
1232
|
+
return withDrawingZone(params.bundle.timeZone, () => drawExportDocument(params.bundle));
|
|
1233
|
+
}
|
|
1234
|
+
function drawExportDocument(bundle) {
|
|
1235
|
+
const renderedHtml = renderExportBody(bundle);
|
|
1236
|
+
const exportedDate = new Date(bundle.exportedAt * 1e3).toISOString().slice(0, 10);
|
|
1237
|
+
const hasGloss = /<ruby class="sv-gloss">/.test(renderedHtml);
|
|
1238
|
+
const exportedLabel = formatDateTimeWithYear(bundle.exportedAt);
|
|
1239
|
+
const checkedLabel = formatDateTime(bundle.report.checkedAt);
|
|
1240
|
+
const tzLabel = utcOffsetLabel(bundle.exportedAt);
|
|
1241
|
+
const headerHtml = `<div class="sv-export-header"><p class="sv-export-meta">Suveren Gateway ${escapeHtml(bundle.gatewayVersion)} \u2014 exported ${escapeHtml(exportedLabel)} · checked ${escapeHtml(checkedLabel)} (times in ${escapeHtml(tzLabel)})</p><p class="sv-export-howto">How to check this report: each ticket below links to its public record on suveren.ai ("Check on suveren.ai \u2197"). To verify this entire file offline (every signature, and every value the boxes show), run <code>suveren-gateway verify-report ${escapeHtml(suggestedFilename(bundle))}</code> from a terminal with the Suveren gateway CLI installed.</p><div class="sv-export-legend">${reportLegend(hasGloss)}</div>` + (hasGloss ? `<div class="sv-translate"><label for="sv-gloss-toggle" class="sv-toggle-switch" aria-hidden="true"><span class="sv-toggle-track"><span class="sv-toggle-thumb"></span></span></label><label for="sv-gloss-toggle" class="sv-toggle-text">\xDCbersetzung anzeigen / show translation</label></div>` : "") + `</div>`;
|
|
1242
|
+
const proofScriptJson = JSON.stringify(bundle).replace(/<\//g, "<\\/");
|
|
1243
|
+
const proofScript = `<script type="application/json" id="suveren-proof">${proofScriptJson}</script>`;
|
|
1244
|
+
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">`;
|
|
1245
|
+
const head = `<meta charset="utf-8">${csp}<title>Suveren report export \u2014 ${escapeHtml(exportedDate)}</title><style>${DRAWN_ELEMENT_STYLES}${EXPORT_PAGE_STYLES}</style>`;
|
|
1246
|
+
const toggleInput = hasGloss ? `<input type="checkbox" id="sv-gloss-toggle" class="sv-toggle-input">` : "";
|
|
1247
|
+
return `<!doctype html><html lang="en"><head>${head}</head><body>` + toggleInput + headerHtml + `<div class="sv-export-layout"><div class="sv-export-main">${renderedHtml}</div>${renderProofCoveragePanels(bundle.proof, bundle.coverage)}</div>${proofScript}</body></html>`;
|
|
1248
|
+
}
|
|
1249
|
+
function suggestedFilename(bundle) {
|
|
1250
|
+
const date = new Date(bundle.exportedAt * 1e3).toISOString().slice(0, 10);
|
|
1251
|
+
return `suveren-report-${date}.html`;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/lib/report/export-types.ts
|
|
1255
|
+
function isExportBundle(v) {
|
|
1256
|
+
if (!v || typeof v !== "object") return false;
|
|
1257
|
+
const o = v;
|
|
1258
|
+
if (o.format !== "suveren-report-export" || o.version !== 2) return false;
|
|
1259
|
+
if (!Array.isArray(o.elements) || typeof o.timeZone !== "string" || !Array.isArray(o.identityAttestations)) return false;
|
|
1260
|
+
if (!o.report || typeof o.report !== "object") return false;
|
|
1261
|
+
if (!Array.isArray(o.tickets)) return false;
|
|
1262
|
+
if (!o.authorizations || typeof o.authorizations !== "object") return false;
|
|
1263
|
+
if (!o.authorityServer || typeof o.authorityServer !== "object") return false;
|
|
1264
|
+
const as = o.authorityServer;
|
|
1265
|
+
return typeof as.url === "string" && typeof as.publicKeyHex === "string";
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/lib/report/verify-export.ts
|
|
1269
|
+
import { Parser as Parser2 } from "htmlparser2";
|
|
1270
|
+
import { verifyReceiptSignature as verifyReceiptSignature2, verifyAttestationSignature as verifyAttestationSignature4, decodeAttestationBlob as decodeAttestationBlob5, computeBoundsHash } from "@hap/core";
|
|
1271
|
+
|
|
1272
|
+
// src/lib/report/verify-drawn.ts
|
|
1273
|
+
import { computeIntentHash, decodeAttestationBlob as decodeAttestationBlob4, verifyAttestationSignature as verifyAttestationSignature3 } from "@hap/core";
|
|
1274
|
+
|
|
1275
|
+
// src/lib/report/metric-resolvers.ts
|
|
1276
|
+
var METRIC_KINDS = [
|
|
1277
|
+
"completed",
|
|
1278
|
+
"median-time",
|
|
1279
|
+
"average-time",
|
|
1280
|
+
"without-approval",
|
|
1281
|
+
"approvals",
|
|
1282
|
+
"median-approval-wait",
|
|
1283
|
+
"tickets",
|
|
1284
|
+
"refusals"
|
|
1285
|
+
];
|
|
1286
|
+
var UnknownMetricKindError = class extends Error {
|
|
1287
|
+
constructor(kind) {
|
|
1288
|
+
super(`Unknown sv-metric kind "${kind}" \u2014 expected one of: ${METRIC_KINDS.join(", ")}.`);
|
|
1289
|
+
this.name = "UnknownMetricKindError";
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
var START_TIME_METRICS = /* @__PURE__ */ new Set(["median-time", "average-time", "refusals"]);
|
|
1293
|
+
function median(values) {
|
|
1294
|
+
if (values.length === 0) return 0;
|
|
1295
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1296
|
+
const mid = Math.floor(sorted.length / 2);
|
|
1297
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
1298
|
+
}
|
|
1299
|
+
function average(values) {
|
|
1300
|
+
if (values.length === 0) return 0;
|
|
1301
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
1302
|
+
}
|
|
1303
|
+
function computeMetric(kind, cases, refusalTimes = []) {
|
|
1304
|
+
if (START_TIME_METRICS.has(kind) && cases.some((c) => c.totalDurationSeconds === null)) {
|
|
1305
|
+
throw new Error("Case start times are not verifiable.");
|
|
1306
|
+
}
|
|
1307
|
+
switch (kind) {
|
|
1308
|
+
case "completed":
|
|
1309
|
+
return cases.length;
|
|
1310
|
+
case "median-time":
|
|
1311
|
+
return median(cases.map((c) => c.totalDurationSeconds));
|
|
1312
|
+
case "average-time":
|
|
1313
|
+
return average(cases.map((c) => c.totalDurationSeconds));
|
|
1314
|
+
case "without-approval": {
|
|
1315
|
+
if (cases.length === 0) return 0;
|
|
1316
|
+
const withoutApproval = cases.filter((c) => c.approvals.length === 0).length;
|
|
1317
|
+
return withoutApproval / cases.length;
|
|
1318
|
+
}
|
|
1319
|
+
case "approvals":
|
|
1320
|
+
return cases.reduce((sum, c) => sum + c.approvals.length, 0);
|
|
1321
|
+
case "median-approval-wait":
|
|
1322
|
+
return median(cases.flatMap((c) => c.approvals.map((a) => a.waitSeconds).filter((w) => typeof w === "number")));
|
|
1323
|
+
case "tickets": {
|
|
1324
|
+
const ids = new Set(cases.flatMap((c) => c.ticketIds));
|
|
1325
|
+
return ids.size;
|
|
1326
|
+
}
|
|
1327
|
+
case "refusals":
|
|
1328
|
+
return cases.reduce(
|
|
1329
|
+
(sum, c) => sum + refusalTimes.filter((t) => t >= c.startTime && t <= c.goalTime).length,
|
|
1330
|
+
0
|
|
1331
|
+
);
|
|
1332
|
+
default:
|
|
1333
|
+
throw new UnknownMetricKindError(kind);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/lib/report/ticket-resolvers.ts
|
|
1338
|
+
import { verifyReceiptSignature, verifyAttestationSignature as verifyAttestationSignature2, decodeAttestationBlob as decodeAttestationBlob3 } from "@hap/core";
|
|
1339
|
+
|
|
1340
|
+
// src/lib/report/identity.ts
|
|
1341
|
+
import { decodeAttestationBlob as decodeAttestationBlob2, verifyAttestationSignature } from "@hap/core";
|
|
1342
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
1343
|
+
function cacheKey(archive) {
|
|
1344
|
+
const auths = archive.getAuthorizations();
|
|
1345
|
+
return `${auths.length}:${auths.map((a) => `${a.authorizationId}/${a.attestations.length}`).join(",")}:${archive.getReceipts().length}`;
|
|
1346
|
+
}
|
|
1347
|
+
async function build(archive) {
|
|
1348
|
+
const names = /* @__PURE__ */ new Map();
|
|
1349
|
+
const domainDids = /* @__PURE__ */ new Map();
|
|
1350
|
+
const receipts = archive.getReceipts();
|
|
1351
|
+
for (const auth of archive.getAuthorizations()) {
|
|
1352
|
+
const key2 = receipts.find((r) => r.authorizationId === auth.authorizationId && r.asPublicKey)?.asPublicKey;
|
|
1353
|
+
if (!key2) continue;
|
|
1354
|
+
for (const att of auth.attestations) {
|
|
1355
|
+
try {
|
|
1356
|
+
const attestation = decodeAttestationBlob2(att.blob);
|
|
1357
|
+
await verifyAttestationSignature(attestation, key2);
|
|
1358
|
+
for (const s of attestation.payload.subjects ?? []) {
|
|
1359
|
+
if (s.assurance === "high" && s.disclose?.name && !names.has(s.did)) names.set(s.did, s.disclose.name);
|
|
1360
|
+
}
|
|
1361
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
1362
|
+
if (rd?.domain && rd?.did && !domainDids.has(rd.domain)) domainDids.set(rd.domain, rd.did);
|
|
1363
|
+
}
|
|
1364
|
+
} catch {
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return { names, domainDids };
|
|
1369
|
+
}
|
|
1370
|
+
function getIdentityDirectory(archive) {
|
|
1371
|
+
const key2 = cacheKey(archive);
|
|
1372
|
+
const hit = cache.get(archive);
|
|
1373
|
+
if (hit && hit.key === key2) return hit.dir;
|
|
1374
|
+
const dir = build(archive);
|
|
1375
|
+
cache.set(archive, { key: key2, dir });
|
|
1376
|
+
return dir;
|
|
1377
|
+
}
|
|
1378
|
+
function ownerLabel(dir, did) {
|
|
1379
|
+
return dir.names.get(did) ?? formatOwnerLabel(did);
|
|
1380
|
+
}
|
|
1381
|
+
var UNDISCLOSED_APPROVER_LABEL = "a person (name not disclosed)";
|
|
1382
|
+
function approverLabel(dir, domainKey, userId) {
|
|
1383
|
+
const did = dir.domainDids.get(domainKey) ?? dir.domainDids.get(userId);
|
|
1384
|
+
const name = did ? dir.names.get(did) : void 0;
|
|
1385
|
+
if (name) return name;
|
|
1386
|
+
return UNDISCLOSED_APPROVER_LABEL;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/lib/report/agent-view.ts
|
|
1390
|
+
var FORBIDDEN_AGENT_KEYS = [
|
|
1391
|
+
"userId",
|
|
1392
|
+
"groupId",
|
|
1393
|
+
"authorizationId",
|
|
1394
|
+
"signature",
|
|
1395
|
+
"approvalSignature",
|
|
1396
|
+
"blob",
|
|
1397
|
+
"attestations",
|
|
1398
|
+
"ownersRaw",
|
|
1399
|
+
"who",
|
|
1400
|
+
"subjects",
|
|
1401
|
+
"asPublicKey",
|
|
1402
|
+
"credentials",
|
|
1403
|
+
"proposalId",
|
|
1404
|
+
"boundsHash",
|
|
1405
|
+
"contextHash"
|
|
1406
|
+
];
|
|
1407
|
+
var FORBIDDEN = new Set(FORBIDDEN_AGENT_KEYS);
|
|
1408
|
+
function scrubForbidden(value) {
|
|
1409
|
+
if (Array.isArray(value)) return value.map(scrubForbidden);
|
|
1410
|
+
if (value && typeof value === "object") {
|
|
1411
|
+
const out = {};
|
|
1412
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1413
|
+
if (FORBIDDEN.has(k)) continue;
|
|
1414
|
+
out[k] = scrubForbidden(v);
|
|
1415
|
+
}
|
|
1416
|
+
return out;
|
|
1417
|
+
}
|
|
1418
|
+
return value;
|
|
1419
|
+
}
|
|
1420
|
+
function agentTicketRow(receipt, hasApproval, profileLabel) {
|
|
1421
|
+
return {
|
|
1422
|
+
id: typeof receipt.id === "string" ? receipt.id : String(receipt.id ?? ""),
|
|
1423
|
+
time: typeof receipt.timestamp === "number" ? receipt.timestamp : void 0,
|
|
1424
|
+
action: receipt.action,
|
|
1425
|
+
actionType: receipt.actionType ?? null,
|
|
1426
|
+
profile: receipt.profileId,
|
|
1427
|
+
profileLabel,
|
|
1428
|
+
limitsUsed: scrubForbidden(receipt.limits ?? receipt.executionContext ?? {}),
|
|
1429
|
+
hasApproval
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
function agentTicket(d) {
|
|
1433
|
+
return {
|
|
1434
|
+
ticketId: d.ticketId,
|
|
1435
|
+
action: d.action,
|
|
1436
|
+
actionLabel: d.actionLabel,
|
|
1437
|
+
time: d.time,
|
|
1438
|
+
timeLabel: d.timeLabel,
|
|
1439
|
+
profile: d.profile,
|
|
1440
|
+
profileLabel: d.profileLabel,
|
|
1441
|
+
limitsUsed: scrubForbidden(d.limitsUsed ?? {}),
|
|
1442
|
+
checkUrl: d.checkUrl
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
var APPROVED_STATUSES = /* @__PURE__ */ new Set(["committed", "executed"]);
|
|
1446
|
+
function agentApproval(d) {
|
|
1447
|
+
const status = typeof d.status === "string" ? d.status : void 0;
|
|
1448
|
+
return {
|
|
1449
|
+
approved: status !== void 0 ? APPROVED_STATUSES.has(status) : d.decidedAt !== void 0,
|
|
1450
|
+
approvedBy: d.whoLabel,
|
|
1451
|
+
askedAt: d.createdAt,
|
|
1452
|
+
askedAtLabel: d.createdAtLabel,
|
|
1453
|
+
decidedAt: d.decidedAt,
|
|
1454
|
+
decidedAtLabel: d.decidedAtLabel,
|
|
1455
|
+
waitSeconds: d.waitSeconds,
|
|
1456
|
+
waitLabel: d.waitLabel
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
function agentMandate(d) {
|
|
1460
|
+
return {
|
|
1461
|
+
profile: d.profile,
|
|
1462
|
+
profileLabel: d.profileLabel,
|
|
1463
|
+
limits: d.limits,
|
|
1464
|
+
rawLimits: scrubForbidden(d.rawLimits ?? {}),
|
|
1465
|
+
mode: d.mode,
|
|
1466
|
+
intent: d.intent,
|
|
1467
|
+
owners: d.owners
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// src/lib/report/ticket-resolvers.ts
|
|
1472
|
+
function findReceiptEntry(archive, ticketId2) {
|
|
1473
|
+
return archive.getReceipts().find((r) => r.receipt.id === ticketId2);
|
|
1474
|
+
}
|
|
1475
|
+
function findAuthorization(archive, authorizationId) {
|
|
1476
|
+
return archive.getAuthorizations().find((a) => a.authorizationId === authorizationId);
|
|
1477
|
+
}
|
|
1478
|
+
async function checkTicket(archive, ticketId2) {
|
|
1479
|
+
const entry = findReceiptEntry(archive, ticketId2);
|
|
1480
|
+
if (!entry) {
|
|
1481
|
+
const outside = archive.outsideWindowReason?.(ticketId2);
|
|
1482
|
+
if (outside) return { ok: false, reason: `Ticket "${ticketId2}": ${outside}` };
|
|
1483
|
+
return { ok: false, reason: `No ticket "${ticketId2}" in the local archive.` };
|
|
1484
|
+
}
|
|
1485
|
+
if (!entry.asPublicKey) {
|
|
1486
|
+
return { ok: false, reason: `Ticket "${ticketId2}" has no archived Authority Server key to verify it against.`, entry };
|
|
1487
|
+
}
|
|
1488
|
+
try {
|
|
1489
|
+
await verifyReceiptSignature(entry.receipt, entry.asPublicKey);
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
return {
|
|
1492
|
+
ok: false,
|
|
1493
|
+
reason: `Ticket "${ticketId2}" signature does not verify: ${err instanceof Error ? err.message : String(err)}`,
|
|
1494
|
+
entry
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
return { ok: true, entry };
|
|
1498
|
+
}
|
|
1499
|
+
function checkUrlFor(entry) {
|
|
1500
|
+
const id = String(entry.receipt.id ?? "");
|
|
1501
|
+
return `${entry.asUrl.replace(/\/+$/, "")}/r/${id}`;
|
|
1502
|
+
}
|
|
1503
|
+
async function resolveTicketElement(archive, ref) {
|
|
1504
|
+
const check = await checkTicket(archive, ref);
|
|
1505
|
+
if (!check.ok) return { status: "unverifiable", reason: check.reason };
|
|
1506
|
+
const r = check.entry.receipt;
|
|
1507
|
+
return {
|
|
1508
|
+
status: "verified",
|
|
1509
|
+
data: {
|
|
1510
|
+
ticketId: ref,
|
|
1511
|
+
action: r.action,
|
|
1512
|
+
actionLabel: formatActionLabel(r.action),
|
|
1513
|
+
actionType: r.actionType,
|
|
1514
|
+
// The signed execution context, minus internal ids (agent-view.ts) —
|
|
1515
|
+
// drawn verbatim by the full ticket variant (two-tag rule, RR6).
|
|
1516
|
+
executionContext: scrubForbidden(r.executionContext ?? {}),
|
|
1517
|
+
time: r.timestamp,
|
|
1518
|
+
timeLabel: formatDateTime(r.timestamp),
|
|
1519
|
+
profile: r.profileId,
|
|
1520
|
+
profileLabel: profileShortLabel(typeof r.profileId === "string" ? r.profileId : void 0),
|
|
1521
|
+
authorizationId: r.authorizationId,
|
|
1522
|
+
limitsUsed: r.limits ?? r.executionContext ?? {},
|
|
1523
|
+
checkUrl: checkUrlFor(check.entry)
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
async function resolveApprovalElement(archive, ticketRef) {
|
|
1528
|
+
const check = await checkTicket(archive, ticketRef);
|
|
1529
|
+
if (!check.ok) return { status: "unverifiable", reason: check.reason };
|
|
1530
|
+
const proposal = check.entry.proposal;
|
|
1531
|
+
if (!proposal) {
|
|
1532
|
+
return {
|
|
1533
|
+
status: "unverifiable",
|
|
1534
|
+
reason: `Ticket "${ticketRef}" has no archived approval (it ran without review).`
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
const committedBy = proposal.committedBy ?? {};
|
|
1538
|
+
const approverEntries = Object.entries(committedBy);
|
|
1539
|
+
const approvers = approverEntries.map(([, a]) => a);
|
|
1540
|
+
const createdAt = typeof proposal.createdAt === "number" ? proposal.createdAt : void 0;
|
|
1541
|
+
const decidedAt = approvers.length > 0 ? Math.max(...approvers.map((a) => a.at)) : void 0;
|
|
1542
|
+
const waitSeconds = createdAt !== void 0 && decidedAt !== void 0 ? decidedAt - createdAt : void 0;
|
|
1543
|
+
const who = approvers.map((a) => a.userId);
|
|
1544
|
+
const dir = await getIdentityDirectory(archive);
|
|
1545
|
+
const whoLabels = approverEntries.map(([domain, a]) => approverLabel(dir, domain, String(a.userId ?? "")));
|
|
1546
|
+
return {
|
|
1547
|
+
status: "verified",
|
|
1548
|
+
data: {
|
|
1549
|
+
ticketId: ticketRef,
|
|
1550
|
+
who,
|
|
1551
|
+
whoLabel: whoLabels.length > 0 ? whoLabels.join(", ") : "unknown",
|
|
1552
|
+
createdAt,
|
|
1553
|
+
createdAtLabel: createdAt !== void 0 ? formatDateTime(createdAt) : "unknown",
|
|
1554
|
+
decidedAt,
|
|
1555
|
+
decidedAtLabel: decidedAt !== void 0 ? formatDateTime(decidedAt) : "unknown",
|
|
1556
|
+
waitSeconds,
|
|
1557
|
+
waitLabel: waitSeconds !== void 0 ? formatDuration(waitSeconds) : "unknown",
|
|
1558
|
+
status: proposal.status
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
async function resolveMandateElement(archive, ticketRef) {
|
|
1563
|
+
const check = await checkTicket(archive, ticketRef);
|
|
1564
|
+
if (!check.ok) return { status: "unverifiable", reason: check.reason };
|
|
1565
|
+
const r = check.entry.receipt;
|
|
1566
|
+
const authorizationId = typeof r.authorizationId === "string" ? r.authorizationId : void 0;
|
|
1567
|
+
if (!authorizationId) {
|
|
1568
|
+
return { status: "unverifiable", reason: `Ticket "${ticketRef}" carries no authorizationId.` };
|
|
1569
|
+
}
|
|
1570
|
+
const auth = findAuthorization(archive, authorizationId);
|
|
1571
|
+
if (!auth) {
|
|
1572
|
+
return { status: "unverifiable", reason: `No archived mandate "${authorizationId}" for ticket "${ticketRef}".` };
|
|
1573
|
+
}
|
|
1574
|
+
if (auth.boundsHash && typeof r.boundsHash === "string" && auth.boundsHash !== r.boundsHash) {
|
|
1575
|
+
return {
|
|
1576
|
+
status: "unverifiable",
|
|
1577
|
+
reason: `Mandate "${authorizationId}" boundsHash does not match ticket "${ticketRef}".`
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
let mode;
|
|
1581
|
+
let owners = [];
|
|
1582
|
+
let ownersRaw = [];
|
|
1583
|
+
const firstBlob = auth.attestations[0]?.blob;
|
|
1584
|
+
if (firstBlob && check.entry.asPublicKey) {
|
|
1585
|
+
try {
|
|
1586
|
+
const attestation = decodeAttestationBlob3(firstBlob);
|
|
1587
|
+
await verifyAttestationSignature2(attestation, check.entry.asPublicKey);
|
|
1588
|
+
mode = attestation.payload.commitment_mode;
|
|
1589
|
+
const dids = attestation.payload.resolved_owners ?? [];
|
|
1590
|
+
const subjects = attestation.payload.subjects ?? [];
|
|
1591
|
+
ownersRaw = dids;
|
|
1592
|
+
const dir = await getIdentityDirectory(archive);
|
|
1593
|
+
owners = dids.map((did) => {
|
|
1594
|
+
const subject = subjects.find((s) => s.did === did);
|
|
1595
|
+
return subject?.assurance === "high" && subject.disclose?.name ? subject.disclose.name : ownerLabel(dir, did);
|
|
1596
|
+
});
|
|
1597
|
+
} catch {
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
const fallbackCurrency = typeof auth.context?.currency === "string" ? auth.context.currency : void 0;
|
|
1601
|
+
const rawLimits = auth.bounds ?? {};
|
|
1602
|
+
const limits = Object.entries(rawLimits).map(([key2, value]) => formatBoundLabel(auth.profileId, key2, value, fallbackCurrency));
|
|
1603
|
+
return {
|
|
1604
|
+
status: "verified",
|
|
1605
|
+
data: {
|
|
1606
|
+
authorizationId,
|
|
1607
|
+
profile: auth.profileId,
|
|
1608
|
+
profileLabel: profileShortLabel(auth.profileId),
|
|
1609
|
+
limits,
|
|
1610
|
+
rawLimits,
|
|
1611
|
+
intent: auth.intent,
|
|
1612
|
+
mode,
|
|
1613
|
+
owners,
|
|
1614
|
+
ownersRaw
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// src/lib/report/time.ts
|
|
1620
|
+
function parseTimestampSeconds(value) {
|
|
1621
|
+
if (typeof value !== "string" || !value.trim()) return void 0;
|
|
1622
|
+
const iso = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? `${value.replace(" ", "T")}Z` : value;
|
|
1623
|
+
const ms = Date.parse(iso);
|
|
1624
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1e3) : void 0;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// src/lib/report/case-resolvers.ts
|
|
1628
|
+
function parseRef(value) {
|
|
1629
|
+
if (!value) return { id: "" };
|
|
1630
|
+
const i = value.indexOf(":");
|
|
1631
|
+
if (i === -1) return { id: value.trim() };
|
|
1632
|
+
return { type: value.slice(0, i).trim(), id: value.slice(i + 1).trim() };
|
|
1633
|
+
}
|
|
1634
|
+
function parseStepRefs(value) {
|
|
1635
|
+
if (!value) return [];
|
|
1636
|
+
return value.split(/\s+/).filter(Boolean).map((s) => parseRef(s).id || s);
|
|
1637
|
+
}
|
|
1638
|
+
function parseRecipients(toJson, ccJson) {
|
|
1639
|
+
const out = [];
|
|
1640
|
+
for (const raw of [toJson, ccJson]) {
|
|
1641
|
+
if (!raw) continue;
|
|
1642
|
+
try {
|
|
1643
|
+
const arr = JSON.parse(raw);
|
|
1644
|
+
if (Array.isArray(arr)) out.push(...arr.filter((x) => typeof x === "string"));
|
|
1645
|
+
} catch {
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return out.map((s) => s.toLowerCase());
|
|
1649
|
+
}
|
|
1650
|
+
function parseCaseAttrs(attrs) {
|
|
1651
|
+
return {
|
|
1652
|
+
startId: parseRef(attrs.start).id,
|
|
1653
|
+
goalId: parseRef(attrs.goal).id,
|
|
1654
|
+
stepIds: parseStepRefs(attrs.steps)
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
var CASE_TIME_UNKNOWN_REASON = "Not verifiable: the time the test data was loaded is unknown, and the email's own date may be set by the test package.";
|
|
1658
|
+
async function resolveCaseElement(archive, emailExport, attrs) {
|
|
1659
|
+
const start = parseRef(attrs.start);
|
|
1660
|
+
const startMessage = emailExport.inbox.find((m) => m.id === start.id);
|
|
1661
|
+
if (!startMessage || !startMessage.case_id) {
|
|
1662
|
+
return { status: "unverifiable", reason: `start "${attrs.start ?? ""}" is not a loaded case email with a case_id.` };
|
|
1663
|
+
}
|
|
1664
|
+
const startCaseId = startMessage.case_id;
|
|
1665
|
+
const emailTime = parseTimestampSeconds(startMessage.received_at);
|
|
1666
|
+
if (emailTime === void 0) {
|
|
1667
|
+
return { status: "unverifiable", reason: `start message "${start.id}" has an unparseable received_at.`, startCaseId };
|
|
1668
|
+
}
|
|
1669
|
+
const loadedAt = parseTimestampSeconds(emailExport.simulation_load?.loaded_at);
|
|
1670
|
+
const startTime = loadedAt !== void 0 ? Math.max(emailTime, loadedAt) : emailTime;
|
|
1671
|
+
const basis = loadedAt === void 0 ? "load-unknown" : emailTime >= loadedAt ? "email" : "loaded";
|
|
1672
|
+
const goal = parseRef(attrs.goal);
|
|
1673
|
+
const goalCheck = await checkTicket(archive, goal.id);
|
|
1674
|
+
if (!goalCheck.ok) {
|
|
1675
|
+
return { status: "unverifiable", reason: `goal: ${goalCheck.reason}`, startCaseId };
|
|
1676
|
+
}
|
|
1677
|
+
const goalReceipt = goalCheck.entry.receipt;
|
|
1678
|
+
const goalTime = typeof goalReceipt.timestamp === "number" ? goalReceipt.timestamp : void 0;
|
|
1679
|
+
if (goalTime === void 0) {
|
|
1680
|
+
return { status: "unverifiable", reason: `goal ticket "${goal.id}" has no numeric timestamp.`, startCaseId };
|
|
1681
|
+
}
|
|
1682
|
+
if (goalTime < startTime) {
|
|
1683
|
+
return { status: "unverifiable", reason: `goal ticket "${goal.id}" is timestamped before start "${start.id}".`, startCaseId };
|
|
1684
|
+
}
|
|
1685
|
+
const stepIds = parseStepRefs(attrs.steps);
|
|
1686
|
+
const steps = [];
|
|
1687
|
+
for (const stepId of stepIds) {
|
|
1688
|
+
const stepCheck = await checkTicket(archive, stepId);
|
|
1689
|
+
if (!stepCheck.ok) {
|
|
1690
|
+
return { status: "unverifiable", reason: `step: ${stepCheck.reason}`, startCaseId };
|
|
1691
|
+
}
|
|
1692
|
+
const stepReceipt = stepCheck.entry.receipt;
|
|
1693
|
+
const stepTime = typeof stepReceipt.timestamp === "number" ? stepReceipt.timestamp : void 0;
|
|
1694
|
+
if (stepTime === void 0 || stepTime < startTime || stepTime > goalTime) {
|
|
1695
|
+
return { status: "unverifiable", reason: `step ticket "${stepId}" is outside the case window [${startTime}, ${goalTime}].`, startCaseId };
|
|
1696
|
+
}
|
|
1697
|
+
steps.push({ ticketId: stepId, time: stepTime, action: stepReceipt.action, actionLabel: formatActionLabel(stepReceipt.action) });
|
|
1698
|
+
}
|
|
1699
|
+
let warningReason;
|
|
1700
|
+
const sentForGoal = emailExport.sent.find((m) => m.receipt_id === goal.id);
|
|
1701
|
+
if (sentForGoal) {
|
|
1702
|
+
const isReply = sentForGoal.in_reply_to === start.id;
|
|
1703
|
+
const recipients = parseRecipients(sentForGoal.to_json, sentForGoal.cc_json);
|
|
1704
|
+
const addressedToSender = recipients.includes(startMessage.from_email.toLowerCase());
|
|
1705
|
+
if (!isReply && !addressedToSender) {
|
|
1706
|
+
warningReason = "goal link not confirmed";
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
const approvals = [];
|
|
1710
|
+
for (const ticketId2 of [goal.id, ...stepIds]) {
|
|
1711
|
+
const approval = await resolveApprovalElement(archive, ticketId2);
|
|
1712
|
+
if (approval.status === "verified") {
|
|
1713
|
+
approvals.push({ ticketId: ticketId2, ...approval.data });
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
const timeline = [
|
|
1717
|
+
{ type: "start", time: startTime, label: start.id },
|
|
1718
|
+
...steps.map((s) => ({ type: "step", time: s.time, label: s.ticketId })),
|
|
1719
|
+
...approvals.map((a) => ({ type: "approval", time: a.decidedAt ?? a.createdAt ?? 0, label: a.ticketId })),
|
|
1720
|
+
{ type: "goal", time: goalTime, label: goal.id }
|
|
1721
|
+
].sort((a, b) => a.time - b.time);
|
|
1722
|
+
return {
|
|
1723
|
+
status: warningReason ? "warning" : "verified",
|
|
1724
|
+
reason: warningReason,
|
|
1725
|
+
startCaseId,
|
|
1726
|
+
data: {
|
|
1727
|
+
caseId: startCaseId,
|
|
1728
|
+
start: {
|
|
1729
|
+
id: start.id,
|
|
1730
|
+
time: startTime,
|
|
1731
|
+
sender: startMessage.from_email,
|
|
1732
|
+
emailTime,
|
|
1733
|
+
basis,
|
|
1734
|
+
subject: startMessage.subject,
|
|
1735
|
+
receivedAt: startMessage.received_at,
|
|
1736
|
+
...emailExport.simulation_load?.loaded_at ? { loadedAt: emailExport.simulation_load.loaded_at } : {}
|
|
1737
|
+
},
|
|
1738
|
+
goal: { ticketId: goal.id, time: goalTime, action: goalReceipt.action, actionLabel: formatActionLabel(goalReceipt.action) },
|
|
1739
|
+
steps,
|
|
1740
|
+
approvals,
|
|
1741
|
+
timeline,
|
|
1742
|
+
totalDurationSeconds: basis === "load-unknown" ? null : goalTime - startTime,
|
|
1743
|
+
...basis === "load-unknown" ? { timeUnverifiableReason: CASE_TIME_UNKNOWN_REASON } : {}
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// src/lib/report/verify-drawn.ts
|
|
1749
|
+
function validTimeZone(tz) {
|
|
1750
|
+
try {
|
|
1751
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: tz });
|
|
1752
|
+
return true;
|
|
1753
|
+
} catch {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
function visibleText(html) {
|
|
1758
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
1759
|
+
}
|
|
1760
|
+
function normalizeNewlines(html) {
|
|
1761
|
+
return html.replace(/\r\n?/g, "\n");
|
|
1762
|
+
}
|
|
1763
|
+
function checkDocumentReproduces(bundle, documentHtml) {
|
|
1764
|
+
if (documentHtml === void 0) return { state: "not-given" };
|
|
1765
|
+
if (!validTimeZone(bundle.timeZone)) {
|
|
1766
|
+
return { state: "mismatch", error: `The file names an unknown drawing time zone "${bundle.timeZone}".` };
|
|
1767
|
+
}
|
|
1768
|
+
let expected;
|
|
1769
|
+
try {
|
|
1770
|
+
expected = normalizeNewlines(buildExportDocument({ bundle }));
|
|
1771
|
+
} catch (err) {
|
|
1772
|
+
return { state: "mismatch", error: `The page could not be re-drawn from the bundle: ${err instanceof Error ? err.message : String(err)}` };
|
|
1773
|
+
}
|
|
1774
|
+
const actual = normalizeNewlines(documentHtml);
|
|
1775
|
+
if (actual === expected) return { state: "match" };
|
|
1776
|
+
let i = 0;
|
|
1777
|
+
while (i < actual.length && i < expected.length && actual[i] === expected[i]) i++;
|
|
1778
|
+
const around = (s) => {
|
|
1779
|
+
const from = Math.max(0, s.lastIndexOf("<div", i));
|
|
1780
|
+
const close = s.indexOf("</div>", i);
|
|
1781
|
+
return visibleText(s.slice(from, close === -1 ? i + 200 : close + 6)).slice(0, 160);
|
|
1782
|
+
};
|
|
1783
|
+
return {
|
|
1784
|
+
state: "mismatch",
|
|
1785
|
+
error: `The visible page differs from what the signed bundle draws (first difference at byte ${i}). File shows: "${around(actual)}" \u2014 the bundle draws: "${around(expected)}". Either the page was edited, or it was drawn by a different gateway version than this checker (the file says ${bundle.gatewayVersion}).`
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
async function verifiedPayload(blob, publicKeyHex) {
|
|
1789
|
+
try {
|
|
1790
|
+
const att = decodeAttestationBlob4(blob);
|
|
1791
|
+
await verifyAttestationSignature3(att, publicKeyHex);
|
|
1792
|
+
return att.payload;
|
|
1793
|
+
} catch {
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
async function collectAttestations(bundle) {
|
|
1798
|
+
const key2 = bundle.authorityServer.publicKeyHex;
|
|
1799
|
+
const first = /* @__PURE__ */ new Map();
|
|
1800
|
+
const names = /* @__PURE__ */ new Map();
|
|
1801
|
+
const addNames = (p) => {
|
|
1802
|
+
for (const s of p?.subjects ?? []) {
|
|
1803
|
+
if (s.assurance === "high" && s.disclose?.name) {
|
|
1804
|
+
if (!names.has(s.did)) names.set(s.did, /* @__PURE__ */ new Set());
|
|
1805
|
+
names.get(s.did).add(s.disclose.name);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
for (const [id, auth] of Object.entries(bundle.authorizations)) {
|
|
1810
|
+
let i = 0;
|
|
1811
|
+
for (const att of auth.attestations) {
|
|
1812
|
+
const p = await verifiedPayload(att.blob, key2);
|
|
1813
|
+
if (i++ === 0) first.set(id, p);
|
|
1814
|
+
addNames(p);
|
|
1815
|
+
}
|
|
1816
|
+
if (auth.attestations.length === 0) first.set(id, null);
|
|
1817
|
+
}
|
|
1818
|
+
for (const blob of bundle.identityAttestations ?? []) addNames(await verifiedPayload(blob, key2));
|
|
1819
|
+
return { first, names };
|
|
1820
|
+
}
|
|
1821
|
+
function same(a, b) {
|
|
1822
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1823
|
+
}
|
|
1824
|
+
function sameMap(a, b) {
|
|
1825
|
+
const ka = Object.keys(a ?? {}).sort();
|
|
1826
|
+
const kb = Object.keys(b ?? {}).sort();
|
|
1827
|
+
return same(ka, kb) && ka.every((k) => same((a ?? {})[k], (b ?? {})[k]));
|
|
1828
|
+
}
|
|
1829
|
+
async function checkDrawnElements(ctx, reportElements2) {
|
|
1830
|
+
const { bundle } = ctx;
|
|
1831
|
+
const errors = [];
|
|
1832
|
+
const att = await collectAttestations(bundle);
|
|
1833
|
+
const parsedById = new Map(reportElements2.map((e) => [e.id, e]));
|
|
1834
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1835
|
+
for (const el of bundle.elements) {
|
|
1836
|
+
if (seen.has(el.id)) errors.push(`Element ${el.id} appears twice in the bundle.`);
|
|
1837
|
+
seen.add(el.id);
|
|
1838
|
+
const p = parsedById.get(el.id);
|
|
1839
|
+
if (!p || p.kind !== el.kind || !sameMap(p.attrs, el.attrs)) {
|
|
1840
|
+
errors.push(`Element ${el.id} in the bundle is not the report's own element (id, kind or attributes differ).`);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
errors.push(...checkProofSummary(bundle));
|
|
1844
|
+
const boxes = [];
|
|
1845
|
+
for (const el of bundle.elements) {
|
|
1846
|
+
if (el.status === "unverifiable" || !el.data) continue;
|
|
1847
|
+
const box = { elementId: el.id, kind: el.kind, signed: [], recomputed: [], notCheckable: [], mismatches: [] };
|
|
1848
|
+
try {
|
|
1849
|
+
switch (el.kind) {
|
|
1850
|
+
case "sv-ticket":
|
|
1851
|
+
checkTicketBox(ctx, att, el, box);
|
|
1852
|
+
break;
|
|
1853
|
+
case "sv-mandate":
|
|
1854
|
+
checkMandateBox(ctx, att, el, box);
|
|
1855
|
+
break;
|
|
1856
|
+
case "sv-approval":
|
|
1857
|
+
checkApprovalBox(el, box);
|
|
1858
|
+
break;
|
|
1859
|
+
case "sv-record":
|
|
1860
|
+
for (const k of Object.keys(el.data)) box.notCheckable.push({ field: k, source: "database" });
|
|
1861
|
+
break;
|
|
1862
|
+
case "sv-case":
|
|
1863
|
+
checkCaseBox(ctx, el, box);
|
|
1864
|
+
break;
|
|
1865
|
+
case "sv-metric":
|
|
1866
|
+
checkMetricBox(bundle, el, box);
|
|
1867
|
+
break;
|
|
1868
|
+
default:
|
|
1869
|
+
box.mismatches.push(`Unknown element kind "${el.kind}" drawn as verified.`);
|
|
1870
|
+
}
|
|
1871
|
+
} catch (err) {
|
|
1872
|
+
box.mismatches.push(`Could not check: ${err instanceof Error ? err.message : String(err)}`);
|
|
1873
|
+
}
|
|
1874
|
+
boxes.push(box);
|
|
1875
|
+
}
|
|
1876
|
+
return { boxes, errors };
|
|
1877
|
+
}
|
|
1878
|
+
function proofOf(elements) {
|
|
1879
|
+
const ticketsReferenced = /* @__PURE__ */ new Set();
|
|
1880
|
+
const signaturesValid = /* @__PURE__ */ new Set();
|
|
1881
|
+
let recordsChecked = 0;
|
|
1882
|
+
let unverifiableCount = 0;
|
|
1883
|
+
const verifiedValues = [];
|
|
1884
|
+
for (const el of elements) {
|
|
1885
|
+
if (el.status === "unverifiable") unverifiableCount++;
|
|
1886
|
+
else verifiedValues.push({ elementId: el.id, kind: el.kind, summary: checkedValueLine(el) });
|
|
1887
|
+
const ids = [];
|
|
1888
|
+
if (el.kind === "sv-ticket" && el.attrs.ref) ids.push(el.attrs.ref);
|
|
1889
|
+
if ((el.kind === "sv-approval" || el.kind === "sv-mandate") && el.attrs.ticket) ids.push(el.attrs.ticket);
|
|
1890
|
+
if (el.kind === "sv-case") {
|
|
1891
|
+
const { goalId, stepIds } = parseCaseAttrs(el.attrs);
|
|
1892
|
+
ids.push(...[goalId, ...stepIds].filter(Boolean));
|
|
1893
|
+
}
|
|
1894
|
+
for (const id of ids) {
|
|
1895
|
+
ticketsReferenced.add(id);
|
|
1896
|
+
if (el.status !== "unverifiable") signaturesValid.add(id);
|
|
1897
|
+
}
|
|
1898
|
+
if (el.kind === "sv-record" && el.status === "verified") recordsChecked++;
|
|
1899
|
+
}
|
|
1900
|
+
return { ticketsReferenced, signaturesValid: signaturesValid.size, recordsChecked, unverifiableCount, verifiedValues };
|
|
1901
|
+
}
|
|
1902
|
+
function checkProofSummary(bundle) {
|
|
1903
|
+
const p = validTimeZone(bundle.timeZone) ? withDrawingZone(bundle.timeZone, () => proofOf(bundle.elements)) : proofOf(bundle.elements);
|
|
1904
|
+
const errors = [];
|
|
1905
|
+
const b = bundle.proof;
|
|
1906
|
+
if (!same(b.verifiedValues, p.verifiedValues)) errors.push('The "Checked values" panel does not match the boxes.');
|
|
1907
|
+
if (!same([...new Set(b.ticketsReferenced)].sort(), [...p.ticketsReferenced].sort())) errors.push(`The Proof panel's "Tickets referenced" does not match the report.`);
|
|
1908
|
+
if (b.signaturesValid !== p.signaturesValid) errors.push(`The Proof panel's "Signatures valid" does not match the boxes.`);
|
|
1909
|
+
if (b.recordsChecked !== p.recordsChecked) errors.push(`The Proof panel's "Records checked" does not match the boxes.`);
|
|
1910
|
+
if (b.unverifiableCount !== p.unverifiableCount) errors.push(`The Proof panel's "Not verifiable" count does not match the boxes.`);
|
|
1911
|
+
return errors;
|
|
1912
|
+
}
|
|
1913
|
+
function receiptFor(ctx, id, box) {
|
|
1914
|
+
const r = id ? ctx.validTickets.get(id) : void 0;
|
|
1915
|
+
if (!r) box.mismatches.push(`Ticket ${id ?? "(none)"} is not in the file with a valid signature.`);
|
|
1916
|
+
return r;
|
|
1917
|
+
}
|
|
1918
|
+
function signedField(box, field, drawn, signed) {
|
|
1919
|
+
if (same(drawn, signed)) box.signed.push(field);
|
|
1920
|
+
else box.mismatches.push(`${field}: the box shows ${JSON.stringify(drawn)}, the signed source says ${JSON.stringify(signed)}.`);
|
|
1921
|
+
}
|
|
1922
|
+
function checkTicketBox(ctx, att, el, box) {
|
|
1923
|
+
const d = el.data;
|
|
1924
|
+
const r = receiptFor(ctx, el.attrs.ref, box);
|
|
1925
|
+
if (!r) return;
|
|
1926
|
+
signedField(box, "action", d.action, r.action);
|
|
1927
|
+
signedField(box, "timestamp", d.time, r.timestamp);
|
|
1928
|
+
const full = (el.attrs.variant ?? "").trim().toLowerCase() === "full";
|
|
1929
|
+
if (!full) return;
|
|
1930
|
+
if (d.actionType !== void 0 || r.actionType !== void 0) signedField(box, "actionType", d.actionType, r.actionType);
|
|
1931
|
+
signedField(box, "profileId", d.profile, r.profileId);
|
|
1932
|
+
signedField(box, "ticket", d.ticketId, r.id);
|
|
1933
|
+
if (sameMap(d.executionContext, scrubForbidden(r.executionContext ?? {}))) {
|
|
1934
|
+
for (const k of Object.keys(d.executionContext ?? {})) box.signed.push(k);
|
|
1935
|
+
} else {
|
|
1936
|
+
box.mismatches.push("The execution context the box shows differs from the signed ticket.");
|
|
1937
|
+
}
|
|
1938
|
+
const expectedUrl = `${ctx.bundle.authorityServer.url.replace(/\/+$/, "")}/r/${String(r.id ?? "")}`;
|
|
1939
|
+
if (d.checkUrl !== void 0 && d.checkUrl !== expectedUrl) {
|
|
1940
|
+
box.mismatches.push(`The "Check on suveren.ai" link points to ${JSON.stringify(d.checkUrl)}, not ${expectedUrl}.`);
|
|
1941
|
+
}
|
|
1942
|
+
if (d.mandate) checkMandateFields(ctx, att, d.mandate, r, box, "mandate.");
|
|
1943
|
+
if (d.approval) {
|
|
1944
|
+
const a = d.approval;
|
|
1945
|
+
for (const [k, f] of [["createdAt", "approval.createdAt"], ["decidedAt", "approval.decidedAt"], ["whoLabel", "approval.committedBy"]]) {
|
|
1946
|
+
if (a[k] !== void 0) box.notCheckable.push({ field: f, source: "archive" });
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
function checkMandateBox(ctx, att, el, box) {
|
|
1951
|
+
const r = receiptFor(ctx, el.attrs.ticket, box);
|
|
1952
|
+
if (!r) return;
|
|
1953
|
+
checkMandateFields(ctx, att, el.data, r, box, "");
|
|
1954
|
+
}
|
|
1955
|
+
function checkMandateFields(ctx, att, m, r, box, prefix) {
|
|
1956
|
+
const authorizationId = typeof r.authorizationId === "string" ? r.authorizationId : "";
|
|
1957
|
+
const auth = ctx.bundle.authorizations[authorizationId];
|
|
1958
|
+
if (!auth) {
|
|
1959
|
+
box.mismatches.push(`${prefix || "mandate "}drawn, but the mandate ${authorizationId || "(none)"} is not in the file.`);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
const payload = att.first.get(authorizationId) ?? null;
|
|
1963
|
+
const rawLimits = m.rawLimits ?? {};
|
|
1964
|
+
if (Object.keys(rawLimits).length > 0) {
|
|
1965
|
+
if (!sameMap(rawLimits, auth.bounds)) {
|
|
1966
|
+
box.mismatches.push(`${prefix}limits: the box shows ${JSON.stringify(rawLimits)}, the mandate in the file says ${JSON.stringify(auth.bounds ?? {})}.`);
|
|
1967
|
+
} else if (!ctx.boundsProven.has(authorizationId)) {
|
|
1968
|
+
box.mismatches.push(`${prefix}limits: the bounds values are not proven by a signed bounds_hash.`);
|
|
1969
|
+
} else {
|
|
1970
|
+
for (const k of Object.keys(rawLimits)) box.signed.push(prefix + k);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
if (m.profile !== void 0) {
|
|
1974
|
+
if (!payload) box.mismatches.push("profileId: no verified mandate attestation in the file backs it.");
|
|
1975
|
+
else signedField(box, "profileId", m.profile, payload.profile_id);
|
|
1976
|
+
}
|
|
1977
|
+
if (m.mode !== void 0 || Array.isArray(m.owners) && m.owners.length > 0) {
|
|
1978
|
+
if (!payload) {
|
|
1979
|
+
box.mismatches.push(`${prefix}commitment_mode/owner: no verified mandate attestation in the file backs them.`);
|
|
1980
|
+
} else {
|
|
1981
|
+
if (m.mode !== void 0) signedField(box, `${prefix}commitment_mode`, m.mode, payload.commitment_mode);
|
|
1982
|
+
checkOwners(att, payload, Array.isArray(m.owners) ? m.owners : [], box, prefix);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
if (typeof m.intent === "string" && m.intent) {
|
|
1986
|
+
if (auth.intent !== void 0 && auth.intent !== m.intent) {
|
|
1987
|
+
box.mismatches.push("intent: the box shows a different text than the mandate in the file.");
|
|
1988
|
+
}
|
|
1989
|
+
const committed = payload?.gate_content_hashes?.intent;
|
|
1990
|
+
if (committed) {
|
|
1991
|
+
if (computeIntentHash(m.intent) === committed) box.signed.push("intent");
|
|
1992
|
+
else box.mismatches.push("intent: the text does not match the signed intent hash.");
|
|
1993
|
+
} else {
|
|
1994
|
+
box.notCheckable.push({ field: "intent", source: "archive" });
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
function checkOwners(att, payload, labels, box, prefix) {
|
|
1999
|
+
const dids = payload.resolved_owners ?? [];
|
|
2000
|
+
if (labels.length !== dids.length) {
|
|
2001
|
+
box.mismatches.push(`${prefix}owner: the box shows ${labels.length} owner(s), the signed mandate names ${dids.length}.`);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
dids.forEach((did, i) => {
|
|
2005
|
+
const label = labels[i];
|
|
2006
|
+
const own = (payload.subjects ?? []).find((s) => s.did === did);
|
|
2007
|
+
const ownName = own?.assurance === "high" && own.disclose?.name ? own.disclose.name : void 0;
|
|
2008
|
+
const ok = ownName !== void 0 ? label === ownName : label === formatOwnerLabel(did) || label === UNDISCLOSED_OWNER_LABEL || typeof label === "string" && !!att.names.get(did)?.has(label);
|
|
2009
|
+
if (ok) box.signed.push(`${prefix}owner`);
|
|
2010
|
+
else box.mismatches.push(`${prefix}owner: "${String(label)}" is not a name this owner disclosed in a signed attestation in the file.`);
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
function checkApprovalBox(el, box) {
|
|
2014
|
+
const d = el.data;
|
|
2015
|
+
for (const [k, f] of [["createdAt", "createdAt"], ["decidedAt", "decidedAt"], ["whoLabel", "committedBy"], ["status", "status"]]) {
|
|
2016
|
+
if (d[k] !== void 0) box.notCheckable.push({ field: f, source: "archive" });
|
|
2017
|
+
}
|
|
2018
|
+
if (typeof d.waitSeconds === "number") {
|
|
2019
|
+
if (typeof d.createdAt === "number" && typeof d.decidedAt === "number" && d.decidedAt - d.createdAt === d.waitSeconds) {
|
|
2020
|
+
box.recomputed.push({ field: "wait_s", inputs: ["archive"] });
|
|
2021
|
+
} else {
|
|
2022
|
+
box.mismatches.push(`wait_s: ${d.waitSeconds} is not decidedAt \u2212 createdAt.`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
function checkCaseBox(ctx, el, box) {
|
|
2027
|
+
const d = el.data;
|
|
2028
|
+
const { goalId, stepIds } = parseCaseAttrs(el.attrs);
|
|
2029
|
+
const goal = d.goal ?? {};
|
|
2030
|
+
const steps = Array.isArray(d.steps) ? d.steps : [];
|
|
2031
|
+
const start = d.start ?? {};
|
|
2032
|
+
if (goal.ticketId !== goalId) box.mismatches.push(`goal: the box shows ticket ${String(goal.ticketId)}, the report names ${goalId}.`);
|
|
2033
|
+
const gr = receiptFor(ctx, goalId, box);
|
|
2034
|
+
if (gr) {
|
|
2035
|
+
signedField(box, "goal.action", goal.action, gr.action);
|
|
2036
|
+
signedField(box, "goal.timestamp", goal.time, gr.timestamp);
|
|
2037
|
+
}
|
|
2038
|
+
if (!same(steps.map((s) => s.ticketId), stepIds)) box.mismatches.push("steps: the box does not show exactly the step tickets the report names.");
|
|
2039
|
+
steps.forEach((s, i) => {
|
|
2040
|
+
const sr = receiptFor(ctx, String(s.ticketId ?? ""), box);
|
|
2041
|
+
if (!sr) return;
|
|
2042
|
+
signedField(box, `step[${i}].action`, s.action, sr.action);
|
|
2043
|
+
signedField(box, `step[${i}].timestamp`, s.time, sr.timestamp);
|
|
2044
|
+
});
|
|
2045
|
+
box.notCheckable.push({ field: "case_id", source: "database" });
|
|
2046
|
+
if (start.receivedAt !== void 0 || start.emailTime !== void 0) box.notCheckable.push({ field: "received_at", source: "database" });
|
|
2047
|
+
if (start.subject !== void 0) box.notCheckable.push({ field: "subject", source: "database" });
|
|
2048
|
+
if (start.sender !== void 0) box.notCheckable.push({ field: "from_email", source: "database" });
|
|
2049
|
+
if (start.loadedAt !== void 0) box.notCheckable.push({ field: "simulation_load.loaded_at", source: "database" });
|
|
2050
|
+
for (const a of Array.isArray(d.approvals) ? d.approvals : []) {
|
|
2051
|
+
if (a.ticketId !== goalId && !stepIds.includes(String(a.ticketId))) box.mismatches.push(`An approval step names ticket ${String(a.ticketId)}, which is not in this case.`);
|
|
2052
|
+
box.notCheckable.push({ field: `approval(${String(a.ticketId)})`, source: "archive" });
|
|
2053
|
+
}
|
|
2054
|
+
const emailTime = start.receivedAt !== void 0 ? parseTimestampSeconds(start.receivedAt) : start.emailTime;
|
|
2055
|
+
const loadedAt = start.loadedAt !== void 0 ? parseTimestampSeconds(start.loadedAt) : void 0;
|
|
2056
|
+
if (typeof emailTime !== "number" || emailTime !== start.emailTime) {
|
|
2057
|
+
box.mismatches.push("received_at: the drawn date is not the start time the case uses.");
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
const startTime = loadedAt !== void 0 ? Math.max(emailTime, loadedAt) : emailTime;
|
|
2061
|
+
const basis = loadedAt === void 0 ? "load-unknown" : emailTime >= loadedAt ? "email" : "loaded";
|
|
2062
|
+
if (start.time !== startTime || start.basis !== basis) box.mismatches.push("The case start is not max(received_at, simulation_load.loaded_at).");
|
|
2063
|
+
const goalTime = gr?.timestamp;
|
|
2064
|
+
if (typeof goalTime === "number") {
|
|
2065
|
+
if (goalTime < startTime) box.mismatches.push("The goal ticket is timestamped before the case start.");
|
|
2066
|
+
for (const s of steps) {
|
|
2067
|
+
if (typeof s.time !== "number" || s.time < startTime || s.time > goalTime) box.mismatches.push(`Step ${String(s.ticketId)} lies outside the case window.`);
|
|
2068
|
+
}
|
|
2069
|
+
const expected = basis === "load-unknown" ? null : goalTime - startTime;
|
|
2070
|
+
if ((d.totalDurationSeconds ?? null) !== expected) {
|
|
2071
|
+
box.mismatches.push(`duration_s: the box shows ${JSON.stringify(d.totalDurationSeconds ?? null)}, recomputed ${JSON.stringify(expected)}.`);
|
|
2072
|
+
} else if (expected !== null) {
|
|
2073
|
+
box.recomputed.push({ field: "duration_s", inputs: ["signed", "database"] });
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
var METRIC_INPUTS = {
|
|
2078
|
+
completed: ["signed", "database"],
|
|
2079
|
+
"median-time": ["signed", "database"],
|
|
2080
|
+
"average-time": ["signed", "database"],
|
|
2081
|
+
"without-approval": ["archive"],
|
|
2082
|
+
approvals: ["archive"],
|
|
2083
|
+
"median-approval-wait": ["archive"],
|
|
2084
|
+
tickets: ["signed"]
|
|
2085
|
+
};
|
|
2086
|
+
function checkMetricBox(bundle, el, box) {
|
|
2087
|
+
const d = el.data;
|
|
2088
|
+
const kind = el.attrs.kind ?? "";
|
|
2089
|
+
if (d.kind !== kind) box.mismatches.push(`kind: the box shows ${JSON.stringify(d.kind)}, the report asks for ${JSON.stringify(kind)}.`);
|
|
2090
|
+
const allCases = bundle.elements.filter((e) => e.kind === "sv-case");
|
|
2091
|
+
const casesAttr = (el.attrs.cases ?? "all").trim();
|
|
2092
|
+
const requested = casesAttr === "all" ? allCases.length : new Set(casesAttr.split(/\s+/).filter(Boolean)).size;
|
|
2093
|
+
let selected = allCases.filter((c) => c.status !== "unverifiable" && c.data);
|
|
2094
|
+
if (casesAttr !== "all") {
|
|
2095
|
+
const wanted = new Set(casesAttr.split(/\s+/).filter(Boolean));
|
|
2096
|
+
selected = selected.filter((c) => wanted.has(String(c.data.caseId)));
|
|
2097
|
+
}
|
|
2098
|
+
const caseIds = selected.map((c) => String(c.data.caseId));
|
|
2099
|
+
if (!same(d.caseIds ?? [], caseIds)) box.mismatches.push("cases: the box does not list the report's own verified cases.");
|
|
2100
|
+
const expectedStatus = selected.length < requested ? "warning" : "verified";
|
|
2101
|
+
if (el.status !== expectedStatus) box.mismatches.push(`The box is drawn as ${el.status}, but it covers ${selected.length} of ${requested} requested cases.`);
|
|
2102
|
+
if (kind === "refusals") {
|
|
2103
|
+
box.notCheckable.push({ field: "refusals", source: "database" });
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
const inputs = selected.map((c) => {
|
|
2107
|
+
const cd = c.data;
|
|
2108
|
+
const goal = cd.goal ?? {};
|
|
2109
|
+
const start = cd.start ?? {};
|
|
2110
|
+
return {
|
|
2111
|
+
caseId: String(cd.caseId),
|
|
2112
|
+
startTime: Number(start.time),
|
|
2113
|
+
goalTime: Number(goal.time),
|
|
2114
|
+
totalDurationSeconds: cd.totalDurationSeconds ?? null,
|
|
2115
|
+
ticketIds: [String(goal.ticketId), ...(Array.isArray(cd.steps) ? cd.steps : []).map((s) => String(s.ticketId))],
|
|
2116
|
+
approvals: (Array.isArray(cd.approvals) ? cd.approvals : []).map((a) => ({
|
|
2117
|
+
waitSeconds: typeof a.createdAt === "number" && typeof a.decidedAt === "number" ? a.decidedAt - a.createdAt : void 0
|
|
2118
|
+
}))
|
|
2119
|
+
};
|
|
2120
|
+
});
|
|
2121
|
+
let value;
|
|
2122
|
+
try {
|
|
2123
|
+
value = computeMetric(kind, inputs);
|
|
2124
|
+
} catch (err) {
|
|
2125
|
+
box.mismatches.push(`The figure cannot be recomputed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (value === d.value) box.recomputed.push({ field: "value", inputs: METRIC_INPUTS[kind] ?? ["signed"] });
|
|
2129
|
+
else box.mismatches.push(`value: the box shows ${JSON.stringify(d.value)}, recomputed from the case boxes ${JSON.stringify(value)}.`);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/lib/report/parse-elements.ts
|
|
2133
|
+
import { Parser } from "htmlparser2";
|
|
2134
|
+
function parseElements(html) {
|
|
2135
|
+
const found = [];
|
|
2136
|
+
const seenCount = /* @__PURE__ */ new Map();
|
|
2137
|
+
let opaqueDepth = 0;
|
|
2138
|
+
const parser = new Parser(
|
|
2139
|
+
{
|
|
2140
|
+
onopentag(name, attribs) {
|
|
2141
|
+
if (name === "sv-ai" || name === "sv-glossary") {
|
|
2142
|
+
opaqueDepth++;
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (opaqueDepth > 0) return;
|
|
2146
|
+
if (!name.startsWith("sv-") || STRUCTURE_TAGS.has(name)) return;
|
|
2147
|
+
const n = seenCount.get(name) ?? 0;
|
|
2148
|
+
seenCount.set(name, n + 1);
|
|
2149
|
+
found.push({ id: `${name}-${n}`, kind: name, attrs: { ...attribs } });
|
|
2150
|
+
},
|
|
2151
|
+
onclosetag(name) {
|
|
2152
|
+
if ((name === "sv-ai" || name === "sv-glossary") && opaqueDepth > 0) opaqueDepth--;
|
|
2153
|
+
}
|
|
2154
|
+
},
|
|
2155
|
+
{ decodeEntities: true, recognizeSelfClosing: true }
|
|
2156
|
+
);
|
|
2157
|
+
parser.write(html);
|
|
2158
|
+
parser.end();
|
|
2159
|
+
return found;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// src/lib/report/verify-export.ts
|
|
2163
|
+
function collectReferencedTicketIds(html) {
|
|
2164
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2165
|
+
for (const el of reportElements(html)) {
|
|
2166
|
+
if (el.kind === "sv-ticket" && el.attrs.ref) ids.add(el.attrs.ref);
|
|
2167
|
+
if ((el.kind === "sv-approval" || el.kind === "sv-mandate") && el.attrs.ticket) ids.add(el.attrs.ticket);
|
|
2168
|
+
if (el.kind === "sv-case") {
|
|
2169
|
+
const { goalId, stepIds } = parseCaseAttrs(el.attrs);
|
|
2170
|
+
if (goalId) ids.add(goalId);
|
|
2171
|
+
for (const s of stepIds) ids.add(s);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return ids;
|
|
2175
|
+
}
|
|
2176
|
+
function reportElements(html) {
|
|
2177
|
+
return parseElements(sanitizeReportHtml(html));
|
|
2178
|
+
}
|
|
2179
|
+
function ticketIdsOf(el) {
|
|
2180
|
+
if (el.kind === "sv-ticket") return el.attrs.ref ? [el.attrs.ref] : [];
|
|
2181
|
+
if (el.kind === "sv-approval" || el.kind === "sv-mandate") return el.attrs.ticket ? [el.attrs.ticket] : [];
|
|
2182
|
+
if (el.kind === "sv-case") {
|
|
2183
|
+
const { goalId, stepIds } = parseCaseAttrs(el.attrs);
|
|
2184
|
+
return [...goalId ? [goalId] : [], ...stepIds];
|
|
2185
|
+
}
|
|
2186
|
+
return [];
|
|
2187
|
+
}
|
|
2188
|
+
var TICKET_BACKED_KINDS = /* @__PURE__ */ new Set(["sv-ticket", "sv-approval", "sv-mandate", "sv-case"]);
|
|
2189
|
+
var PROOF_SCRIPT_BLOCK_RE = /<script[^>]*id="suveren-proof"[^>]*>[\s\S]*?<\/script>/gi;
|
|
2190
|
+
function collectPresentedStates(documentHtml) {
|
|
2191
|
+
const html = documentHtml.replace(PROOF_SCRIPT_BLOCK_RE, "");
|
|
2192
|
+
const open = [];
|
|
2193
|
+
const flaggedById = /* @__PURE__ */ new Map();
|
|
2194
|
+
let depth = 0;
|
|
2195
|
+
const classesOf = (attribs) => new Set((attribs.class ?? "").split(/\s+/).filter(Boolean));
|
|
2196
|
+
const settle = (f) => {
|
|
2197
|
+
const flagged = f.classes.has("sv-el-unverifiable") && !f.classes.has("sv-el-verified") && !f.classes.has("sv-el-warning") && f.badges.length > 0 && f.badges.every((b) => b.classes.has("sv-badge-bad") && !b.classes.has("sv-badge-ok") && !b.classes.has("sv-badge-warn") && !b.classes.has("sv-seal") && !b.hasSource);
|
|
2198
|
+
flaggedById.set(f.id, (flaggedById.get(f.id) ?? true) && flagged);
|
|
2199
|
+
};
|
|
2200
|
+
const parser = new Parser2(
|
|
2201
|
+
{
|
|
2202
|
+
onopentag(_name, attribs) {
|
|
2203
|
+
depth++;
|
|
2204
|
+
const classes = classesOf(attribs);
|
|
2205
|
+
const hasSource = typeof attribs["data-sv-source"] === "string";
|
|
2206
|
+
if (hasSource || [...classes].some((c) => c.startsWith("sv-badge") || c === "sv-seal")) {
|
|
2207
|
+
for (const f of open) f.badges.push({ classes, hasSource });
|
|
2208
|
+
}
|
|
2209
|
+
const id = attribs["data-sv-id"];
|
|
2210
|
+
if (typeof id === "string" && id) open.push({ id, depth, classes, badges: [] });
|
|
2211
|
+
},
|
|
2212
|
+
onclosetag() {
|
|
2213
|
+
while (open.length > 0 && open[open.length - 1].depth >= depth) settle(open.pop());
|
|
2214
|
+
depth--;
|
|
2215
|
+
}
|
|
2216
|
+
},
|
|
2217
|
+
{ decodeEntities: true }
|
|
2218
|
+
);
|
|
2219
|
+
parser.write(html);
|
|
2220
|
+
parser.end();
|
|
2221
|
+
while (open.length > 0) settle(open.pop());
|
|
2222
|
+
const states = /* @__PURE__ */ new Map();
|
|
2223
|
+
for (const [id, flagged] of flaggedById) states.set(id, flagged ? "not-verifiable" : "verified");
|
|
2224
|
+
return states;
|
|
2225
|
+
}
|
|
2226
|
+
function recomputeBoundsHash(bounds) {
|
|
2227
|
+
const keyOrder = Object.keys(bounds);
|
|
2228
|
+
const profile = {
|
|
2229
|
+
id: "export-check",
|
|
2230
|
+
boundsSchema: {
|
|
2231
|
+
keyOrder,
|
|
2232
|
+
fields: Object.fromEntries(keyOrder.map((k) => [k, { type: typeof bounds[k] === "number" ? "number" : "string" }]))
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
return computeBoundsHash(bounds, profile);
|
|
2236
|
+
}
|
|
2237
|
+
function ticketId(receipt) {
|
|
2238
|
+
return typeof receipt.id === "string" ? receipt.id : "";
|
|
2239
|
+
}
|
|
2240
|
+
async function checkAttestations(auth, publicKeyHex) {
|
|
2241
|
+
if (auth.attestations.length === 0) {
|
|
2242
|
+
return { valid: false, error: "No archived mandate attestation for this authorization." };
|
|
2243
|
+
}
|
|
2244
|
+
for (const att of auth.attestations) {
|
|
2245
|
+
try {
|
|
2246
|
+
const attestation = decodeAttestationBlob5(att.blob);
|
|
2247
|
+
await verifyAttestationSignature4(attestation, publicKeyHex);
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
return { valid: false, error: err instanceof Error ? err.message : String(err) };
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
return { valid: true };
|
|
2253
|
+
}
|
|
2254
|
+
async function verifyExportBundle(bundle, opts = {}) {
|
|
2255
|
+
const byId = new Map(bundle.tickets.map((t) => [ticketId(t), t]));
|
|
2256
|
+
const referencedIds = collectReferencedTicketIds(bundle.report.html);
|
|
2257
|
+
const allIds = /* @__PURE__ */ new Set([...referencedIds, ...byId.keys()]);
|
|
2258
|
+
const tickets = [];
|
|
2259
|
+
for (const id of allIds) {
|
|
2260
|
+
const receipt = byId.get(id);
|
|
2261
|
+
if (!receipt) {
|
|
2262
|
+
tickets.push({ ticketId: id, referenced: referencedIds.has(id), present: false, signatureValid: false, error: "Not present in the bundle." });
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
try {
|
|
2266
|
+
await verifyReceiptSignature2(receipt, bundle.authorityServer.publicKeyHex);
|
|
2267
|
+
tickets.push({ ticketId: id, referenced: referencedIds.has(id), present: true, signatureValid: true });
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
tickets.push({
|
|
2270
|
+
ticketId: id,
|
|
2271
|
+
referenced: referencedIds.has(id),
|
|
2272
|
+
present: true,
|
|
2273
|
+
signatureValid: false,
|
|
2274
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
const authorizations = [];
|
|
2279
|
+
const boundsProven = /* @__PURE__ */ new Set();
|
|
2280
|
+
for (const [authorizationId, auth] of Object.entries(bundle.authorizations)) {
|
|
2281
|
+
const { valid: attestationValid, error: attestationError } = await checkAttestations(auth, bundle.authorityServer.publicKeyHex);
|
|
2282
|
+
const hashes = [];
|
|
2283
|
+
if (auth.boundsHash) hashes.push({ from: "the mandate record", value: auth.boundsHash, signed: false });
|
|
2284
|
+
for (const att of auth.attestations) {
|
|
2285
|
+
try {
|
|
2286
|
+
const bh = decodeAttestationBlob5(att.blob).payload.bounds_hash;
|
|
2287
|
+
if (bh) hashes.push({ from: "the signed attestation", value: bh, signed: attestationValid });
|
|
2288
|
+
} catch {
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
for (const r of byId.values()) {
|
|
2292
|
+
if (r.authorizationId === authorizationId && typeof r.boundsHash === "string") {
|
|
2293
|
+
const t = tickets.find((x) => x.ticketId === ticketId(r));
|
|
2294
|
+
hashes.push({ from: `signed ticket ${String(r.id ?? "")}`, value: r.boundsHash, signed: !!t?.signatureValid });
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
let recomputed;
|
|
2298
|
+
let recomputeError;
|
|
2299
|
+
if (auth.bounds && Object.keys(auth.bounds).length > 0) {
|
|
2300
|
+
try {
|
|
2301
|
+
recomputed = recomputeBoundsHash(auth.bounds);
|
|
2302
|
+
} catch (err) {
|
|
2303
|
+
recomputeError = `The bounds values cannot be hashed: ${err instanceof Error ? err.message : String(err)}`;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
const all = [...hashes.map((h) => h.value), ...recomputed ? [recomputed] : []];
|
|
2307
|
+
const boundsHashMatches = all.length < 2 ? "n/a" : all.every((v) => v === all[0]);
|
|
2308
|
+
let boundsValuesProven = "n/a";
|
|
2309
|
+
if (auth.bounds && Object.keys(auth.bounds).length > 0) {
|
|
2310
|
+
boundsValuesProven = !!recomputed && boundsHashMatches !== false && hashes.some((h) => h.signed && h.value === recomputed);
|
|
2311
|
+
if (boundsValuesProven === true) boundsProven.add(authorizationId);
|
|
2312
|
+
}
|
|
2313
|
+
const mismatch = hashes.find((h) => h.value !== (recomputed ?? all[0]));
|
|
2314
|
+
const boundsError = recomputeError ?? (boundsHashMatches === false ? `Bounds hash mismatch: ${recomputed ? "the hash of the bounds values in the file" : "the mandate record"} does not match ${mismatch?.from ?? "another hash"}.` : boundsValuesProven === false ? "The bounds values in the file are not committed by any signed bounds_hash." : void 0);
|
|
2315
|
+
authorizations.push({
|
|
2316
|
+
authorizationId,
|
|
2317
|
+
attestationValid,
|
|
2318
|
+
boundsHashMatches,
|
|
2319
|
+
boundsValuesProven,
|
|
2320
|
+
...attestationError ? { error: attestationError } : boundsError ? { error: boundsError } : {}
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
const ticketById = new Map(tickets.map((t) => [t.ticketId, t]));
|
|
2324
|
+
const authById = new Map(authorizations.map((a) => [a.authorizationId, a]));
|
|
2325
|
+
const presentedStates = opts.documentHtml !== void 0 ? collectPresentedStates(opts.documentHtml) : /* @__PURE__ */ new Map();
|
|
2326
|
+
const elements = [];
|
|
2327
|
+
for (const el of reportElements(bundle.report.html)) {
|
|
2328
|
+
if (!TICKET_BACKED_KINDS.has(el.kind)) continue;
|
|
2329
|
+
const ticketIds = ticketIdsOf(el);
|
|
2330
|
+
const presented = presentedStates.get(el.id) ?? "verified";
|
|
2331
|
+
let error;
|
|
2332
|
+
if (ticketIds.length === 0) error = "Names no ticket.";
|
|
2333
|
+
for (const id of ticketIds) {
|
|
2334
|
+
if (error) break;
|
|
2335
|
+
const t = ticketById.get(id);
|
|
2336
|
+
if (!t || !t.present) error = `Ticket ${id} is not in the bundle.`;
|
|
2337
|
+
else if (!t.signatureValid) error = `Ticket ${id} has an invalid signature.`;
|
|
2338
|
+
}
|
|
2339
|
+
if (!error && el.kind === "sv-mandate") {
|
|
2340
|
+
const receipt = byId.get(ticketIds[0]);
|
|
2341
|
+
const authorizationId = typeof receipt.authorizationId === "string" ? receipt.authorizationId : void 0;
|
|
2342
|
+
const auth = authorizationId ? bundle.authorizations[authorizationId] : void 0;
|
|
2343
|
+
const authCheck = authorizationId ? authById.get(authorizationId) : void 0;
|
|
2344
|
+
if (!auth || !authCheck) error = `The mandate for ticket ${ticketIds[0]} is not in the bundle.`;
|
|
2345
|
+
else if (!authCheck.attestationValid) error = `The mandate for ticket ${ticketIds[0]} has an invalid attestation.`;
|
|
2346
|
+
else if (auth.boundsHash && typeof receipt.boundsHash === "string" && auth.boundsHash !== receipt.boundsHash) {
|
|
2347
|
+
error = `The mandate's boundsHash does not match ticket ${ticketIds[0]}.`;
|
|
2348
|
+
} else if (authCheck.boundsHashMatches === false || authCheck.boundsValuesProven === false) {
|
|
2349
|
+
error = `The mandate for ticket ${ticketIds[0]}: ${authCheck.error ?? "bounds not proven"}`;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
elements.push({ elementId: el.id, kind: el.kind, ticketIds, presented, backed: !error, ...error ? { error } : {} });
|
|
2353
|
+
}
|
|
2354
|
+
const keyFingerprint = fingerprintOf(bundle.authorityServer.publicKeyHex);
|
|
2355
|
+
const keyConfirmation = resolveKeyConfirmation(bundle.authorityServer.publicKeyHex, opts);
|
|
2356
|
+
const document = checkDocumentReproduces(bundle, opts.documentHtml);
|
|
2357
|
+
const validTickets = /* @__PURE__ */ new Map();
|
|
2358
|
+
for (const t of tickets) if (t.signatureValid) validTickets.set(t.ticketId, byId.get(t.ticketId));
|
|
2359
|
+
const drawn = await checkDrawnElements(
|
|
2360
|
+
{ bundle, validTickets, boundsProven },
|
|
2361
|
+
reportElements(bundle.report.html).map((e) => ({ id: e.id, kind: e.kind, attrs: e.attrs }))
|
|
2362
|
+
);
|
|
2363
|
+
const allValid = tickets.filter((t) => t.present).every((t) => t.signatureValid) && authorizations.every((a) => a.attestationValid && a.boundsHashMatches !== false && a.boundsValuesProven !== false) && elements.every((e) => e.presented === "not-verifiable" || e.backed) && document.state !== "mismatch" && drawn.errors.length === 0 && drawn.boxes.every((b) => b.mismatches.length === 0);
|
|
2364
|
+
return { document, boxes: drawn.boxes, drawnErrors: drawn.errors, tickets, elements, authorizations, keyFingerprint, keyConfirmation, allValid };
|
|
2365
|
+
}
|
|
2366
|
+
function resolveKeyConfirmation(actualKeyHex, opts) {
|
|
2367
|
+
const normalize = (h) => h.trim().toLowerCase();
|
|
2368
|
+
if (opts.expectedKeyHex !== void 0) {
|
|
2369
|
+
if (normalize(opts.expectedKeyHex) === normalize(actualKeyHex)) return { state: "confirmed", source: "provided" };
|
|
2370
|
+
return { state: "mismatch", source: "provided", expectedFingerprint: fingerprintOf(opts.expectedKeyHex) };
|
|
2371
|
+
}
|
|
2372
|
+
if (opts.onlineKeyHex !== void 0) {
|
|
2373
|
+
if (normalize(opts.onlineKeyHex) === normalize(actualKeyHex)) return { state: "confirmed", source: "online" };
|
|
2374
|
+
return { state: "mismatch", source: "online", expectedFingerprint: fingerprintOf(opts.onlineKeyHex) };
|
|
2375
|
+
}
|
|
2376
|
+
return { state: "unconfirmed" };
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
export {
|
|
2380
|
+
readPairing,
|
|
2381
|
+
fingerprintOf,
|
|
2382
|
+
REPORT_ELEMENTS,
|
|
2383
|
+
REPORT_BRIEF,
|
|
2384
|
+
sanitizeReport,
|
|
2385
|
+
parseElements,
|
|
2386
|
+
formatDateTime,
|
|
2387
|
+
profileShortLabel,
|
|
2388
|
+
agentTicketRow,
|
|
2389
|
+
agentTicket,
|
|
2390
|
+
agentApproval,
|
|
2391
|
+
agentMandate,
|
|
2392
|
+
resolveTicketElement,
|
|
2393
|
+
resolveApprovalElement,
|
|
2394
|
+
resolveMandateElement,
|
|
2395
|
+
parseTimestampSeconds,
|
|
2396
|
+
parseCaseAttrs,
|
|
2397
|
+
CASE_TIME_UNKNOWN_REASON,
|
|
2398
|
+
resolveCaseElement,
|
|
2399
|
+
START_TIME_METRICS,
|
|
2400
|
+
computeMetric,
|
|
2401
|
+
ticketFields,
|
|
2402
|
+
approvalFields,
|
|
2403
|
+
mandateFields,
|
|
2404
|
+
checkedValueLine,
|
|
2405
|
+
glossaryUsage,
|
|
2406
|
+
renderReportHtml,
|
|
2407
|
+
buildExportBundle,
|
|
2408
|
+
buildExportDocument,
|
|
2409
|
+
suggestedFilename,
|
|
2410
|
+
isExportBundle,
|
|
2411
|
+
verifyExportBundle
|
|
2412
|
+
};
|