tamper-signal 1.2.0 → 1.4.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/AGENTS.md CHANGED
@@ -35,9 +35,12 @@ These govern copy, code comments, commit messages, and UI text you produce:
35
35
  Requires Python 3.11+.
36
36
 
37
37
  ```bash
38
- pip install git+https://github.com/welovejeff/tamper-evident-verification.git
38
+ pip install tamper-signal
39
39
  ```
40
40
 
41
+ (Installing from source also works:
42
+ `pip install git+https://github.com/welovejeff/tamper-evident-verification.git`)
43
+
41
44
  This provides the `receipts` CLI and the `tamper_signal` Python package.
42
45
  Verify: `receipts --help` exits 0. JavaScript-only project? Use step 1b and
43
46
  the JS equivalents; the two stacks produce interchangeable chains.
@@ -70,14 +73,16 @@ same package: `tamper-signal/light`, `tamper-signal/badge`,
70
73
  `tamper-signal/element`, `tamper-signal/react`. JS reads .csv/.tsv/.json/
71
74
  .ndjson; only the Python side reads .xlsx.
72
75
 
73
- ## 2. Generate a signing keypair (once per project)
76
+ ## 2. Scaffold the project (once)
74
77
 
75
78
  ```bash
76
- receipts keygen --out keys/
79
+ receipts init
77
80
  ```
78
81
 
79
- Writes `keys/signing.key` (private, PEM; never commit) and `keys/signing.pub`
80
- (raw 32-byte hex; safe to commit). Add `keys/` and `*.key` to .gitignore now.
82
+ Idempotent. Generates `keys/signing.key` (private, PEM; never commit) and
83
+ `keys/signing.pub` (raw hex; safe to commit), adds `keys/` and `*.key` to
84
+ .gitignore, creates `receipts/`, and prints exactly what it did. The pieces
85
+ are also available separately (`receipts keygen --out keys/`).
81
86
 
82
87
  ## 3. Start the chain at the source export
83
88
 
@@ -121,9 +126,64 @@ receipts verify receipts/chain.json --pub keys/signing.pub --data path/to/dashbo
121
126
 
122
127
  Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
123
128
  optional and checks the file the dashboard actually reads against the final
124
- receipt. In CI: fail the build on exit 1; surface exit 2 to a human rather
125
- than failing silently. `--warn-drift` additionally flags any control-totals
126
- movement across links (only for pipelines expected to preserve totals).
129
+ receipt. `--warn-drift` additionally flags any control-totals movement across
130
+ links (only for pipelines expected to preserve totals).
131
+
132
+ Add `--json` to get a structured verdict instead of the text report. Parse
133
+ this rather than scraping text:
134
+
135
+ ```json
136
+ {
137
+ "verdict": "green | yellow | red",
138
+ "exit_code": 0,
139
+ "spec_version": "1.1",
140
+ "receipts": 3,
141
+ "transforms": 2,
142
+ "stages": ["source", "clean", "aggregate"],
143
+ "final_row_count": 304,
144
+ "caveats": ["..."],
145
+ "broken_link": {
146
+ "link": [1, 2],
147
+ "stage": "aggregate",
148
+ "expected_input_hash": "...",
149
+ "found_input_hash": "...",
150
+ "totals_delta": ["row_count 4987 -> 304 (-4683)"]
151
+ },
152
+ "data_mismatch": null,
153
+ "report": ["human-legible lines"]
154
+ }
155
+ ```
156
+
157
+ `broken_link` and `data_mismatch` are null unless the verdict is red.
158
+
159
+ ### CI: verify the chain on every push
160
+
161
+ ```yaml
162
+ # .github/workflows/tamper-signal.yml
163
+ name: tamper-signal
164
+ on: [push]
165
+ jobs:
166
+ verify:
167
+ runs-on: ubuntu-latest
168
+ steps:
169
+ - uses: actions/checkout@v4
170
+ - uses: actions/setup-python@v5
171
+ with: { python-version: "3.12" }
172
+ - run: pip install tamper-signal
173
+ - name: Verify the receipt chain
174
+ run: |
175
+ set +e
176
+ receipts verify receipts/chain.json --json | tee verdict.json
177
+ code=$?
178
+ if [ "$code" = "2" ]; then
179
+ echo "::warning::The light is yellow, a human should look: $(python -c 'import json;print("; ".join(json.load(open("verdict.json"))["caveats"]))')"
180
+ exit 0
181
+ fi
182
+ exit $code
183
+ ```
184
+
185
+ Exit 1 (red) fails the build; exit 2 (yellow) surfaces a warning annotation
186
+ without failing.
127
187
 
128
188
  ## 6. Add the signal to the host UI
129
189
 
@@ -159,11 +219,48 @@ and badge.js beside it) and write one tag:
159
219
  Attributes mirror the options: `pub-key`, `watch`, `warn-drift`,
160
220
  `receipts-href`, `theme`.
161
221
 
162
- Static serving examples: Flask
163
- `app = Flask(__name__, static_folder="receipts", static_url_path="/receipts")`;
164
- FastAPI `app.mount("/receipts", StaticFiles(directory="receipts"))`; Express
165
- `app.use("/receipts", express.static("receipts"))`. A purely static site can
166
- copy `receipts/` into its public directory at build time.
222
+ Prefer the one-call attach helpers; each serves the receipts directory AND
223
+ the bundled browser assets, and returns a `snippet` to render once in the
224
+ layout (it mounts the signal into `header`, falling back to `body`):
225
+
226
+ ```python
227
+ # Flask
228
+ from tamper_signal.flask_ext import attach
229
+ signal = attach(app, receipts_dir="receipts/") # then: {{ signal.snippet | safe }}
230
+
231
+ # FastAPI
232
+ from tamper_signal.fastapi_ext import attach
233
+ signal = attach(app, receipts_dir="receipts/")
234
+ ```
235
+
236
+ ```js
237
+ // Express (or any Connect-style router)
238
+ import { tamperSignal } from "tamper-signal/express";
239
+ const signal = tamperSignal(app, { receiptsDir: "receipts/" });
240
+ // serve signal.snippet once in your layout
241
+ ```
242
+
243
+ Next.js: copy `receipts/` into `public/receipts/` as part of the pipeline
244
+ run (the simplest correct path; receipts are plain files), then mount with
245
+ `<TamperSignal chain="/receipts/chain.json" />` from `tamper-signal/react`
246
+ in a client component, or the `<tamper-signal>` element in any layout.
247
+
248
+ Streamlit: `from tamper_signal.streamlit_ext import signal, verified_dataframe`.
249
+ These verify SERVER-SIDE with the Python verifier and the pill says so;
250
+ Streamlit cannot serve the receipts directory for the in-browser walk, and
251
+ faking the stronger claim would violate rule 1.
252
+
253
+ Every attach helper also serves the verification console at
254
+ `<assets_prefix>/console` (e.g. `/tamper-signal/console`): the chain as an
255
+ inspectable pipeline with the break pinned at the severed link, for the
256
+ dashboard's builder and for auditors. Mention it to the user when handing
257
+ over; it is the page to open when the light is anything but green.
258
+
259
+ Manual fallback when no helper fits: serve the directory statically (Flask
260
+ `static_folder="receipts"`, FastAPI `StaticFiles`, Express
261
+ `express.static("receipts")`), or copy `receipts/` into the public dir of a
262
+ static site at build time. For local development, `receipts serve` serves
263
+ the directory on localhost with CORS open and caching off.
167
264
 
168
265
  Placement: the right end of the host header, after the host's own controls.
169
266
  The pill is intentionally dark and mono; do not restyle it to match the host
@@ -189,15 +286,44 @@ under `control_totals.numeric_sums` / `null_counts`.
189
286
  ## 8. The Data tab (when asked for table UI or views)
190
287
 
191
288
  The project's stance: a dashboard built on verified data should show the
