Releases: siaginw/sheetdiff
Release list
v0.6.3 — clean-pass polish
SheetDiff v0.6.3 — the clean-pass polish
The audit pass on v0.6.2 was the first clean pass — no CRITICAL, no HIGH findings. The SSRF guard survived a 42-probe attack on its hand-rolled hex parser, the richest-first ownership rule held on the real tracker in every Line List position, and the per-tab import suppression passed all divergent-timing fixtures. This release ships the pass's MEDIUM/LOW findings.
- ARCHITECTURE.md still taught the pre-0.6.2 ownership rule — "first tab in position order wins," the exact sentence describing the bug 0.6.2 fixed. A contributor refactoring from the doc would have reintroduced it. Rewritten to richest-first with the trade-off spelled out; also removed a phantom
stoppages.tsmodule entry, fixed a garbled routes paragraph, and added/settingsand the PDF route to the documented HTTP surface. - The SSRF guard's IPv4-mapped matcher is now structural (the
ffffhextet followed by exactly two more groups, zeros before it) rather than a prefix string — non-canonical spellings like0:0:0:0:0:ffff:7f00:1are caught too, and non-canonical tails are refused instead of misread (the 4-group path previously computed0.0.0.0and false-blocked a valid public spelling). - Digest emails no longer double-list check findings from compilation tabs.
- Flash copy names the
NOTIFY_ALLOW_PRIVATE_URLS=1opt-in where users hit it; the onboarding "collection point" step acknowledges the automatic first baseline. - knip is installed as a real devDependency and fully clean; the settings page drops a redundant query; doc test counts corrected.
394 tests + 7 E2E, all green. knip exit 0. Node 22+.
v0.6.2 — guard the guard
SheetDiff v0.6.2 — guard the guard
The latest audit pass attacked v0.6.1's own fixes. It won. Everything it found is fixed and regression-tested.
CRITICAL: the SSRF guard was bypassable
The WHATWG URL parser canonicalizes dotted IPv4-mapped IPv6 literals to hex — new URL("http://[::ffff:127.0.0.1]/").hostname is [::ffff:7f00:1] — so v0.6.1's dotted-only unwrap never fired on a URL-derived hostname. The auditor demonstrated end-to-end, with a live listener, that ::ffff:7f00:1 reached loopback with the guard passing, and that ::ffff:a9fe:a9fe (cloud metadata) sailed through too.
Mapped addresses are now parsed in both spellings; unparseable ::ffff: forms are refused outright; and NAT64 (64:ff9b::/96), 6to4 (2002::/16), and CGNAT (100.64.0.0/10 — Tailscale ranges) joined the block list.
The rebinding window, closed
The guard resolved the hostname, then fetch() resolved it again independently — a rebinding DNS server could pass the check publicly and answer the fetch privately. Plain-http push URLs are now refused unless NOTIFY_ALLOW_PRIVATE_URLS=1 is set: a rebound connection can't complete TLS without a hostname-valid certificate, and LAN-ntfy users already have the opt-in. Response bodies are released so sockets pool promptly.
"Line List FIRST" no longer flips the billing basis
Compilation tabs re-list the working tabs with fewer columns (the real Line List carries 20 vs the PE tabs' 23). With raw position order, a compilation that precedes the tabs it copies owned everything — and the working tabs got classified as its copies. Measured on the real tracker: 208,961 ft placed-since and zero open holes instead of the true 55,683 ft / 28,299.
Ownership now runs richest-tab-first (column count, then position). Same-width sheets behave exactly as before. Verified on the real file: Line List first, last, or absent now all produce identical billing packets.
Also
- Import-quiet push suppression is per-tab — an import between one tab's base and now silences only that tab's echoes; genuine changes on clean tabs still notify.
- The 0.6.1 "honesty" fixes actually render now (they didn't): failed test pushes and invalid URLs show their messages on /settings, and an invalid entry never clears a previously saved topic.
- Pino redaction restores the
req.headerspaths 0.6.1 dropped, censorserr.response.data/err.config.headers, and its comment now tells the truth about wildcard depth. .env.exampledocumentsLOG_LEVELandNOTIFY_ALLOW_PRIVATE_URLS.
394 tests + 7 E2E, all green in CI. Node 22+.
v0.6.1 — SSRF guard + audit fixes
SheetDiff v0.6.1 — the audit pass
The post-release audit of v0.6.0 found one HIGH security issue and one false claim in the changelog. Both corrected, plus the rest of the findings.
The HIGH: push URLs were a full SSRF surface
users.notify_url is a URL the server fetches — and it was fetched with no restrictions. The auditor proved, with live listeners, that a user could make the server POST to 127.0.0.1, ::1, cloud metadata (169.254.169.254), RFC1918 and ULA addresses, and non-canonical spellings like http://2130706433/; redirects were followed (a "public" URL could 302 into the LAN); and nothing checked DNS resolution, so rebinding was trivial.
Now: the hostname is resolved at send time and every resolved address is refused when it's loopback, link-local, RFC1918, ULA, unspecified, or IPv4-mapped IPv6; redirects are refused outright (redirect: "error"); non-canonical IPv4 spellings die at resolution. Deployers who deliberately run ntfy on the same LAN set NOTIFY_ALLOW_PRIVATE_URLS=1. Six unit tests pin the guard.
The false claim, corrected
The v0.6.0 changelog said parseCompletedDate validated via Temporal.PlainDate. It didn't — an aborted edit script meant the old day-count check shipped. Now it really is ported, with overflow: "reject" — critical, because Temporal's default is "constrain", which would clamp Feb 30 to Feb 28 and silently reintroduce the exact rollover bug this exists to kill. The full existing date battery plus the auditor's differential corpus pin the behavior.
The rest
- Captures right after a GIS import stay quiet — their "changes" are the rows the office just imported; announcing them as new work was backwards.
- The push is fire-and-forget: a dead ntfy endpoint can no longer add its 5-second timeout to a capture or to the sequential scheduler tick.
- Pino redaction now covers nested credential paths (
err.response.data.access_token,err.config.headers.authorization), and the token-persistence path logserr.messageinstead of raw gaxios error objects. - The billing page now surfaces an untracked TOTALS tab's over-placement exactly like the CSV and PDF always did.
- The test-push button reports failure honestly; an invalid URL is rejected with a message instead of silently cleared; pure viewers no longer see the onboarding checklist (steps they can never complete).
- knip-cleanups:
pino-prettyremoved,temporal-specdeclared as the types dependency it is, dead exports dropped; pino externalized from the server bundle.
390 tests + 7 E2E, all green in CI. Node 22+.
v0.6.0 — push notifications, PDF packets, onboarding
SheetDiff v0.6.0 — push notifications, PDF packets, and a real onboarding flow
Eight researched integrations from the recommendations pass, each one vetted to actually fit before it shipped — plus the onboarding and settings work to make them discoverable.
The instant lane: push notifications
Settings → Push notifications. Subscribe to any topic in the ntfy app (or a browser), paste the topic URL, hit the built-in Send a test button. From then on, every capture that finds new work to enter buzzes your phone — the first capture of a sheet stays quiet (it's just a baseline), and quiet captures never ping. Validated http(s) URLs only, 5-second timeout, and a push failure can never fail a capture. Self-hosted ntfy servers work identically if nothing should leave the LAN.
The billing-day packet, now also a PDF
The billing page gains a PDF button next to CSV — a printable, fileable artifact for the invoice batch. It's built from the same packet assembly as the CSV (extracted into one shared source) on the same data clock, so the two can never disagree, and both are byte-identical on re-export.
Settings hub + getting-started checklist
All user-level settings now live on one /settings page (push, digest email, server-settings pointers), linked from the account menu. And the dashboard gained a four-step onboarding card — track a sheet → mark a collection point → turn on notifications → share with your collector — whose completion is derived from the database, never stored, so it can't drift out of sync with reality. It disappears when you're done (or when you dismiss it).
Under the hood
- Temporal date math:
parseCompletedDatenow validates throughTemporal.PlainDate— an impossible calendar date throws instead of relying on hand-rolled day-count checks, making the "Feb 30 became Mar 2" bug class structurally impossible. Week bucketing computes Mondays with Temporal day arithmetic, killing the DST-crossing drift the millisecond subtraction could produce. All output contracts unchanged; all existing tests untouched. - Pino structured logging with token redaction replaces every server-side
console.log— greppable JSON indocker logs, level viaLOG_LEVEL. - MSW wire-path tests for the real Google client: range quoting, 10-range chunking, response pairing, the reordered-response abort, and empty-grid handling now have actual coverage instead of a module mock that bypassed all of it.
- Recharts bar chart for weekly footage on the report page — same deduped numbers, actually drawn.
- README gained a Notifications & monitoring guide: ntfy setup, capture monitoring via Uptime Kuma or healthchecks.io (works with the existing
HEALTHCHECK_PING_URLtoday), and a Litestream disaster-recovery compose recipe for continuous SQLite replication.
381 tests + 7 E2E, all green in CI. Node 22+.
v0.5.2 — Playwright E2E + permit-deny fix
SheetDiff v0.5.2 — Playwright E2E + two accounting-bug fixes
The second dev-environment pass added the last recommended item — a Playwright smoke E2E suite — and the audit that accompanied it surfaced two pre-existing bugs with real accounting impact, both fixed.
Fixed
- The permit deny-list failed open. A regex typo (
nots+fornot\s+) meant "Not Issued", "Not Approved", and "Not Released" matched the approved vocabulary — placed-under-unapproved findings were silently missed on exactly the permits that say no. Negations are denied now, with the vocabulary pinned by tests. - Header drift could key history by the wrong column. SheetDiff's identity columns resolve once on latest data — but they're indices, and a column inserted or removed since a baseline shifts every index after it. The history walk now drops to content identity whenever the slice's header at a resolved index isn't the column the latest walk keyed on.
- Smaller: day-family headers ("Day No") join date/week as date-ish (a day-number column no longer collapses two same-week logs into one); the Google token refresh preserves the stored access token; coverage thresholds carry ~1% of ratchet margin.
New: Playwright smoke E2E
Five tests against the production build (not next dev): health endpoint, the public landing page, demo login → dashboard, the sheet diff view with its changes-since-collection bar, and the report + billing pages. The suite boots on its own port with a throwaway temp-dir database — it can never touch a real one — and the CI job uploads the HTML report on failure. Run locally with npm run test:e2e.
Also: typecheck now runs next typegen first (Next 16's own recommendation), a db:studio script (Drizzle Studio), a CI badge in the README, and the docker boot-probe job builds arm64 alongside x64 — free on public repos, and it matches how self-hosters actually deploy this (NAS, Raspberry Pi).
370 tests + 5 E2E, all green in CI. Node 22+.
v0.5.1 — identity hardening + dev environment
SheetDiff v0.5.1 — identity hardening + a real dev environment
Two passes in one release: a fresh adversarial audit of v0.5's smart-identifier hierarchy (five real defects found and fixed), and a researched dev-environment overhaul based on how comparable open-source Next.js projects (cal.com, dub, formbricks, documenso, vitest, drizzle-orm) actually work in 2026.
Fixed — the audit pass
- Identity is now resolved once, on latest data, for every slice. The auto-detected key column was being re-detected per snapshot; when uniqueness flipped between them (two rows share a date on a baseline), the same row keyed differently over time and a compilation tab's baseline escaped ownership — deflating placed-since, the exact bug class v0.5 claimed to have killed.
- Dates no longer masquerade as row identifiers. A unique Date column used to outrank a real ID/SKU column in detection scoring (making a date correction look like remove+add), and the dedup's auto-tier keyed by date — two crew logs spanning the same period collapsed into one tab and vanished from billing. Date/week now score below real identifiers and are skipped by the dedup auto-tier entirely; a date key you pick deliberately is still honored.
sheetBillableNowhonors the per-tab key column — the sheet-page badge and the money page can no longer disagree, the invariant that function exists to guarantee.- A mostly-copy tab that owns real work is no longer skipped wholesale — the coverage-based compilation classification now caps strays at 2% of keyed rows (the real Line List sits at 1.9% and still classifies; 20-copies-plus-straggler no longer vanishes).
- Removals net out per tab again — the slice walk's key-namespace exception was inverted (within-tab repeats dropped, cross-tab repeats kept).
New — the dev environment
- Prettier with Tailwind class sorting and import organizing; the repo is formatted once and
npm run format/format:checkkeep it that way. - Pre-commit hooks (husky + lint-staged): ESLint --fix + Prettier on staged files only, skipped in CI.
- Coverage with teeth: vitest v8 coverage with enforced thresholds (currently ~80% lines), run in CI.
npm run verify: format + lint + typecheck + tests in one command — what CI checks, what you run before pushing.- Security workflows: CodeQL (weekly + PRs) and zizmor (lints our own Actions files).
- Hardened CI: least-privilege permissions, cancel-in-progress, timeouts,
persist-credentials: false, format + coverage steps. - Repo hygiene:
.nvmrc,.editorconfig,.vscodesettings + recommended extensions, PR template, feature-request form, FUNDING. - knip dead-code sweep — 6 unused scaffold components removed, and the phantom
google-auth-librarytype import is now derived fromgoogleapisitself.
367 tests green (6 new regression tests pin every audit fix). Node 22+.
v0.5.0 — works with any sheet
SheetDiff v0.5.0 — works with any sheet
SheetDiff's core loop — snapshots, GitHub-style diffs, "what do I still need to enter?", acks, digest email — was always sheet-agnostic. The identity layer wasn't: it knew how to match construction shots by station ranges, and nothing else. v0.5 makes it universal.
Smart identifiers, zero configuration
Every row now gets an identity through a hierarchy:
- Your choice — a "Match rows by" column per tab, validated before it's trusted (populated + unique). Pick "Activity" on a tracker and the choice is ignored, not obeyed into corrupting counts.
- Work identity — activity + parsed stations on sheets that have them (
164+82≡16,482). - Auto-detected ID column —
ID,SKU,Ticket #,Order,Email,PO,Asset, … (vocabulary widened) when it's populated and unique. - Whole-row content — the always-there fallback, also registered alongside the other tiers so a verbatim copy of a tab matches however each side keyed its rows.
That identity drives diffs, ack tracking, compilation-tab detection, and the cross-surface to-enter counts — so an inventory sheet gets the same "a Master List that re-lists your working tab is tagged copy and counted zero times" guarantee the construction trackers already had. The identity tier is decided once on the latest data and applied to history, so the same row can never key differently at different times (a subtle bug caught and fixed while shipping this).
Honest math on sheets without stations
The billing packet on a row-based sheet now says "COULD NOT DETERMINE — no collection marker or no station columns (row-based sheet)" instead of a confident 0 ft, while the to-enter worklist, late entries, and office backlog behave exactly as on a tracker. Construction math (footage, gap reports) activates only when the sheet carries the vocabulary for it.
Verified
- New acceptance suite: a generic inventory sheet (SKU keys, reformatted compilation copy) — copy classification, count agreement across dashboard/CSVs/digest, honest billing output.
- The construction path re-verified against the real 36-package tracker: PE-only and PE+Line-List configurations still export identical billing packets.
- 361 tests green. Requires Node.js 22+.
v0.4.0 — the accounting-grade release
SheetDiff v0.4.0 — the accounting-grade release
The numbers on a billing packet can cost real money. v0.4 hardens every
computation path in SheetDiff with a five-agent accuracy audit — number
correctness, adversarial fuzzing, data integrity, cross-surface invariants,
and a forensic pass against a real 36-package utility production tracker —
then fixes everything it found. Every fix is enforced by a permanent test
suite (353 tests, 24 new), including a cross-surface invariant suite that
seeds one sheet and asserts the dashboard, sheet page, three CSVs, billing
page, report, and digest all show the same hand-computed numbers.
The headline fixes
- Compilation tabs can no longer double-bill. Real trackers carry a "Line
List" that re-lists the working tabs — sometimes reformatted (2+14for
214, retyped crews, different widths). Cell-text dedup never matched those,
and on the real tracker the configuration nearly doubled invoiceable
footage (910,210 ft vs the true 459,472 ft). Rows are now deduplicated by
work identity — activity + parsed stations — with ownership decided once and
applied to baselines and history, verified against the real file: PE-only
and PE+Line-List export IDENTICAL billing packets. - Exports are byte-identical. Timestamps, aging day-counts, and filenames
ride the data clock (the latest snapshot), never the export moment.
Re-export unchanged data, get identical bytes — diff two exports to prove
nothing moved. - Undo is exact. "Mark as collected" captures each tab's previous
collection point and restores it in one transaction. The old single-run undo
could leave mixed-baseline tabs collected and hide un-entered work. - Nothing silently mis-dates or vanishes. "Feb 30 2026" is unreadable, not
March 2nd (it used to mis-age receivables by days). 1–2 digit invoice
numbers land in the billed ledger. Keyed ledger entries that match no known
bucket are surfaced with row and column instead of dropped. - Hostile-input safe. Infinity footage, 130k-row crashes, NUL-byte key
collisions, CRLF-corrupted CSV re-imports, formula injection, reordered
Google responses — all closed.
Also in this release
- One "placed" definition everywhere (ledger, weekly buckets, gap report all
use the same chain rule). - Billing packets stamped with their run id for audit tracing.
- Compilation strays surfaced as a check finding.
- The digest computes from the same deduped view as billing — the email can
no longer overstate footage on copy-tab sheets.
Requires Node.js 22+. Full changelog: CHANGELOG.md.
v0.3.3 — digest TOTALS fix, permit watch fix, digest highlights
- Digest TOTALS wiring fixed — placed/designed/remaining numbers now render (regex typo made them always null)
- Permit deny-list fixed — "Not Issued" correctly reads as NOT approved
- Digest highlights — stoppage/permit findings in the Monday email
- Add-sheet error taxonomy — unset credentials name the env vars
- Notation legend + timeline a11y
- Zero-week guard, quiet-log entry-date fix, stoppage header widened
Suite: 327 tests.
v0.3.2 — billing CSV dedup fix, stoppage report, permit watch
SheetDiff v0.3.2
Fixes a billing-dashboard crash at v0.3.1 and closes a billing-CSV correctness gap.
Dedup unified — one tested dedupeTabData() replaces three inline copies. Compilation tabs (Line List copies the working tabs) no longer double-count footage, holes, or the A/R backlog on any surface.
Billing dashboard rebuilt + tested — the page that 500'd at v0.3.1 is clean, with SSR tests pinning render, dedup, and section classification.
New: stoppage-aware weekly report (dip weeks annotated with logged stoppages); permit watch (designed-no-permit, unapproved crossings, submitted-aging); billing & report in the top bar with a billable-now badge; capture-failure health signal; legacy v0.2 upgrade fixed.
Suite: 312 tests. Node 22+.