tamper-signal 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/badge/badge.js ADDED
@@ -0,0 +1,436 @@
1
+ // Tamper Signal: the standalone receipt verification badge. No build step,
2
+ // no framework.
3
+ //
4
+ // This file is also the browser verification core for the inline status light
5
+ // (light.js): verifyReceipts(chainUrl, pubKeyHex) runs the whole pipeline
6
+ // (fetch, signatures, hash links, caveats) and returns a structured result;
7
+ // the render layers differ.
8
+ //
9
+ // renderReceiptBadge(containerEl, chainUrl, pubKeyHex)
10
+ // Fetches chain.json and every receipt, re-verifies all signatures with Web
11
+ // Crypto Ed25519, re-checks all hash links (receipt N input == receipt N-1
12
+ // output), and renders a collapsed green/yellow/red badge that expands to
13
+ // per-stage detail. Yellow means the chain verifies, with caveats (coverage
14
+ // gap in the receipt numbering, or signatures that only verify under the
15
+ // chain's embedded key rather than the caller-supplied trusted key). The
16
+ // separate amber state ("could not load" / "unsupported browser") is a
17
+ // capability fallback, not a verdict: it says nothing about the chain.
18
+ // The browser does NOT re-canonicalize xlsx; it only re-links hashes.
19
+ //
20
+ // pubKeyHex is optional; when omitted the key embedded in chain.json is used.
21
+
22
+ export const SHORT = (h) => (h && h.length > 10 ? `${h.slice(0, 4)}...${h.slice(-2)}` : h || "(none)");
23
+
24
+ // --- Canonical JSON, byte-identical to tamper_signal/canonical.py's JCS output. ---
25
+ // Leaves are strings, integers, booleans, or null (no floats). Object keys are
26
+ // sorted; strings use JSON.stringify, whose escaping matches the Python side.
27
+ function canonicalize(value) {
28
+ if (value === null) return "null";
29
+ const t = typeof value;
30
+ if (t === "boolean") return value ? "true" : "false";
31
+ if (t === "number") {
32
+ // Python's JCS only serializes integers (floats/NaN/Infinity raise). Match
33
+ // that so the badge never shows green for a receipt Python would refuse.
34
+ if (!Number.isSafeInteger(value)) throw new Error("non-integer number leaf");
35
+ return String(value);
36
+ }
37
+ if (t === "string") return JSON.stringify(value);
38
+ if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
39
+ if (t === "object") {
40
+ const keys = Object.keys(value).sort();
41
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(value[k])).join(",") + "}";
42
+ }
43
+ throw new Error("cannot canonicalize " + t);
44
+ }
45
+
46
+ function receiptBody(receipt) {
47
+ const body = {};
48
+ for (const k of Object.keys(receipt)) if (k !== "signature") body[k] = receipt[k];
49
+ return body;
50
+ }
51
+
52
+ function hexToBytes(hex) {
53
+ // Reject malformed hex (odd length / non-hex chars) so the browser fails
54
+ // closed exactly like Python's bytes.fromhex, instead of silently coercing
55
+ // NaN to 0 and accepting a signature/key that Python would reject.
56
+ if (typeof hex !== "string" || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
57
+ throw new Error("invalid hex");
58
+ }
59
+ const out = new Uint8Array(hex.length / 2);
60
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
61
+ return out;
62
+ }
63
+
64
+ // --- Hash accessors mirroring tamper_signal/receipts.py. ---
65
+ export const outputHashOf = (r) =>
66
+ r.kind === "source_manifest" ? r.semantic_hash : r.output_semantic_hash;
67
+ export const inputHashOf = (r) => (r.kind === "source_manifest" ? null : r.input_semantic_hash);
68
+ export const totalsOf = (r) =>
69
+ r.kind === "source_manifest" ? r.control_totals : r.output_control_totals;
70
+ export const stageNameOf = (r) => (r.kind === "source_manifest" ? "source" : r.transform.name);
71
+
72
+ // --- Totals delta, mirroring tamper_signal/totals.py for the red expand. Reports
73
+ // row_count, column_count, numeric_sums and null_counts, with sorted (so
74
+ // deterministic) column ordering to stay consistent with the CLI verifier. The
75
+ // numeric diff is shown as before -> after (no Decimal arithmetic in-browser).
76
+ const sortedUnion = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
77
+
78
+ export function totalsDelta(up, down) {
79
+ const lines = [];
80
+ if (up.row_count !== down.row_count) {
81
+ const d = down.row_count - up.row_count;
82
+ lines.push(`row_count ${up.row_count} -> ${down.row_count} (${d >= 0 ? "+" : ""}${d})`);
83
+ }
84
+ if (up.column_count !== down.column_count) {
85
+ const d = down.column_count - up.column_count;
86
+ lines.push(`column_count ${up.column_count} -> ${down.column_count} (${d >= 0 ? "+" : ""}${d})`);
87
+ }
88
+ const us = up.numeric_sums || {};
89
+ const ds = down.numeric_sums || {};
90
+ for (const col of sortedUnion(us, ds)) {
91
+ if (us[col] !== ds[col]) lines.push(`${col} ${us[col] ?? "(added)"} -> ${ds[col] ?? "(removed)"}`);
92
+ }
93
+ const un = up.null_counts || {};
94
+ const dn = down.null_counts || {};
95
+ for (const col of sortedUnion(un, dn)) {
96
+ const before = un[col] ?? 0;
97
+ const after = dn[col] ?? 0;
98
+ if (before !== after) {
99
+ const d = after - before;
100
+ lines.push(`null_counts[${col}] ${before} -> ${after} (${d >= 0 ? "+" : ""}${d})`);
101
+ }
102
+ }
103
+ return lines;
104
+ }
105
+
106
+ export async function ed25519Available() {
107
+ if (!window.crypto || !crypto.subtle) return false;
108
+ try {
109
+ await crypto.subtle.importKey("raw", new Uint8Array(32), { name: "Ed25519" }, false, [
110
+ "verify",
111
+ ]);
112
+ return true;
113
+ } catch (_e) {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ export async function verifySignature(receipt, pubKeyHex) {
119
+ const key = await crypto.subtle.importKey(
120
+ "raw",
121
+ hexToBytes(pubKeyHex),
122
+ { name: "Ed25519" },
123
+ false,
124
+ ["verify"]
125
+ );
126
+ const message = new TextEncoder().encode(canonicalize(receiptBody(receipt)));
127
+ const sig = hexToBytes(receipt.signature.value);
128
+ return crypto.subtle.verify({ name: "Ed25519" }, key, sig, message);
129
+ }
130
+
131
+ // chain.receipts is attacker-controlled, so receipt names are restricted to a
132
+ // bare filename next to chain.json (matching how they are generated and the
133
+ // Python read_receipt guard). This blocks absolute URLs and ../ traversal that
134
+ // could make the viewer's browser fetch arbitrary / cross-origin resources.
135
+ const SAFE_RECEIPT_NAME = /^[A-Za-z0-9._-]+$/;
136
+
137
+ export async function loadChain(chainUrl) {
138
+ const base = new URL(chainUrl, window.location.href);
139
+ const chain = await (await fetch(base)).json();
140
+ const receipts = [];
141
+ for (const name of chain.receipts || []) {
142
+ if (typeof name !== "string" || !SAFE_RECEIPT_NAME.test(name)) {
143
+ throw new Error("unsafe receipt name in chain: " + name);
144
+ }
145
+ const url = new URL(name, base);
146
+ receipts.push(await (await fetch(url)).json());
147
+ }
148
+ return { chain, receipts };
149
+ }
150
+
151
+ // Gaps in the generated NNN_ receipt numbering, mirroring tamper_signal/receipts.py's
152
+ // _coverage_gaps. Hand-named receipt sets (no numeric prefix) opt out.
153
+ export function coverageGaps(receiptNames) {
154
+ const indices = [];
155
+ for (const name of receiptNames) {
156
+ const prefix = String(name).split("_", 1)[0];
157
+ if (!/^[0-9]{3}$/.test(prefix)) return [];
158
+ indices.push(parseInt(prefix, 10));
159
+ }
160
+ const gaps = [];
161
+ if (indices.length && indices[0] !== 0) {
162
+ gaps.push(`chain starts at ${String(indices[0]).padStart(3, "0")}, not 000`);
163
+ }
164
+ for (let i = 1; i < indices.length; i++) {
165
+ if (indices[i] !== indices[i - 1] + 1) {
166
+ const a = String(indices[i - 1]).padStart(3, "0");
167
+ const b = String(indices[i]).padStart(3, "0");
168
+ gaps.push(`numbering jumps ${a} -> ${b}`);
169
+ }
170
+ }
171
+ return gaps;
172
+ }
173
+
174
+ // Signature pass mirroring tamper_signal/receipts.py: every receipt is checked
175
+ // against the trusted key; one that fails there but verifies under the key
176
+ // embedded in chain.json means the chain is internally consistent but vouched
177
+ // for by a key the caller does not trust (yellow), not broken (red).
178
+ export async function checkSignatures(receipts, trustedKeyHex, chainKeyHex) {
179
+ let valid = true;
180
+ let unrecognized = false;
181
+ const useFallback = chainKeyHex && chainKeyHex !== trustedKeyHex;
182
+ for (const r of receipts) {
183
+ let ok = false;
184
+ try {
185
+ ok = await verifySignature(r, trustedKeyHex);
186
+ } catch (_e) {
187
+ ok = false;
188
+ }
189
+ if (ok) continue;
190
+ if (useFallback) {
191
+ let chainOk = false;
192
+ try {
193
+ chainOk = await verifySignature(r, chainKeyHex);
194
+ } catch (_e) {
195
+ chainOk = false;
196
+ }
197
+ if (chainOk) {
198
+ unrecognized = true;
199
+ continue;
200
+ }
201
+ }
202
+ valid = false;
203
+ }
204
+ return { valid, unrecognized };
205
+ }
206
+
207
+ export function evaluate(receipts) {
208
+ // Returns {ok, brokenAt, brokenStage, delta}. Signatures checked separately.
209
+ for (let i = 1; i < receipts.length; i++) {
210
+ const expected = outputHashOf(receipts[i - 1]);
211
+ const found = inputHashOf(receipts[i]);
212
+ if (found !== expected) {
213
+ return {
214
+ ok: false,
215
+ brokenAt: i,
216
+ brokenStage: stageNameOf(receipts[i]),
217
+ expected,
218
+ found,
219
+ delta: totalsDelta(totalsOf(receipts[i - 1]), totalsOf(receipts[i])),
220
+ };
221
+ }
222
+ }
223
+ return { ok: true };
224
+ }
225
+
226
+ // The whole browser verification pipeline as one call, shared by the badge and
227
+ // the inline status light. Returns a structured result:
228
+ //
229
+ // state "green" | "yellow" | "red" | "unverifiable"
230
+ // reason short human phrase for red / unverifiable states
231
+ // caveats yellow caveat strings (empty for green)
232
+ // linkResult evaluate() output ({ok} or {ok:false, brokenAt, ...})
233
+ // signaturesValid, chain, receipts, origin, finalRows, transforms, verifiedAt
234
+ //
235
+ // "unverifiable" is the capability fallback (fetch failed, no Web Crypto
236
+ // Ed25519): it says nothing about the chain and must never be conflated with
237
+ // the yellow verdict. opts.warnDrift adds a caveat for any control-totals
238
+ // movement across intact links (off by default: filters and aggregations
239
+ // legitimately move totals).
240
+ export async function verifyReceipts(chainUrl, pubKeyHex, opts = {}) {
241
+ const verifiedAt = new Date().toISOString();
242
+ let chain, receipts;
243
+ try {
244
+ ({ chain, receipts } = await loadChain(chainUrl));
245
+ } catch (_e) {
246
+ return { state: "unverifiable", reason: "could not load chain", caveats: [], verifiedAt };
247
+ }
248
+ if (!(await ed25519Available())) {
249
+ return {
250
+ state: "unverifiable",
251
+ reason: "verification unsupported in this browser",
252
+ caveats: [],
253
+ chain,
254
+ receipts,
255
+ verifiedAt,
256
+ };
257
+ }
258
+
259
+ const summary = {
260
+ chain,
261
+ receipts,
262
+ verifiedAt,
263
+ origin: "source",
264
+ finalRows: undefined,
265
+ transforms: 0,
266
+ signaturesValid: true,
267
+ linkResult: { ok: true },
268
+ caveats: [],
269
+ };
270
+ if (!receipts.length) {
271
+ return { ...summary, state: "red", reason: "chain empty" };
272
+ }
273
+ const source = receipts.find((r) => r.kind === "source_manifest");
274
+ summary.origin = (source && source.source.declared_origin) || "source";
275
+ summary.finalRows = totalsOf(receipts[receipts.length - 1]).row_count;
276
+ summary.transforms = receipts.filter((r) => r.kind === "transform_receipt").length;
277
+
278
+ const trustedKey = pubKeyHex || chain.public_key;
279
+ const sigResult = await checkSignatures(receipts, trustedKey, chain.public_key);
280
+ summary.signaturesValid = sigResult.valid;
281
+ summary.linkResult = evaluate(receipts);
282
+
283
+ if (!sigResult.valid) {
284
+ return { ...summary, state: "red", reason: "signature invalid" };
285
+ }
286
+ if (!summary.linkResult.ok) {
287
+ return {
288
+ ...summary,
289
+ state: "red",
290
+ reason: `chain broken at ${summary.linkResult.brokenStage}`,
291
+ };
292
+ }
293
+
294
+ // Chain verifies. Collect yellow caveats; none means green.
295
+ if (sigResult.unrecognized) {
296
+ summary.caveats.push(
297
+ "unrecognized signing key: receipts verify under the chain's embedded key, " +
298
+ "not the key this page trusts"
299
+ );
300
+ }
301
+ for (const gap of coverageGaps(chain.receipts || [])) {
302
+ summary.caveats.push(
303
+ `coverage gap: receipt ${gap}; a stage may have run without leaving a receipt`
304
+ );
305
+ }
306
+ if (opts.warnDrift) {
307
+ for (let i = 1; i < receipts.length; i++) {
308
+ const delta = totalsDelta(totalsOf(receipts[i - 1]), totalsOf(receipts[i]));
309
+ if (delta.length) {
310
+ summary.caveats.push(
311
+ `totals drift at ${stageNameOf(receipts[i])}: ${delta.join(", ")}`
312
+ );
313
+ }
314
+ }
315
+ }
316
+ return { ...summary, state: summary.caveats.length ? "yellow" : "green" };
317
+ }
318
+
319
+ function el(tag, props = {}, children = []) {
320
+ const node = document.createElement(tag);
321
+ Object.assign(node, props);
322
+ for (const child of [].concat(children)) {
323
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
324
+ }
325
+ return node;
326
+ }
327
+
328
+ function injectStyles() {
329
+ if (document.getElementById("receipt-badge-styles")) return;
330
+ const css = `
331
+ .receipt-badge{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
332
+ font-size:14px;border:1px solid #d0d5dd;border-radius:8px;max-width:640px;background:#fff;color:#1d2939}
333
+ .receipt-badge .lb-head{display:flex;align-items:center;gap:8px;padding:12px 14px;cursor:pointer;
334
+ user-select:none}
335
+ .receipt-badge .lb-mark{font-weight:700}
336
+ .receipt-badge.lb-green{border-color:#abefc6}
337
+ .receipt-badge.lb-green .lb-mark{color:#067647}
338
+ .receipt-badge.lb-red{border-color:#fda29b}
339
+ .receipt-badge.lb-red .lb-mark{color:#b42318}
340
+ .receipt-badge.lb-amber{border-color:#fedf89}
341
+ .receipt-badge.lb-amber .lb-mark{color:#b54708}
342
+ .receipt-badge.lb-yellow{border-color:#fedf89}
343
+ .receipt-badge.lb-yellow .lb-mark{color:#b54708}
344
+ .receipt-badge .lb-caveats{margin-top:8px;color:#b54708;font-size:13px}
345
+ .receipt-badge .lb-body{border-top:1px solid #eaecf0;padding:10px 14px;display:none}
346
+ .receipt-badge.lb-open .lb-body{display:block}
347
+ .receipt-badge table{border-collapse:collapse;width:100%;font-size:13px}
348
+ .receipt-badge th,.receipt-badge td{text-align:left;padding:6px 8px;border-bottom:1px solid #f2f4f7}
349
+ .receipt-badge th{color:#475467;font-weight:600}
350
+ .receipt-badge code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#475467}
351
+ .receipt-badge .lb-delta{margin-top:8px;color:#b42318;font-size:13px}
352
+ .receipt-badge .lb-caret{margin-left:auto;color:#98a2b3}`;
353
+ document.head.appendChild(el("style", { id: "receipt-badge-styles", textContent: css }));
354
+ }
355
+
356
+ function renderDetail(receipts) {
357
+ const rows = receipts.map((r) =>
358
+ el("tr", {}, [
359
+ el("td", {}, stageNameOf(r)),
360
+ el("td", {}, r.created_at || ""),
361
+ el("td", {}, [el("code", {}, SHORT(outputHashOf(r)))]),
362
+ el("td", {}, String(totalsOf(r).row_count ?? "")),
363
+ ])
364
+ );
365
+ return el("table", {}, [
366
+ el("thead", {}, el("tr", {}, [
367
+ el("th", {}, "stage"),
368
+ el("th", {}, "timestamp"),
369
+ el("th", {}, "output hash"),
370
+ el("th", {}, "rows"),
371
+ ])),
372
+ el("tbody", {}, rows),
373
+ ]);
374
+ }
375
+
376
+ export async function renderReceiptBadge(containerEl, chainUrl, pubKeyHex) {
377
+ injectStyles();
378
+ containerEl.innerHTML = "";
379
+ const badge = el("div", { className: "receipt-badge" });
380
+ const mark = el("span", { className: "lb-mark" });
381
+ const label = el("span", { className: "lb-label" });
382
+ const caret = el("span", { className: "lb-caret", textContent: "▸" });
383
+ const head = el("div", { className: "lb-head" }, [mark, label, caret]);
384
+ const body = el("div", { className: "lb-body" });
385
+ badge.appendChild(head);
386
+ badge.appendChild(body);
387
+ containerEl.appendChild(badge);
388
+
389
+ head.addEventListener("click", () => {
390
+ badge.classList.toggle("lb-open");
391
+ caret.textContent = badge.classList.contains("lb-open") ? "▾" : "▸";
392
+ });
393
+
394
+ const result = await verifyReceipts(chainUrl, pubKeyHex);
395
+ const { receipts, origin, finalRows, transforms, linkResult, caveats } = result;
396
+
397
+ if (result.state === "unverifiable") {
398
+ badge.classList.add("lb-amber");
399
+ mark.textContent = "!";
400
+ label.textContent = result.reason;
401
+ return;
402
+ }
403
+
404
+ if (result.state === "green") {
405
+ badge.classList.add("lb-green");
406
+ mark.textContent = "✓";
407
+ label.textContent =
408
+ `Verified · ${origin} · ${Number(finalRows).toLocaleString()} rows · ` +
409
+ `${transforms} transform${transforms === 1 ? "" : "s"} · chain intact`;
410
+ body.appendChild(renderDetail(receipts));
411
+ return;
412
+ }
413
+
414
+ if (result.state === "yellow") {
415
+ badge.classList.add("lb-yellow");
416
+ mark.textContent = "⚠";
417
+ label.textContent =
418
+ `Verified, with caveats · ${origin} · ${Number(finalRows).toLocaleString()} rows · ` +
419
+ `${caveats.length} caveat${caveats.length === 1 ? "" : "s"}`;
420
+ body.appendChild(renderDetail(receipts));
421
+ body.appendChild(
422
+ el("div", { className: "lb-caveats" }, "A human should look: " + caveats.join("; "))
423
+ );
424
+ return;
425
+ }
426
+
427
+ badge.classList.add("lb-red");
428
+ mark.textContent = "✗";
429
+ label.textContent = result.reason.charAt(0).toUpperCase() + result.reason.slice(1);
430
+ body.appendChild(renderDetail(receipts));
431
+ if (linkResult.ok === false && linkResult.delta && linkResult.delta.length) {
432
+ body.appendChild(
433
+ el("div", { className: "lb-delta" }, "Totals delta vs upstream: " + linkResult.delta.join(", "))
434
+ );
435
+ }
436
+ }
@@ -0,0 +1,77 @@
1
+ // <tamper-signal>: the inline status light as a custom element, so one tag
2
+ // works in plain HTML, Vue, Svelte, Angular, or anything else that renders
3
+ // DOM. Wraps mountTamperSignal; importing this module registers the element.
4
+ //
5
+ // <script type="module" src="/badge/element.js"></script>
6
+ // <tamper-signal chain="/receipts/chain.json"></tamper-signal>
7
+ //
8
+ // Attributes (all but `chain` optional):
9
+ // chain URL of chain.json (required; nothing mounts without it)
10
+ // pub-key trusted public key hex; defaults to the chain's embedded key
11
+ // watch re-verify every N milliseconds
12
+ // warn-drift present = flag any control-totals movement across links
13
+ // receipts-href href for the popover's "view receipts" link
14
+ // theme "light" to invert the pill on dark host pages
15
+ //
16
+ // The light renders into the element's light DOM (no shadow root) on purpose:
17
+ // the instrument's styles are aggressively namespaced already, and the red
18
+ // state must reach out and flag [data-receipt-column] elements in the host
19
+ // page, which a closed shadow boundary would complicate.
20
+
21
+ import { mountTamperSignal } from "./light.js";
22
+
23
+ const ATTRS = ["chain", "pub-key", "watch", "warn-drift", "receipts-href", "theme"];
24
+
25
+ class TamperSignalElement extends HTMLElement {
26
+ static get observedAttributes() {
27
+ return ATTRS;
28
+ }
29
+
30
+ constructor() {
31
+ super();
32
+ this._handle = null;
33
+ }
34
+
35
+ connectedCallback() {
36
+ this._mount();
37
+ }
38
+
39
+ disconnectedCallback() {
40
+ this._unmount();
41
+ }
42
+
43
+ attributeChangedCallback() {
44
+ if (this.isConnected) this._mount();
45
+ }
46
+
47
+ // The mount handle, for hosts that want refresh()/setOpen()/getState().
48
+ get signal() {
49
+ return this._handle;
50
+ }
51
+
52
+ _unmount() {
53
+ if (this._handle) {
54
+ this._handle.destroy();
55
+ this._handle = null;
56
+ }
57
+ }
58
+
59
+ _mount() {
60
+ this._unmount();
61
+ const chain = this.getAttribute("chain");
62
+ if (!chain) return; // nothing to verify yet; attribute may arrive later
63
+ const watch = Number(this.getAttribute("watch"));
64
+ this._handle = mountTamperSignal(this, chain, this.getAttribute("pub-key") || undefined, {
65
+ watch: Number.isFinite(watch) && watch > 0 ? watch : undefined,
66
+ warnDrift: this.hasAttribute("warn-drift"),
67
+ receiptsHref: this.getAttribute("receipts-href") || undefined,
68
+ theme: this.getAttribute("theme") || undefined,
69
+ });
70
+ }
71
+ }
72
+
73
+ if (!customElements.get("tamper-signal")) {
74
+ customElements.define("tamper-signal", TamperSignalElement);
75
+ }
76
+
77
+ export { TamperSignalElement };
@@ -0,0 +1,26 @@
1
+ // React wrapper for the inline status light.
2
+ //
3
+ // import { TamperSignal } from "./badge/light-react.js";
4
+ // <TamperSignal chain="/receipts/chain.json" />
5
+ //
6
+ // Props mirror mountTamperSignal: chain (required), pubKey, watch, warnDrift,
7
+ // receiptsHref, theme. This file assumes a bundler that resolves "react";
8
+ // light.js itself has no dependencies and works from a plain script tag.
9
+
10
+ import { createElement, useEffect, useRef } from "react";
11
+ import { mountTamperSignal } from "./light.js";
12
+
13
+ export function TamperSignal({ chain, pubKey, watch, warnDrift, receiptsHref, theme }) {
14
+ const ref = useRef(null);
15
+ useEffect(() => {
16
+ if (!ref.current) return undefined;
17
+ const handle = mountTamperSignal(ref.current, chain, pubKey, {
18
+ watch,
19
+ warnDrift,
20
+ receiptsHref,
21
+ theme,
22
+ });
23
+ return () => handle.destroy();
24
+ }, [chain, pubKey, watch, warnDrift, receiptsHref, theme]);
25
+ return createElement("span", { ref });
26
+ }