192
- verified table, not just charts. If the user asks for the table UI, add a
193
- "Data" tab next to the charts that renders the final stage's output (the same
194
- file `--data` verifies), labeled with the current verdict. The design
195
- reference is `designs/03-data-tab.html` with notes in `designs/03-NOTES.md`;
196
- there is no packaged component yet, so build it in the host's own stack and
197
- say so honestly.
289
+ verified table, not just charts. Two steps:
290
+
291
+ 1. After the pipeline runs, export the canonical table document:
292
+
293
+ ```bash
294
+ receipts export --chain receipts/chain.json --data path/to/dashboard_data.xlsx
295
+ ```
296
+
297
+ This writes `receipts/table.json` and refuses if the data does not match
298
+ the final receipt (the Data tab only ever shows attested data). Re-run it
299
+ whenever the pipeline runs, or the tab will honestly report a stale table.
300
+
301
+ 2. Mount the table (vendor `badge/table.js` beside badge.js, or import
302
+ `tamper-signal/table`):
303
+
304
+ ```html
305
+ <script type="module">
306
+ import { mountReceiptTable } from "/static/table.js";
307
+ mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
308
+ </script>
309
+ ```
310
+
311
+ The component re-hashes the served document in the viewer's browser and
312
+ compares it against the final receipt, so VERIFIED means the rows on screen
313
+ are byte-for-byte the attested data. It renders its own states: green, yellow
314
+ with caveats, chain broken (with the moved columns flagged), and "not the
315
+ attested data" when table.json is stale or edited. Design reference:
316
+ `designs/03-data-tab.html`.
198
317
 
199
318
  ## 9. Verify your work before reporting done
200
319
 
320
+ Run `receipts doctor` first: it checks the Python version, that the private
321
+ key exists and is not tracked by git, that .gitignore covers it, and that the
322
+ chain verifies; pass `--url http://localhost:PORT/chain.json` to also confirm
323
+ the receipts directory is reachable over HTTP. Every failure prints its fix.
324
+ Exit 0 means the integration is healthy. Then confirm the user-visible
325
+ surfaces:
326
+
201
327
  1. `receipts verify receipts/chain.json --pub keys/signing.pub` exits 0.
202
328
  2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
203
329
  the per-stage popover).
@@ -233,3 +359,5 @@ say so honestly.
233
359
  | `examples/chains/` | Committed known-good and known-broken demo chains |
234
360
  | `designs/` | Working HTML mockups for the signal, console, and Data tab |
235
361
  | `docs/MESSAGING.md` | Copy rules; the source of truth for any words you write |
362
+ | `docs/solutions/` | Documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`); relevant when working in documented areas |
363
+ | `CONCEPTS.md` | Shared domain vocabulary (entities, named processes, status concepts); relevant when orienting to the codebase |
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  # The light is green, the data is clean.
4
4
 
5
+ [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/1.2.0)](https://socket.dev/npm/package/tamper-signal/overview/1.2.0) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.2.0)](https://socket.dev/pypi/package/tamper-signal/overview/1.2.0) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+
5
7
  Your social team exports a month of TikTok performance data. Someone vibe-codes a dashboard on top of it with an AI assistant in an afternoon. It looks great. Then a transform silently drops 22 rows, or the model hallucinates an aggregation, and the numbers in front of your boss are wrong. Nothing in that workflow catches it. This is the missing verification layer: every stage of the pipeline signs a receipt for what went in and what came out, and one command (or a badge on the dashboard itself) tells you whether the chain is intact, or exactly where it broke and by how much.
6
8
 
7
- **Live demo:** [welovejeff.github.io/tamper-evident-verification](https://welovejeff.github.io/tamper-evident-verification/) re-verifies a real committed receipt chain in your browser: swap in a tampered chain or an untrusted key and watch the light catch it.
9
+ **Live demo:** [tampersignal.com](https://tampersignal.com/) re-verifies a real committed receipt chain in your browser: swap in a tampered chain or an untrusted key and watch the light catch it.
8
10
 
9
11
  **Pointing a coding agent at this repo?** `AGENTS.md` is the full integration runbook: install, keygen, ingest, wrap transforms, mount the signal, verify. Tell your agent "add tamper signal" and it will find it.
10
12
 
@@ -26,15 +28,15 @@ The badge and the verifier reduce the whole chain to one state:
26
28
 
27
29
  *The inline status light: a small dark instrument in your dashboard's header. When the chain breaks, it reaches into the page and flags the exact metric that no longer descends from the source.*
28
30
 
29
- Honest status: all three verdicts are implemented in `receipts verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The Data tab and console animations in this README are design previews of later interface tiers, built from the working mockups in `designs/`. The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
31
+ Honest status: all three verdicts are implemented in `receipts verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The animations in this README are renders of the design mockups in `designs/`; the interfaces they depict have since shipped (`badge/light.js`, `badge/table.js`, `badge/console.js`). The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
30
32
 
31
33
  ## 60-second quickstart
32
34
 
33
- Python 3.11+. Open source (MIT), `pip`-installable.
35
+ Python 3.11+. Open source (MIT).
34
36
 
35
37
  ```bash
36
- git clone <this repo> && cd tamper-evident-verification
37
- pip install -e .
38
+ pip install tamper-signal
39
+ git clone https://github.com/welovejeff/tamper-evident-verification && cd tamper-evident-verification
38
40
  receipts demo
39
41
  ```
40
42
 
@@ -43,12 +45,14 @@ receipts demo
43
45
  ## CLI
44
46
 
45
47
  ```bash
46
- receipts keygen --out keys/
48
+ receipts init # scaffold: keys, .gitignore safety, receipts dir (idempotent)
47
49
  receipts ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
48
50
  receipts verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
51
+ receipts doctor # integration self-check with actionable fixes
52
+ receipts serve # serve receipts/ on localhost with CORS (dev only)
49
53
  ```
50
54
 
51
- `ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals.
55
+ `ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals. `--json` emits a structured verdict (schema in `AGENTS.md`) for CI and coding agents.
52
56
 
53
57
  Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
54
58
 
@@ -136,14 +140,26 @@ The pill expands to a popover: the per-stage table when green, the caveat list w
136
140
 
137
141
  Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `theme: "light"` so the pill stays the one foreign object on a dark host. `receipts demo` serves a live three-state example at `http://localhost:8000/badge/light.html`.
138
142
 
143
+ One-call framework helpers serve the receipts directory and the browser files together and hand back the mounting snippet: `tamper_signal.flask_ext.attach(app)`, `tamper_signal.fastapi_ext.attach(app)`, and `tamperSignal(app)` from `tamper-signal/express`. Streamlit apps get a server-side-verified pill and table caption via `tamper_signal.streamlit_ext` (labeled as the weaker check it is).
144
+
139
145
  ## Dashboards should show their work
140
146
 
141
147
  We think any dashboard built on verified data should let you see the data. Not a tooltip, not an export-on-request: a Data tab, right next to the charts, showing the raw verified table the pretty numbers came from. If the chain is intact and the light is green, there is no reason to hide the rows, and if you find yourself wanting to hide them, that's worth sitting with. A chart asks you to believe; a table lets you check. Green light, open table: that's the whole standard.
142
148
 
149
+ It ships: `receipts export` writes the canonical table document next to the chain (refusing data that does not match the final receipt), and `mountReceiptTable(el, "/receipts/chain.json")` from `badge/table.js` (npm: `tamper-signal/table`) renders it after re-hashing it in the viewer's browser against the final receipt. VERIFIED means the rows on screen are byte-for-byte the attested data; a stale or edited table.json renders dimmed under a "not the attested data" strip, and a broken chain flags the columns that moved at the break. Live demo: `badge/table.html`.
150
+
143
151
  ![The Data tab: the dashboard flips to a dark raw-table view where a broken chain is localized to the views column](docs/media/data-tab.gif)
