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 +147 -19
- package/README.md +23 -11
- package/badge/badge.js +1 -1
- package/badge/console.js +350 -0
- package/badge/table.js +255 -0
- package/node/express.js +103 -0
- package/node/test/express.test.js +72 -0
- package/package.json +7 -2
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
|
|
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.
|
|
76
|
+
## 2. Scaffold the project (once)
|
|
74
77
|
|
|
75
78
|
```bash
|
|
76
|
-
receipts
|
|
79
|
+
receipts init
|
|
77
80
|
```
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
(raw
|
|
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.
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
[](https://pypi.org/project/tamper-signal/) [](https://www.npmjs.com/package/tamper-signal) [](https://socket.dev/npm/package/tamper-signal/overview/1.2.0) [](https://socket.dev/pypi/package/tamper-signal/overview/1.2.0) [](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:** [
|
|
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
|
|
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)
|
|
35
|
+
Python 3.11+. Open source (MIT).
|
|
34
36
|
|
|
35
37
|
```bash
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|

|
|
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
|
+

|
|
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
|
-

|
|
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";
|
package/badge/console.js
ADDED
|
@@ -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
|
+
}
|
package/node/express.js
ADDED
|
@@ -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.
|
|
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://
|
|
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
|
],
|