144
152
 
145
153
  *Design preview: install the verification layer and your dashboard grows a Data tab. When the chain breaks, the break is localized to the column and total that no longer verify, right in the table.*
146
154
 
155
+ ## The console
156
+
157
+ The light answers "is it fine?"; the console answers "where, exactly, and by how much?" `mountReceiptConsole(el, "/receipts/chain.json")` from `badge/console.js` (npm: `tamper-signal/console`) renders the chain as an inspectable pipeline: links carry the hash they proved, a break severs the link with the break card pinned at it, coverage gaps appear as ghost nodes at their position, and the event log mirrors `receipts verify` line for line. Every attach helper also serves it ready-made at `/tamper-signal/console`. Live demo: `badge/console.html`.
158
+
159
+ ![The verification console: a pipeline of signed receipts where a tampered stage severs the chain at the exact link](docs/media/console.gif)
160
+
161
+ *The verification console: calm when green, surgical when red.*
162
+
147
163
  ## What this proves, and what it doesn't
148
164
 
149
165
  This proves **continuity, not correctness**. It can't tell you the data is right, but it can prove nobody changed it. The chain shows the dashboard numbers descend from the ingested export through a known sequence of code, and it locates the exact stage where a number changed unexpectedly. If the source export is itself wrong, the chain faithfully verifies wrong numbers. It is not a data-quality tool.
@@ -154,11 +170,7 @@ Also worth knowing: the signing key lives on your machine, and today that local
154
170
 
155
171
  - **Richer yellow taxonomy.** Yellow currently detects coverage gaps, unrecognized signing keys, and opt-in totals drift. Distinct severities and smarter drift heuristics are open questions (see `designs/01-NOTES.md`).
156
172
  - **External anchoring.** Sigstore transparency logs or RFC 3161 timestamps, so a chain can't be silently re-signed after the fact. The attachment points are already marked `FUTURE:` in `tamper_signal/keys.py` and `tamper_signal/receipts.py`.
157
- - **Verification console.** A devtools-for-data window: the receipt chain as an inspectable pipeline, an event log of verify runs, and the break pinned at the severed link.
158
-
159
- ![The verification console: a pipeline of signed receipts where a tampered stage severs the chain at the exact link](docs/media/console.gif)
160
173
 
161
- *Design preview of the verification console: calm when green, surgical when red.*
162
174
 
163
175
  ## Relation to OpenLineage, dbt, and Great Expectations
164
176
 
package/badge/badge.js CHANGED
@@ -24,7 +24,7 @@ export const SHORT = (h) => (h && h.length > 10 ? `${h.slice(0, 4)}...${h.slice(
24
24
  // --- Canonical JSON, byte-identical to tamper_signal/canonical.py's JCS output. ---
25
25
  // Leaves are strings, integers, booleans, or null (no floats). Object keys are
26
26
  // sorted; strings use JSON.stringify, whose escaping matches the Python side.
27
- function canonicalize(value) {
27
+ export function canonicalize(value) {
28
28
  if (value === null) return "null";
29
29
  const t = typeof value;
30
30
  if (t === "boolean") return value ? "true" : "false";
@@ -0,0 +1,350 @@
1
+ // The verification console: devtools-for-data. The inline light answers "is
2
+ // it fine?"; this window answers "where, exactly, and by how much?" Design
3
+ // reference: designs/02-debug-window.html, notes in designs/02-NOTES.md.
4
+ //
5
+ // mountReceiptConsole(containerEl, chainUrl, opts?)
6
+ // Runs the same in-browser verification as the badge/signal/table
7
+ // (verifyReceipts in badge.js) and renders:
8
+ // - the lamp: state-coded motion (slow breathing green, brisk yellow,
9
+ // sharp double-blink red; none under prefers-reduced-motion)
10
+ // - the pipeline: one card per receipt, links carrying the hash they
11
+ // proved; a break severs the link and pins a break card at the break
12
+ // with expected/found chips and the totals delta; a coverage-gap caveat
13
+ // renders as a dashed amber link to a ghost node at the gap's position
14
+ // - the inspector: click a card for its receipt (kv + raw JSON)
15
+ // - the event log: one entry per verify run, mirroring the CLI verifier
16
+ //
17
+ // opts: pubKey (trusted key hex), watch (re-verify every N ms), warnDrift
18
+ // Returns { el, ready, refresh(), destroy() }.
19
+
20
+ import {
21
+ verifyReceipts,
22
+ SHORT,
23
+ outputHashOf,
24
+ inputHashOf,
25
+ totalsOf,
26
+ stageNameOf,
27
+ } from "./badge.js";
28
+
29
+ function el(tag, props = {}, children = []) {
30
+ const node = document.createElement(tag);
31
+ Object.assign(node, props);
32
+ for (const child of [].concat(children)) {
33
+ if (child == null) continue;
34
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
35
+ }
36
+ return node;
37
+ }
38
+
39
+ function injectConsoleStyles() {
40
+ if (document.getElementById("tamper-console-styles")) return;
41
+ const css = `
42
+ .tc{--bg:#0b0f14;--panel:#11161d;--border:#1f2937;--chrome:#161d26;--text:#e5e7eb;
43
+ --dim:#8b98a5;--faint:#3d4854;--row:#18202b;--green:#34d399;--red:#f87171;
44
+ --amber:#fbbf24;--cyan:#67e8f9;
45
+ font-family:ui-monospace,'SF Mono',Menlo,Monaco,'Cascadia Code',monospace;
46
+ background:var(--bg);border:1px solid var(--border);border-radius:12px;
47
+ color:var(--text);overflow:hidden;font-size:12px}
48
+ .tc .tc-head{display:flex;align-items:center;gap:12px;background:var(--chrome);
49
+ border-bottom:1px solid var(--border);padding:12px 16px}
50
+ .tc .tc-lamp{width:16px;height:16px;border-radius:50%;flex:none;background:var(--faint)}
51
+ .tc[data-state="green"] .tc-lamp{background:var(--green);box-shadow:0 0 10px 1px rgba(52,211,153,0.7);
52
+ animation:tc-breathe 4s ease-in-out infinite}
53
+ .tc[data-state="yellow"] .tc-lamp{background:var(--amber);box-shadow:0 0 10px 1px rgba(251,191,36,0.7);
54
+ animation:tc-breathe 1.6s ease-in-out infinite}
55
+ .tc[data-state="red"] .tc-lamp{background:var(--red);box-shadow:0 0 10px 1px rgba(248,113,113,0.7);
56
+ animation:tc-blink 1.8s steps(1) infinite}
57
+ @keyframes tc-breathe{0%,100%{opacity:1}50%{opacity:0.55}}
58
+ @keyframes tc-blink{0%,12%,24%{opacity:1}6%,18%{opacity:0.25}30%,100%{opacity:1}}
59
+ @media (prefers-reduced-motion: reduce){.tc .tc-lamp{animation:none !important}}
60
+ .tc .tc-title{color:var(--dim);letter-spacing:1.2px;font-size:11px}
61
+ .tc .tc-verdict{font-weight:700}
62
+ .tc[data-state="green"] .tc-verdict{color:var(--green)}
63
+ .tc[data-state="yellow"] .tc-verdict{color:var(--amber)}
64
+ .tc[data-state="red"] .tc-verdict{color:var(--red)}
65
+ .tc[data-state="unverifiable"] .tc-verdict{color:var(--dim)}
66
+ .tc .tc-time{margin-left:auto;color:var(--faint);font-size:10px}
67
+ .tc .tc-reverify{font:10px inherit;font-family:inherit;color:var(--cyan);background:none;
68
+ border:1px solid var(--border);border-radius:6px;padding:5px 10px;cursor:pointer}
69
+ .tc .tc-reverify:hover{border-color:var(--faint)}
70
+ .tc .tc-caveats{padding:8px 16px;font-size:11px;color:var(--amber);
71
+ border-bottom:1px solid var(--border);background:rgba(251,191,36,0.06)}
72
+ .tc .tc-rail-wrap{padding:18px 16px 6px;overflow-x:auto}
73
+ .tc .tc-rail{display:flex;align-items:flex-start;gap:0;min-width:max-content}
74
+ .tc .tc-node{border:1px solid var(--border);border-radius:10px;background:var(--panel);
75
+ padding:10px 12px;min-width:148px;cursor:pointer}
76
+ .tc .tc-node:hover{border-color:var(--faint)}
77
+ .tc .tc-node.tc-active{border-color:var(--cyan)}
78
+ .tc .tc-node.tc-ghost{border-style:dashed;border-color:rgba(251,191,36,0.55);
79
+ color:var(--amber);cursor:default;background:rgba(251,191,36,0.04)}
80
+ .tc .tc-node .n-name{font-weight:700;font-size:12px}
81
+ .tc .tc-node .n-kind{color:var(--faint);font-size:9px;letter-spacing:0.6px;text-transform:uppercase}
82
+ .tc .tc-node .n-row{color:var(--dim);font-size:10.5px;margin-top:5px}
83
+ .tc .tc-node .hash{color:var(--cyan)}
84
+ .tc .tc-node .ok{color:var(--green)}
85
+ .tc .tc-link{display:flex;flex-direction:column;align-items:center;justify-content:center;
86
+ padding:0 4px;min-width:96px;align-self:stretch}
87
+ .tc .tc-link .l-line{font-size:11px;white-space:nowrap;color:var(--green)}
88
+ .tc .tc-link .l-hash{font-size:9.5px;color:var(--faint);margin-top:2px}
89
+ .tc .tc-link.tc-broken .l-line{color:var(--red);font-weight:700}
90
+ .tc .tc-link.tc-gap .l-line{color:var(--amber)}
91
+ .tc .tc-break{margin:10px 16px 4px;border:1px solid rgba(248,113,113,0.4);
92
+ background:rgba(180,35,24,0.10);border-radius:9px;padding:10px 14px;font-size:11px}
93
+ .tc .tc-break h4{margin:0 0 6px;color:var(--red);font-size:11px}
94
+ .tc .tc-break .kv{display:flex;gap:10px;padding:1px 0}
95
+ .tc .tc-break .kv b{color:var(--faint);font-weight:400;width:70px;flex:none}
96
+ .tc .tc-break .bad{color:var(--red)}
97
+ .tc .tc-break .hash{color:var(--cyan)}
98
+ .tc .tc-break .delta{margin-top:6px;color:var(--red)}
99
+ .tc .tc-inspect{margin:10px 16px;border:1px solid var(--border);border-radius:9px;
100
+ background:var(--panel);padding:10px 14px;font-size:11px;display:none}
101
+ .tc .tc-inspect.open{display:block}
102
+ .tc .tc-inspect .kv{display:flex;gap:10px;padding:1px 0}
103
+ .tc .tc-inspect .kv b{color:var(--faint);font-weight:400;width:92px;flex:none}
104
+ .tc .tc-inspect .hash{color:var(--cyan)}
105
+ .tc .tc-inspect summary{cursor:pointer;color:var(--faint);font-size:10px;margin-top:6px}
106
+ .tc .tc-inspect pre{margin:8px 0 0;padding:10px;background:var(--bg);
107
+ border:1px solid var(--border);border-radius:7px;font-size:10px;line-height:1.5;
108
+ overflow:auto;max-height:240px}
109
+ .tc .tc-log{border-top:1px solid var(--border);background:#07090d;
110
+ padding:10px 16px;max-height:170px;overflow-y:auto}
111
+ .tc .tc-log .l-head{color:var(--faint);font-size:9.5px;letter-spacing:1px;margin-bottom:6px}
112
+ .tc .tc-log .entry{font-size:10.5px;line-height:1.7;color:var(--dim);white-space:pre-wrap}
113
+ .tc .tc-log .t-green{color:var(--green)}
114
+ .tc .tc-log .t-amber{color:var(--amber)}
115
+ .tc .tc-log .t-red{color:var(--red)}
116
+ .tc .tc-log .t-faint{color:var(--faint)}`;
117
+ document.head.appendChild(el("style", { id: "tamper-console-styles", textContent: css }));
118
+ }
119
+
120
+ // Parse "coverage gap: receipt numbering jumps 001 -> 003; ..." caveats into
121
+ // the rail position (insert the ghost after the receipt whose prefix is the
122
+ // jump's left side). Returns a map from receipt-array index to gap label.
123
+ function gapPositions(result) {
124
+ const gaps = new Map();
125
+ const names = result.chain?.receipts ?? [];
126
+ for (const caveat of result.caveats ?? []) {
127
+ const match = /numbering jumps (\d{3}) -> (\d{3})/.exec(caveat);
128
+ if (!match) continue;
129
+ const after = names.findIndex((n) => String(n).startsWith(match[1] + "_"));
130
+ if (after !== -1) gaps.set(after, `between ${match[1]} and ${match[2]}`);
131
+ }
132
+ return gaps;
133
+ }
134
+
135
+ // Mirror the CLI verifier's report shape for the event log.
136
+ function cliLines(result) {
137
+ const out = [];
138
+ if (result.state === "unverifiable") {
139
+ out.push(["t-faint", `! UNVERIFIABLE: ${result.reason}`]);
140
+ return out;
141
+ }
142
+ const rows = result.receipts.length
143
+ ? totalsOf(result.receipts[result.receipts.length - 1]).row_count ?? "?"
144
+ : "?";
145
+ if (result.state === "green") {
146
+ out.push(["t-green", `✓ CHAIN INTACT: ${result.receipts.length} receipts, ${result.transforms} transforms, final row_count ${rows}`]);
147
+ } else if (result.state === "yellow") {
148
+ out.push(["t-amber", `⚠ CHAIN VERIFIES, WITH CAVEATS: ${result.receipts.length} receipts, ${result.transforms} transforms, final row_count ${rows}`]);
149
+ for (const caveat of result.caveats) out.push(["", ` - ${caveat}`]);
150
+ out.push(["t-amber", " A human should look."]);
151
+ } else {
152
+ const lr = result.linkResult;
153
+ if (lr && lr.ok === false) {
154
+ out.push(["t-red", `✗ CHAIN BROKEN at link ${lr.brokenAt - 1} -> ${lr.brokenAt} (${lr.brokenStage})`]);
155
+ out.push(["", ` expected input hash ${SHORT(lr.expected)} (output of ${stageNameOf(result.receipts[lr.brokenAt - 1])})`]);
156
+ out.push(["", ` found input hash ${SHORT(lr.found)}`]);
157
+ if (lr.delta?.length) out.push(["", ` Control totals delta vs upstream: ${lr.delta.join(", ")}`]);
158
+ } else {
159
+ out.push(["t-red", `✗ ${result.reason.toUpperCase()}`]);
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ export function mountReceiptConsole(containerEl, chainUrl, opts) {
166
+ opts = opts || {};
167
+ injectConsoleStyles();
168
+
169
+ const lamp = el("span", { className: "tc-lamp" });
170
+ const verdictEl = el("span", { className: "tc-verdict" }, "VERIFYING");
171
+ const time = el("span", { className: "tc-time" });
172
+ const reverify = el("button", { className: "tc-reverify", type: "button" }, "re-verify");
173
+ const head = el("div", { className: "tc-head" }, [
174
+ lamp,
175
+ el("span", { className: "tc-title" }, "TAMPER SIGNAL · CONSOLE"),
176
+ verdictEl,
177
+ time,
178
+ reverify,
179
+ ]);
180
+ const caveatStrip = el("div");
181
+ const railWrap = el("div", { className: "tc-rail-wrap" });
182
+ const breakSlot = el("div");
183
+ const inspect = el("div", { className: "tc-inspect" });
184
+ const logEntries = el("div");
185
+ const log = el("div", { className: "tc-log" }, [
186
+ el("div", { className: "l-head" }, "EVENT LOG · mirrors `receipts verify`"),
187
+ logEntries,
188
+ ]);
189
+ const root = el("div", { className: "tc" }, [head, caveatStrip, railWrap, breakSlot, inspect, log]);
190
+ root.dataset.state = "unverifiable";
191
+ containerEl.appendChild(root);
192
+
193
+ let destroyed = false;
194
+ let timer = null;
195
+ let activeNode = null;
196
+
197
+ function inspectReceipt(receipt, card) {
198
+ if (activeNode) activeNode.classList.remove("tc-active");
199
+ activeNode = card;
200
+ card.classList.add("tc-active");
201
+ inspect.textContent = "";
202
+ inspect.classList.add("open");
203
+ const totals = totalsOf(receipt);
204
+ const kv = (label, value) => el("div", { className: "kv" }, [el("b", {}, label), el("span", {}, value)]);
205
+ inspect.append(
206
+ el("div", { className: "kv" }, [
207
+ el("b", {}, "stage"),
208
+ el("span", {}, [stageNameOf(receipt), " ", el("span", { className: "hash" }, SHORT(outputHashOf(receipt)))]),
209
+ ]),
210
+ kv("kind", receipt.kind ?? "?"),
211
+ kv("created", receipt.created_at ?? "?"),
212
+ kv("rows out", String(totals.row_count ?? "?")),
213
+ );
214
+ if (receipt.kind === "transform_receipt") {
215
+ inspect.append(
216
+ kv("code file", receipt.transform?.code_file ?? "?"),
217
+ kv("code hash", SHORT(receipt.transform?.code_hash)),
218
+ );
219
+ }
220
+ if (receipt.signature?.key_fingerprint) {
221
+ inspect.append(kv("signed by", receipt.signature.key_fingerprint));
222
+ }
223
+ inspect.append(
224
+ el("details", {}, [
225
+ el("summary", {}, "raw receipt JSON"),
226
+ el("pre", {}, JSON.stringify(receipt, null, 2)),
227
+ ])
228
+ );
229
+ }
230
+
231
+ function render(result, durationMs) {
232
+ root.dataset.state = result.state;
233
+ verdictEl.textContent = {
234
+ green: "GREEN · ALL LINKS VERIFIED",
235
+ yellow: "YELLOW · VERIFIED WITH CAVEATS",
236
+ red: "RED · CHAIN BROKEN",
237
+ unverifiable: "UNVERIFIED",
238
+ }[result.state];
239
+ if (result.verifiedAt) {
240
+ time.textContent = `verified ${result.verifiedAt.slice(11, 19)}Z`;
241
+ }
242
+
243
+ // Caveat strip (yellow only; gaps are also drawn on the rail).
244
+ caveatStrip.textContent = "";
245
+ if (result.state === "yellow") {
246
+ caveatStrip.appendChild(
247
+ el("div", { className: "tc-caveats" }, "⚠ " + result.caveats.join(" · "))
248
+ );
249
+ }
250
+
251
+ // Pipeline rail.
252
+ railWrap.textContent = "";
253
+ breakSlot.textContent = "";
254
+ inspect.classList.remove("open");
255
+ activeNode = null;
256
+ if (result.state !== "unverifiable" && result.receipts?.length) {
257
+ const rail = el("div", { className: "tc-rail" });
258
+ const gaps = gapPositions(result);
259
+ const lr = result.linkResult;
260
+ result.receipts.forEach((receipt, i) => {
261
+ if (i > 0) {
262
+ const broken = lr && lr.ok === false && lr.brokenAt === i;
263
+ const link = el("div", { className: `tc-link${broken ? " tc-broken" : ""}` }, [
264
+ el("span", { className: "l-line" }, broken ? "──✗⚡✗──" : "───▶"),
265
+ el("span", { className: "l-hash" },
266
+ broken ? "link severed" : `carries ${SHORT(inputHashOf(receipt))}`),
267
+ ]);
268
+ rail.appendChild(link);
269
+ }
270
+ const totals = totalsOf(receipt);
271
+ const card = el("div", { className: "tc-node" }, [
272
+ el("div", { className: "n-kind" }, receipt.kind === "source_manifest" ? "source" : "transform"),
273
+ el("div", { className: "n-name" }, stageNameOf(receipt)),
274
+ el("div", { className: "n-row" }, [
275
+ "out ", el("span", { className: "hash" }, SHORT(outputHashOf(receipt))),
276
+ ]),
277
+ el("div", { className: "n-row" }, [
278
+ `rows ${totals.row_count ?? "?"} · sig `,
279
+ el("span", { className: "ok" }, result.signaturesValid ? "✓" : "?"),
280
+ ]),
281
+ ]);
282
+ card.addEventListener("click", () => inspectReceipt(receipt, card));
283
+ rail.appendChild(card);
284
+
285
+ if (gaps.has(i)) {
286
+ rail.appendChild(el("div", { className: "tc-link tc-gap" }, [
287
+ el("span", { className: "l-line" }, "┄┄?┄┄"),
288
+ el("span", { className: "l-hash" }, "coverage gap"),
289
+ ]));
290
+ rail.appendChild(el("div", { className: "tc-node tc-ghost" }, [
291
+ el("div", { className: "n-kind" }, "missing"),
292
+ el("div", { className: "n-name" }, "no receipt emitted"),
293
+ el("div", { className: "n-row" }, gaps.get(i)),
294
+ ]));
295
+ }
296
+ });
297
+ railWrap.appendChild(rail);
298
+
299
+ // Break card pinned at the break.
300
+ if (lr && lr.ok === false) {
301
+ breakSlot.appendChild(el("div", { className: "tc-break" }, [
302
+ el("h4", {}, `✗ break at link ${lr.brokenAt - 1} -> ${lr.brokenAt} (${lr.brokenStage})`),
303
+ el("div", { className: "kv" }, [
304
+ el("b", {}, "expected"),
305
+ el("span", {}, [el("span", { className: "hash" }, SHORT(lr.expected)),
306
+ ` (output of ${stageNameOf(result.receipts[lr.brokenAt - 1])}, signed)`]),
307
+ ]),
308
+ el("div", { className: "kv" }, [
309
+ el("b", {}, "found"),
310
+ el("span", { className: "bad" }, SHORT(lr.found)),
311
+ ]),
312
+ lr.delta?.length
313
+ ? el("div", { className: "delta" }, `totals delta vs upstream: ${lr.delta.join(" · ")}`)
314
+ : null,
315
+ ]));
316
+ }
317
+ }
318
+
319
+ // Event log entry, newest first.
320
+ const stamp = (result.verifiedAt ?? new Date().toISOString()).slice(11, 19);
321
+ const entry = el("div", { className: "entry" });
322
+ entry.appendChild(el("span", { className: "t-faint" }, `${stamp}Z $ verify (${durationMs}ms)\n`));
323
+ for (const [cls, text] of cliLines(result)) {
324
+ entry.appendChild(el("span", { className: cls }, text + "\n"));
325
+ }
326
+ logEntries.prepend(entry);
327
+ }
328
+
329
+ async function refresh() {
330
+ const started = performance.now();
331
+ const result = await verifyReceipts(chainUrl, opts.pubKey, { warnDrift: opts.warnDrift });
332
+ if (!destroyed) render(result, Math.round(performance.now() - started));
333
+ return result;
334
+ }
335
+
336
+ reverify.addEventListener("click", refresh);
337
+ const ready = refresh();
338
+ if (opts.watch) timer = setInterval(refresh, Math.max(1000, Number(opts.watch) || 0));
339
+
340
+ return {
341
+ el: root,
342
+ ready,
343
+ refresh,
344
+ destroy() {
345
+ destroyed = true;
346
+ if (timer) clearInterval(timer);
347
+ if (root.parentNode) root.parentNode.removeChild(root);
348
+ },
349
+ };
350
+ }
package/badge/table.js ADDED
@@ -0,0 +1,255 @@
1
+ // The verified Data tab: render the canonical table document written by
2
+ // `receipts export`, after proving in the viewer's browser that it IS the
3
+ // attested data. Design reference: designs/03-data-tab.html.
4
+ //
5
+ // mountReceiptTable(containerEl, chainUrl, tableUrl?, opts?)
6
+ // Verifies the chain (same core as the badge and the signal), fetches the
7
+ // canonical {"headers": [...], "rows": [...]} document, re-serializes it
8
+ // with the byte-identical JCS from badge.js, hashes it with Web Crypto
9
+ // SHA-256, and compares against the final receipt's output hash. Only when
10
+ // they match does the strip say VERIFIED: the rows on screen are then
11
+ // byte-for-byte the attested data, not a claim about it.
12
+ //
13
+ // tableUrl defaults to table.json next to chain.json. opts:
14
+ // maxRows rows rendered before the "show all" footer (default 500)
15
+ //
16
+ // Returns { el, ready, refresh(), destroy() }.
17
+ //
18
+ // Honesty notes baked into the states:
19
+ // - chain red: the table renders dimmed with a red strip naming the broken
20
+ // link; columns whose totals moved at the break are flagged.
21
+ // - table hash mismatch: red strip; the table is NOT the attested data and
22
+ // renders dimmed. This catches a stale or edited table.json.
23
+ // - chain yellow: amber strip with the caveats; the table still verified
24
+ // against the final receipt.
25
+ // - fetch/capability failure: grey UNVERIFIED strip, never the yellow color.
26
+
27
+ import {
28
+ verifyReceipts,
29
+ canonicalize,
30
+ outputHashOf,
31
+ totalsOf,
32
+ SHORT,
33
+ stageNameOf,
34
+ } from "./badge.js";
35
+
36
+ function el(tag, props = {}, children = []) {
37
+ const node = document.createElement(tag);
38
+ Object.assign(node, props);
39
+ for (const child of [].concat(children)) {
40
+ if (child == null) continue;
41
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
42
+ }
43
+ return node;
44
+ }
45
+
46
+ function injectTableStyles() {
47
+ if (document.getElementById("tamper-table-styles")) return;
48
+ const css = `
49
+ .tt{--tt-bg:#0b0f14;--tt-panel:#11161d;--tt-border:#1f2937;--tt-chrome:#161d26;
50
+ --tt-text:#e5e7eb;--tt-dim:#8b98a5;--tt-faint:#3d4854;--tt-row:#18202b;
51
+ --tt-green:#34d399;--tt-red:#f87171;--tt-amber:#fbbf24;--tt-cyan:#67e8f9;
52
+ font-family:ui-monospace,'SF Mono',Menlo,Monaco,'Cascadia Code',monospace;
53
+ background:var(--tt-bg);border:1px solid var(--tt-border);border-radius:12px;
54
+ color:var(--tt-text);overflow:hidden}
55
+ .tt .tt-strip{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px;
56
+ background:var(--tt-chrome);border-bottom:1px solid var(--tt-border);
57
+ padding:10px 14px;font-size:11px;letter-spacing:0.4px}
58
+ .tt .tt-mark{color:var(--tt-dim);letter-spacing:1.2px}
59
+ .tt .tt-verdict{font-weight:700}
60
+ .tt[data-state="green"] .tt-verdict{color:var(--tt-green)}
61
+ .tt[data-state="yellow"] .tt-verdict{color:var(--tt-amber)}
62
+ .tt[data-state="red"] .tt-verdict{color:var(--tt-red)}
63
+ .tt[data-state="unverifiable"] .tt-verdict{color:var(--tt-dim)}
64
+ .tt .tt-meta{color:var(--tt-dim)}
65
+ .tt .tt-hash{color:var(--tt-cyan)}
66
+ .tt .tt-note{padding:8px 14px;font-size:11px;color:var(--tt-amber);
67
+ border-bottom:1px solid var(--tt-border);background:rgba(251,191,36,0.06)}
68
+ .tt .tt-note.tt-bad{color:var(--tt-red);background:rgba(180,35,24,0.10)}
69
+ .tt .tt-scroll{overflow:auto;max-height:440px}
70
+ .tt table{border-collapse:collapse;width:100%;font-size:11.5px;line-height:1.5}
71
+ .tt th{position:sticky;top:0;background:var(--tt-panel);color:var(--tt-dim);
72
+ text-align:left;font-weight:600;padding:8px 12px;border-bottom:1px solid var(--tt-border);
73
+ white-space:nowrap}
74
+ .tt th.tt-flag{color:var(--tt-red)}
75
+ .tt td{padding:6px 12px;border-bottom:1px solid var(--tt-row);white-space:nowrap;color:var(--tt-text)}
76
+ .tt td.tt-null{color:var(--tt-faint);font-style:italic}
77
+ .tt td.tt-flagcol{background:rgba(248,113,113,0.06)}
78
+ .tt[data-trust="false"] .tt-scroll{opacity:0.45}
79
+ .tt .tt-foot{display:flex;align-items:center;gap:10px;border-top:1px solid var(--tt-border);
80
+ padding:8px 14px;font-size:10px;color:var(--tt-faint)}
81
+ .tt .tt-foot button{font:10px inherit;font-family:inherit;color:var(--tt-cyan);
82
+ background:none;border:1px solid var(--tt-border);border-radius:6px;
83
+ padding:4px 10px;cursor:pointer}
84
+ .tt .tt-foot button:hover{border-color:var(--tt-faint)}
85
+ .tt .tt-tagline{margin-left:auto;color:var(--tt-green)}`;
86
+ document.head.appendChild(el("style", { id: "tamper-table-styles", textContent: css }));
87
+ }
88
+
89
+ async function sha256Hex(bytes) {
90
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
91
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
92
+ }
93
+
94
+ // Columns whose totals moved at the broken link; the table flags them.
95
+ function brokenColumns(result) {
96
+ const { linkResult, receipts } = result;
97
+ if (!linkResult || linkResult.ok !== false || linkResult.brokenAt == null) return new Set();
98
+ const up = totalsOf(receipts[linkResult.brokenAt - 1]);
99
+ const down = totalsOf(receipts[linkResult.brokenAt]);
100
+ const cols = new Set();
101
+ for (const key of ["numeric_sums", "null_counts"]) {
102
+ const a = up[key] ?? {};
103
+ const b = down[key] ?? {};
104
+ for (const c of new Set([...Object.keys(a), ...Object.keys(b)])) {
105
+ if (a[c] !== b[c]) cols.add(c);
106
+ }
107
+ }
108
+ return cols;
109
+ }
110
+
111
+ export function mountReceiptTable(containerEl, chainUrl, tableUrl, opts) {
112
+ if (tableUrl && typeof tableUrl === "object") {
113
+ opts = tableUrl;
114
+ tableUrl = undefined;
115
+ }
116
+ opts = opts || {};
117
+ const maxRows = opts.maxRows ?? 500;
118
+ injectTableStyles();
119
+
120
+ const root = el("div", { className: "tt" });
121
+ const verdict = el("span", { className: "tt-verdict" }, "VERIFYING");
122
+ const meta = el("span", { className: "tt-meta" });
123
+ const strip = el("div", { className: "tt-strip" }, [
124
+ el("span", { className: "tt-mark" }, "🧾 DATA · TAMPER SIGNAL"),
125
+ verdict,
126
+ meta,
127
+ ]);
128
+ const noteSlot = el("div");
129
+ const scroll = el("div", { className: "tt-scroll" });
130
+ const foot = el("div", { className: "tt-foot" });
131
+ root.append(strip, noteSlot, scroll, foot);
132
+ containerEl.appendChild(root);
133
+ root.dataset.state = "unverifiable";
134
+
135
+ let destroyed = false;
136
+ let showAll = false;
137
+
138
+ function setStrip(state, verdictText, metaText, trusted) {
139
+ root.dataset.state = state;
140
+ root.dataset.trust = String(trusted);
141
+ verdict.textContent = verdictText;
142
+ meta.textContent = metaText || "";
143
+ }
144
+
145
+ function renderTable(doc, flagged) {
146
+ scroll.textContent = "";
147
+ foot.textContent = "";
148
+ const limit = showAll ? doc.rows.length : Math.min(maxRows, doc.rows.length);
149
+ const head = el("tr", {}, doc.headers.map((h) =>
150
+ el("th", { className: flagged.has(h) ? "tt-flag" : "" },
151
+ flagged.has(h) ? `⚠ ${h}` : h)
152
+ ));
153
+ const body = doc.rows.slice(0, limit).map((row) =>
154
+ el("tr", {}, row.map((cell, i) => {
155
+ const cls = [
156
+ cell === null ? "tt-null" : "",
157
+ flagged.has(doc.headers[i]) ? "tt-flagcol" : "",
158
+ ].join(" ").trim();
159
+ return el("td", { className: cls }, cell === null ? "null" : String(cell));
160
+ }))
161
+ );
162
+ scroll.appendChild(el("table", {}, [el("thead", {}, head), el("tbody", {}, body)]));
163
+
164
+ foot.appendChild(el("span", {}, `${limit.toLocaleString()} of ${doc.rows.length.toLocaleString()} rows`));
165
+ if (!showAll && doc.rows.length > maxRows) {
166
+ const btn = el("button", { type: "button" }, "show all");
167
+ btn.addEventListener("click", () => {
168
+ showAll = true;
169
+ renderTable(doc, flagged);
170
+ });
171
+ foot.appendChild(btn);
172
+ }
173
+ if (root.dataset.state === "green") {
174
+ foot.appendChild(el("span", { className: "tt-tagline" }, "green light, open table"));
175
+ }
176
+ }
177
+
178
+ async function refresh() {
179
+ const result = await verifyReceipts(chainUrl);
180
+ if (destroyed) return result;
181
+
182
+ const resolvedTableUrl = tableUrl || new URL("table.json", new URL(chainUrl, window.location.href));
183
+ let doc = null;
184
+ try {
185
+ const fetched = await fetch(resolvedTableUrl);
186
+ if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`);
187
+ doc = await fetched.json();
188
+ if (!Array.isArray(doc.headers) || !Array.isArray(doc.rows)) throw new Error("not a table document");
189
+ } catch (_e) {
190
+ doc = null;
191
+ }
192
+ if (destroyed) return result;
193
+
194
+ if (result.state === "unverifiable" || !doc) {
195
+ setStrip("unverifiable", "UNVERIFIED",
196
+ !doc ? "could not load table.json (run: receipts export)" : result.reason, false);
197
+ scroll.textContent = "";
198
+ foot.textContent = "";
199
+ if (doc) renderTable(doc, new Set());
200
+ return result;
201
+ }
202
+
203
+ // Hash the document exactly as Python signed it.
204
+ let tableHash = null;
205
+ try {
206
+ tableHash = await sha256Hex(
207
+ new TextEncoder().encode(canonicalize({ headers: doc.headers, rows: doc.rows }))
208
+ );
209
+ } catch (_e) {
210
+ setStrip("unverifiable", "UNVERIFIED", "could not hash the table in this browser", false);
211
+ renderTable(doc, new Set());
212
+ return result;
213
+ }
214
+ const finalReceipt = result.receipts[result.receipts.length - 1];
215
+ const attested = tableHash === outputHashOf(finalReceipt);
216
+ const rowsText = `${doc.rows.length.toLocaleString()} rows · sha ${SHORT(tableHash)}`;
217
+ noteSlot.textContent = "";
218
+
219
+ if (result.state === "red") {
220
+ const flagged = brokenColumns(result);
221
+ setStrip("red", "CHAIN BROKEN", rowsText, false);
222
+ noteSlot.appendChild(el("div", { className: "tt-note tt-bad" },
223
+ `✗ ${result.reason}. Totals fed by this chain cannot be trusted` +
224
+ (flagged.size ? `; flagged columns moved at the break: ${[...flagged].join(", ")}` : ".")));
225
+ renderTable(doc, flagged);
226
+ } else if (!attested) {
227
+ setStrip("red", "NOT THE ATTESTED DATA", rowsText, false);
228
+ noteSlot.appendChild(el("div", { className: "tt-note tt-bad" },
229
+ `✗ This table does not match the final receipt (${stageNameOf(finalReceipt)}): ` +
230
+ `expected ${SHORT(outputHashOf(finalReceipt))}, found ${SHORT(tableHash)}. ` +
231
+ "The file may be stale; re-run: receipts export"));
232
+ renderTable(doc, new Set());
233
+ } else if (result.state === "yellow") {
234
+ setStrip("yellow", "VERIFIED, WITH CAVEATS", rowsText, true);
235
+ noteSlot.appendChild(el("div", { className: "tt-note" },
236
+ "⚠ " + result.caveats.join(" · ")));
237
+ renderTable(doc, new Set());
238
+ } else {
239
+ setStrip("green", "VERIFIED", rowsText, true);
240
+ renderTable(doc, new Set());
241
+ }
242
+ return result;
243
+ }
244
+
245
+ const ready = refresh();
246
+ return {
247
+ el: root,
248
+ ready,
249
+ refresh,
250
+ destroy() {
251
+ destroyed = true;
252
+ if (root.parentNode) root.parentNode.removeChild(root);
253
+ },
254
+ };
255
+ }
@@ -0,0 +1,103 @@
1
+ // Express/Connect attach helper: serve the receipts directory and the browser
2
+ // surfaces in one call. Framework-free internally (plain (req, res, next)
3
+ // handlers), so it also works with Connect, Polka, or bare http routers that
4
+ // accept middleware.
5
+ //
6
+ // import express from "express";
7
+ // import { tamperSignal } from "tamper-signal/express";
8
+ //
9
+ // const app = express();
10
+ // const signal = tamperSignal(app, { receiptsDir: "receipts/" });
11
+ // // serve signal.snippet once in your layout, or add the script tag by hand
12
+ //
13
+ // The npm package ships the browser files; they are served from the package
14
+ // itself, side by side, so their relative imports resolve.
15
+
16
+ import { createReadStream, existsSync, statSync } from "node:fs";
17
+ import { basename, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const ASSET_NAMES = ["badge.js", "light.js", "element.js", "table.js", "console.js"];
21
+ const ASSET_DIR = fileURLToPath(new URL("../badge/", import.meta.url));
22
+
23
+ const TYPES = { ".json": "application/json", ".js": "text/javascript", ".pub": "text/plain" };
24
+
25
+ function send(res, path) {
26
+ const ext = path.slice(path.lastIndexOf("."));
27
+ res.statusCode = 200;
28
+ res.setHeader("Content-Type", TYPES[ext] ?? "application/octet-stream");
29
+ res.setHeader("Cache-Control", "no-store");
30
+ createReadStream(path).pipe(res);
31
+ }
32
+
33
+ // Middleware serving one directory, confined: no traversal, files only.
34
+ export function receiptsMiddleware({ receiptsDir = "receipts/" } = {}) {
35
+ const base = resolve(receiptsDir);
36
+ return function tamperSignalReceipts(req, res, next) {
37
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
38
+ const name = decodeURIComponent(req.url.split("?")[0].replace(/^\/+/, ""));
39
+ const target = resolve(base, name);
40
+ // Confine to the directory and its direct files (receipts are flat).
41
+ if (!name || target !== join(base, basename(name)) || !existsSync(target) || !statSync(target).isFile()) {
42
+ return next();
43
+ }
44
+ send(res, target);
45
+ };
46
+ }
47
+
48
+ // Middleware serving the bundled browser assets from the npm package.
49
+ export function assetsMiddleware() {
50
+ return function tamperSignalAssets(req, res, next) {
51
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
52
+ const name = decodeURIComponent(req.url.split("?")[0].replace(/^\/+/, ""));
53
+ if (!ASSET_NAMES.includes(name)) return next();
54
+ send(res, join(ASSET_DIR, name));
55
+ };
56
+ }
57
+
58
+ export function signalSnippet(chainUrl = "/receipts/chain.json", { assetsPrefix = "/tamper-signal", selector = "header" } = {}) {
59
+ return (
60
+ `<script type="module">` +
61
+ `import { mountTamperSignal } from "${assetsPrefix}/light.js"; ` +
62
+ `mountTamperSignal(document.querySelector(${JSON.stringify(selector)}) ?? document.body, ${JSON.stringify(chainUrl)});` +
63
+ `</script>`
64
+ );
65
+ }
66
+
67
+ export function consolePage(chainUrl = "/receipts/chain.json", { assetsPrefix = "/tamper-signal" } = {}) {
68
+ return `<!DOCTYPE html>
69
+ <html lang="en"><head><meta charset="utf-8" />
70
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
71
+ <title>Tamper Signal console</title>
72
+ <style>body{margin:0;background:#07090d;padding:24px}</style></head>
73
+ <body><div id="console"></div>
74
+ <script type="module">
75
+ import { mountReceiptConsole } from "${assetsPrefix}/console.js";
76
+ mountReceiptConsole(document.getElementById("console"), ${JSON.stringify(chainUrl)});
77
+ </script></body></html>
78
+ `;
79
+ }
80
+
81
+ export function tamperSignal(app, {
82
+ receiptsDir = "receipts/",
83
+ urlPrefix = "/receipts",
84
+ assetsPrefix = "/tamper-signal",
85
+ selector = "header",
86
+ } = {}) {
87
+ const chainUrl = `${urlPrefix}/chain.json`;
88
+ app.use(urlPrefix, receiptsMiddleware({ receiptsDir }));
89
+ app.use(assetsPrefix, function tamperSignalConsole(req, res, next) {
90
+ const name = decodeURIComponent(req.url.split("?")[0].replace(/^\/+/, ""));
91
+ if ((req.method !== "GET" && req.method !== "HEAD") || name !== "console") return next();
92
+ res.statusCode = 200;
93
+ res.setHeader("Content-Type", "text/html");
94
+ res.end(consolePage(chainUrl, { assetsPrefix }));
95
+ });
96
+ app.use(assetsPrefix, assetsMiddleware());
97
+ return {
98
+ chainUrl,
99
+ assetsPrefix,
100
+ consoleUrl: `${assetsPrefix}/console`,
101
+ snippet: signalSnippet(chainUrl, { assetsPrefix, selector }),
102
+ };
103
+ }
@@ -0,0 +1,72 @@
1
+ // The Express attach helper, exercised framework-free with mock req/res.
2
+
3
+ import assert from "node:assert/strict";
4
+ import { test } from "node:test";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { assetsMiddleware, receiptsMiddleware, signalSnippet, tamperSignal } from "../express.js";
9
+
10
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+ const intactDir = join(repoRoot, "examples", "chains", "intact");
12
+
13
+ function run(middleware, url) {
14
+ return new Promise((resolve) => {
15
+ const chunks = [];
16
+ let nexted = false;
17
+ const res = {
18
+ statusCode: 0,
19
+ headers: {},
20
+ setHeader(k, v) { this.headers[k] = v; },
21
+ write(c) { chunks.push(c); },
22
+ end(c) { if (c) chunks.push(c); resolve({ res: this, body: Buffer.concat(chunks.map(Buffer.from)).toString(), nexted }); },
23
+ emit() {},
24
+ on() {},
25
+ once() {},
26
+ removeListener() {},
27
+ };
28
+ middleware({ method: "GET", url }, res, () => { nexted = true; resolve({ res, body: "", nexted: true }); });
29
+ });
30
+ }
31
+
32
+ test("receiptsMiddleware serves chain.json with no-store", async () => {
33
+ const { res, body, nexted } = await run(receiptsMiddleware({ receiptsDir: intactDir }), "/chain.json");
34
+ assert.equal(nexted, false);
35
+ assert.equal(res.statusCode, 200);
36
+ assert.equal(res.headers["Content-Type"], "application/json");
37
+ assert.equal(res.headers["Cache-Control"], "no-store");
38
+ assert.ok(JSON.parse(body).receipts.length >= 1);
39
+ });
40
+
41
+ test("receiptsMiddleware refuses traversal and missing files", async () => {
42
+ assert.equal((await run(receiptsMiddleware({ receiptsDir: intactDir }), "/../../package.json")).nexted, true);
43
+ assert.equal((await run(receiptsMiddleware({ receiptsDir: intactDir }), "/nope.json")).nexted, true);
44
+ });
45
+
46
+ test("assetsMiddleware serves only the bundled surfaces", async () => {
47
+ const { res, body } = await run(assetsMiddleware(), "/light.js");
48
+ assert.equal(res.statusCode, 200);
49
+ assert.match(body, /mountTamperSignal/);
50
+ assert.equal((await run(assetsMiddleware(), "/evil.js")).nexted, true);
51
+ });
52
+
53
+ test("tamperSignal wires an app and returns the snippet", () => {
54
+ const uses = [];
55
+ const app = { use: (prefix, fn) => uses.push([prefix, fn.name]) };
56
+ const handle = tamperSignal(app, { receiptsDir: intactDir });
57
+ assert.deepEqual(uses.map(([p]) => p), ["/receipts", "/tamper-signal", "/tamper-signal"]);
58
+ assert.equal(handle.chainUrl, "/receipts/chain.json");
59
+ assert.match(handle.snippet, /mountTamperSignal/);
60
+ assert.match(signalSnippet(), /light\.js/);
61
+ });
62
+
63
+ test("tamperSignal serves the console page", async () => {
64
+ const handlers = [];
65
+ const app = { use: (prefix, fn) => handlers.push([prefix, fn]) };
66
+ const handle = tamperSignal(app, { receiptsDir: intactDir });
67
+ assert.equal(handle.consoleUrl, "/tamper-signal/console");
68
+ const consoleHandler = handlers.find(([p, f]) => p === "/tamper-signal" && f.name === "tamperSignalConsole")[1];
69
+ const { res, body } = await run(consoleHandler, "/console");
70
+ assert.equal(res.statusCode, 200);
71
+ assert.match(body, /mountReceiptConsole/);
72
+ });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "tamper-signal",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Signed receipts for vibe-coded data pipelines. Proves nobody changed your data, and shows the exact link if they did.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "homepage": "https://welovejeff.github.io/tamper-evident-verification/",
7
+ "homepage": "https://tampersignal.com/",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/welovejeff/tamper-evident-verification.git"
@@ -14,6 +14,9 @@
14
14
  "./light": "./badge/light.js",
15
15
  "./badge": "./badge/badge.js",
16
16
  "./element": "./badge/element.js",
17
+ "./table": "./badge/table.js",
18
+ "./console": "./badge/console.js",
19
+ "./express": "./node/express.js",
17
20
  "./react": "./badge/light-react.js"
18
21
  },
19
22
  "bin": {
@@ -24,6 +27,8 @@
24
27
  "badge/badge.js",
25
28
  "badge/light.js",
26
29
  "badge/element.js",
30
+ "badge/table.js",
31
+ "badge/console.js",
27
32
  "badge/light-react.js",
28
33
  "AGENTS.md"
29
34
  ],