Skip to content

Audit remediation: 12 findings, plus the pb-nebula bump that makes Nebula revocation real - #9

Merged
skeeeon merged 14 commits into
mainfrom
fix/audit-remediation
Sep 9, 2026
Merged

Audit remediation: 12 findings, plus the pb-nebula bump that makes Nebula revocation real#9
skeeeon merged 14 commits into
mainfrom
fix/audit-remediation

Conversation

@skeeeon

@skeeeon skeeeon commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

A full-codebase review produced a 12-item ordered fix list. All twelve are here, one commit each, plus a dependency bump that turns item 4 from correct-but-inert into a working control.

52 files, +4405 / −391. No feature work — the freeze holds.

Verification

Gate Result
go build (server + leaf-sync), go vet ok
go test -count=1 ./... ok
./scripts/test-authz.sh 176 passed, 0 failed (was 155)
./scripts/check-sort-fields.sh 58 sort terms resolve
cd ui && npm test 140 passed (was 0 — no runner existed)
vue-tsc --noEmit, vite build clean
go mod tidy -diff, gofmt on every blob git stores clean

The findings, most severe first

1. A blank organization matched a blank organization context — cross-tenant reads

f903acf. Every inventory read rule was organization = @request.auth.current_organization. Both sides are TEXT whose zero value is the empty string, and in PocketBase an empty string equals an empty string. No role was bypassed and no rule was mis-written — two sentinels compared equal.

Both halves were ordinary product states. organizations.deleteRule allowed owner = @request.auth.id, and 16 of the 18 relations into organizations are non-cascade and non-required, which PocketBase blanks rather than deletes (SaveNoValidate) — so an owner deleting their own org orphaned its entire inventory at a blank organization. Meanwhile hooks/membership_lifecycle.go blanks current_organization on the way out of an org. The leaf_nodes branches had the same hole on @request.auth.organization.

18 rules across 8 collections now require a non-blank context and a correlated membership. The membership clause is the load-bearing half: memberships.organization is required and cascade, so it can never be blank — that kills the sentinel by construction rather than by a comparison someone later tidies away. organizations.deleteRule is now operator-only, and users.createRule refuses current_organization outright, because that branch is anonymous and cannot check membership even in principle.

Bounded precisely: credentials were not exposed (memberships.organization is required+cascade), and org ids were not enumerable (organizations.listRule requires membership). Every write rule already carried the membership clause, which is exactly why only reads were exposed.

The review question is not "does this rule name the right roles" but "what does this rule do when both sides are the zero value."

2. leaf-sync zombied ~2 minutes after the local bus restarted

b016e2f. nats.Connect passed no reconnect options, so the nats.go default MaxReconnects: 60 × ReconnectWait: 2s closed the connection permanently while Run looped forever against a dead handle. Now MaxReconnects(-1). The initial dial still fails fast, deliberately — a misconfigured agent should exit, not retry silently.

3. The twin relay dropped failed hub writes, and a short fetch purged live rows

ba90e15. Two independent bugs.

The relay claimed a watcher replay would re-offer a failed write. It cannot: the watcher is on the local bucket, which does not die when the WAN does. Now a pending set is retried on a timer, re-reading the current value from the source (so a stale value is never resurrected) and relaying a delete for locally-removed keys. Retry-and-continue, not return-error — a poison key must not block every other key.

Separately, syncCollection purged any local row absent from the fetch, so a partial page meant deleting live records. Now expected is captured from the first page's TotalItems and a skipPurge flag guards only the delete loop — not the upserts, and not the failed > 0 error return. I caught both of those myself after a first attempt returned early and skipped the upserts too.

4. Nebula certificates were never revoked — plus 989762c

a626930 + 989762c. Setting a device inactive closed the console door and the NATS door; the overlay network stayed open until the certificate expired. pb-nebula v0.1.0 had no revocation concept at all, so this needed skeeeon/pb-nebula#2 first, now released as v0.2.0.

Nebula has no CRL and no OCSP: revocation is pki.blocklist, a fingerprint list in every peer's config, so it is a fan-out and it takes effect on config redeploy (SIGHUP is enough). Two consequences are now documented rather than discovered: deactivate, do not delete — a fingerprint absent from the database cannot be blocklisted, so deleting a host leaves its certificate trusted until expiry — and the config is not the delivery, which matches the NATS boundary of minting a credential and not policing what connects with it. The delivery path is deliberately out of scope: client responsibility, same as NATS.

hooks/active_flag.go now runs the NATS and Nebula cascades independently — a device may hold either identity, both, or neither, so neither may short-circuit the other. It mirrors active, not revoke: in pb-nats revoke is the "credentials leaked" button that hands back a working replacement, and it is checked first and returns early, so setting both in one save silently re-issues a deactivated device's credential.

The bump adds one behaviour that is a dependency's, not ours: a nebula_hosts record created without an active field now lands active, because a host born inactive is one every peer blocklists at birth. Nothing here relied on the old default — hooks/thing_routes.go:473 and the console form both always sent it — but the suite now pins that contract, since a silent upstream revert would provision devices that can never join the mesh.

5. The test harness bound 5 of 13 hooks, and demo-seed produced no decommissioned devices

78499ce. internal/testutil did not bind RegisterActiveFlag or RegisterMembershipLifecycle, so tests passed against behaviour the binary does not have. It now binds both, and documents why the 6 route registrars plus RegisterObservability are deliberately excluded (they bind only OnServe).

demoseed set the inactive flag at create time, which does nothing — a just-created record has an empty Original(), so the update hook sees no flip. It now creates active and re-reads before flipping, so the cascade actually fires. Its revokeNatsUser helper was deleted: it set the wrong flag, for the reason in item 4.

6. Encryption boundary — documentation, not code

f235d0b. The proposal was to encrypt creds_file and config_yaml. Dropped after discussion, and that was the right call. A .creds file contains the user seed, Nebula needs the host key inline, and ui/src/stores/nats.ts reads creds_file to open the browser's own NATS connection — a browser can never hold a decryption key. It would have required a decrypting route inside pb-nats and pb-nebula, hard-coupling those libraries to this platform, to protect material that rotates cheaply on the NATS side anyway.

SECURITY.md now states the boundary precisely: a stolen database with the key held elsewhere yields no ability to mint new identities and every existing credential.

7. CI ran none of the checks that already existed

53a1104. Added the frontend npm test step, go test -count=1, ./scripts/check-sort-fields.sh, go mod tidy -diff, a pb_public placeholder guard before the build, a maplibre-gl 5 pin guard, and a gofmt -l . gate.

The gofmt gate was previously written off as impossible — gofmt rewrites a doubled single-quote in a Go doc comment into a typographic quote. It turned out to be fixable by rewording two comments rather than accepting the corruption, so the gate is now real. -race is deliberately absent: it needs cgo and I had no gcc to verify it with, and an unverified gate that first fails on someone else's PR is worse than a missing one. Reasoning is in ci.yml for whoever has a Linux box.

8. Every rule rejection was counted as a server error

4131abe. statusClass read re.Status, which is zero when a handler returns an error before writing. So every 400 and 404 — including every authz denial — landed in the 5xx bucket, making the error-rate metric useless as an alert. It now resolves router.ToApiError(err).Status, with a test asserting the bucket matches what the router would actually send.

9. Widget defaults had two sources, and the newer one lost

8b00869. I proposed this as deleting a duplicate. It was not a duplicate — it was stale, and it ran second, so it reverted newer defaults: kvtable lost its twin bucket, and button/switch/slider lost their command and desired-state subjects.

I said so before touching it, pulled item 11's runner forward to pin createDefaultWidget first, then merged only the additive parts (per-type titles, $.value jsonPaths). User-visible defaults are unchanged. WIDGET_TYPES is now a runtime as const array with WidgetType derived from it, and configComponents is typed Record<WidgetType, Component> so a new widget type fails to compile until its config form exists.

10. Documentation asserted things the code does not do

0f6852a + 12f1bf8 + platform-docs. The headline feature list still sold message schemas — the worst place for a stale claim, since a buyer demos what they were sold. Also: a pocketbase widget with no component behind it, the role count, the agent's binary name, and "bootstrap is three commands" where it is four.

The agent reports were partly stale, citing line numbers that had moved and claims already fixed. I verified every doc claim against the code before editing.

11. No frontend test runner existed

3359610. Vitest, node environment by default, jsdom opt-in per file. 140 tests over the five files vue-tsc && vite build cannot protect: twinDrift, useSubscriptionManager, the can capability map, dashboard import/export, createDefaultWidget.

12. The gate in front of every destructive action was not a dialog

7a44700. ConfirmDialog was a plain div: no role, no aria-modal, no labelling, no Escape, no focus trap, and autofocus on the destructive button — so Enter on an unread dialog deleted the thing. Now a proper dialog; focus lands on the container, not a button, so nothing is pre-selected.

The habit that mattered most

Every high-severity fix was verified by reverting it and confirming the new test fails. That caught four tests of mine that passed against deliberately broken code:

  1. A leaf-node authz check that restored its fixture before the assertion ran → fixed with a dedicated permanent ORPHAN record.
  2. A relay-retry test that cleared the injected fault on a poll, so the retry path never ran → fixed with a writeFails counter.
  3. A Nebula cascade check whose fixture host was already inactive (the payload omits the flag; PocketBase bools have no schema default) → fixed by forcing and asserting it immediately before deactivating.
  4. A focus-trap test that caught a bug in my own implementation — filtering on offsetParent !== null is null for every element under jsdom and inside position: fixed subtrees, so the trap was a no-op.

The new born-active check in 989762c was verified the same way: it fails against pb-nebula v0.1.0 and passes against v0.2.0.

Also worth knowing for the next authz change: PocketBase tokenizes quote characters in a rule string before comments are stripped, so one apostrophe in a comment re-pairs every quote after it and the collection fails to import with invalid quoted text. My first hypothesis about the mechanism was wrong; I isolated it empirically by restoring the literal and rebuilding. Written up in CLAUDE.md.

Known gaps

  • -race is not in CI (needs cgo; no gcc available to verify with).
  • STONE_AGE_BOOTSTRAP_PASSWORD has no file-based indirection and outlives its single use in the container's environment. Flagged, out of the 12-item scope.
  • The HEALTHCHECK change in 53a1104 is unexercised at runtime. I listed this as "the image was never built", which was wrong: CI's pre-existing Container image builds step passes, so the Dockerfile is valid and the image builds. But building an image never runs its healthcheck, and I had no container runtime locally to start one — so the port-expansion fix itself is still unproven.
  • The console was never opened in a browser. ConfirmDialog is covered by 14 jsdom tests instead; that is a better regression guard but it is not the same as clicking it.
  • Frontend refactors deliberately excluded under the freeze: useListView (~2,700 duplicated lines), a widget registry (28 edit sites per new type), FormField (~640 lines), splitting ScannerWidget. Vitest now existing is what makes those safe to attempt later.
  • Unrelated: GitHub reports 3 Dependabot vulnerabilities on main (2 critical, 1 low), untouched by this branch.

Merge order

skeeeon/pb-nebula#2 and #3 are merged and tagged v0.2.0, so this branch is unblocked. platform-docs fix/docs-truth-pass is pushed but has no PR yet — it pairs with item 10 and can land independently.

🤖 Generated with Claude Code

skeeeon and others added 14 commits September 8, 2026 23:06
Every inventory read rule scoped on
`organization = @request.auth.current_organization`. Both sides are TEXT
columns whose zero value is the empty string, and in PocketBase an empty
string equals an empty string -- so a record with a blank organization was
readable by any authenticated caller whose own context was also blank. No
role check was bypassed and no rule was mis-written; two sentinels compared
equal, which is why the allowlist discipline could not see it.

Both halves were reachable through ordinary use. organizations.deleteRule
permitted `owner = @request.auth.id`, and 16 of the 18 relations into
organizations are non-cascade AND non-required -- PocketBase blanks those
rather than deleting the rows -- so an owner deleting their organization
orphaned every thing, location, type, leaf node, nats_account and nebula_ca.
On the other side, hooks/membership_lifecycle.go blanks current_organization
deliberately when a membership is removed, and it is also the default for a
freshly registered invitee. Chained, a deleted tenant's whole inventory, its
NATS account record and its Nebula CA certificate became readable by a user
in the blank-context state in any other tenant.

Signed credentials were never exposed: nats_users and nebula_hosts require a
correlated membership with an owner/admin role, and memberships.organization
is required and cascade so it can never be blank. That is the row-scoping
design holding.

The eight affected read rules now require a non-blank context AND a
correlated membership -- the clause every write rule already carried, which
is exactly why only the reads were exposed. The leaf_nodes branches needed
the same treatment on @request.auth.organization, which an org delete blanks
for the same reason: a leaf node whose organization was deleted would
otherwise have matched every orphaned record on the platform. Reads stay
org-scoped rather than role-scoped, unchanged and deliberate.

Also here:

- users.createRule no longer accepts current_organization. The update rule
  froze it to organizations the caller holds a membership in; the create rule
  did not mention it, so an invited registrant could name any organization id
  at signup and then read that tenant. The anonymous branch cannot check
  membership even in principle, so it refuses the field; accept-invite fills
  it in afterwards.
- Deleting an organization is operator-only, matching updateRule. It was
  available to the owner, which is what manufactured the orphans above.
  Every console route touching this collection was already operator-gated.
- test-authz.sh: 155 -> 172 checks. Each new check was verified to FAIL
  against the pre-fix rules rather than merely to pass against the fixed
  ones -- an earlier draft of the leaf-node check passed against the bug
  because it restored the shared fixture before the check ran, so the orphan
  is now a dedicated permanent record. The suite also exercises an admin
  token for the first time: every owner/admin rule had been proven for owner
  only, so an allowlist that had lost its "admin" term would have passed
  everything.
- CLAUDE.md records the class ("what does this rule do when both sides are
  the zero value"), and the parsing trap found on the way: an apostrophe in a
  rule comment re-pairs every quote after it, because PocketBase tokenizes
  quotes before stripping comments, and the collection then fails to import
  with `invalid quoted text`.
- The authz check count was stale in two places (CLAUDE.md said 150,
  SECURITY.md said 140).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nats.Connect was called with no reconnect options, so nats.go's defaults
applied: 60 attempts at 2s, after which the connection is CLOSED
permanently. Run loops until its context is cancelled, so roughly two
minutes after the local server went away the agent became a zombie -- the
ticker kept firing, every KV Put failed, the heartbeat failed, the twin relay
failed, and nothing ever reconnected or exited for a supervisor to act on.

This landed on the default topology, a separately supervised nats-server, and
on the documented setup flow: `leaf-sync config` writes nats-leaf.conf and
the next step restarts the server that reads it. An islanded site is
precisely when the agent must keep trying, so an established connection now
retries indefinitely, with disconnect/reconnect/closed handlers so the state
is visible in the log instead of silent.

The INITIAL dial still fails fast, deliberately and unchanged: without --nats
the bus is a separate process that should already be up, and a hard error at
startup is how an operator learns the creds path or URL is wrong. Turning on
RetryOnFailedConnect would also hand newKVWriter a connection that is not up
yet, so a test pins that it stays off.

The options moved into localConnectOptions so the invariant can be asserted
at all -- an inline argument list to nats.Connect cannot be inspected.
TestLocalConnectRetriesForever reports `MaxReconnect = 60` against the
previous behaviour, which is the bug reproduced as a test rather than a claim
that the fix works.

observability.addr now defaults to 127.0.0.1:9100 instead of empty. A stock
edge box served neither /ready nor /metrics, so the only place per-site
health is visible was off unless someone opted in -- which is why the
nats_local check that would have caught this bug had no consumer. Binding was
already non-fatal by design, so a port clash (node_exporter uses the same
one) logs a warning and syncing continues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fetch

Two ways the edge could silently lose data it was responsible for delivering.

The twin relay dropped a failed hub write, on the stated grounds that "a key
missed here is re-offered by the next watcher restart's replay". It was not.
The watcher runs on the LOCAL bucket, which does not die when the hub or the
WAN does, and superviseReportedPump only restarts the pump when the WATCHER
fails. A reported value that changed during an outage, failed its hub write,
and then never changed again was therefore absent from the hub permanently and
without a trace.

Failed keys are now held in a pending set and retried on a ticker. The value
is re-read from the local bucket at retry time rather than remembered from the
failed attempt, so a retry cannot write a stale reading over a newer one --
the device may have reported twice more while the hub was unreachable. A key
deleted locally in the meantime is relayed as a delete, keeping relayEntry's
tombstone-not-absence distinction.

Holding and retrying is deliberately chosen over returning an error and
letting the supervisor replay: a single key the hub will never accept would
otherwise tear down the watcher on every replay and block every other key
behind it, forever.

Separately, syncCollection paged with no sort order, so a record inserted or
deleted between two page requests could shift the window and make the walk
skip a record -- and the deletion pass then read that absence as an upstream
delete and removed it from the edge. The empty-fetch guard only caught a total
failure; a partial walk is the dangerous case, because one record short of a
400-record collection quietly deletes one live config row and the next cycle
puts it back, so the only symptom is a device that briefly cannot resolve
something.

pbclient.List now requests sort=id -- a paginated walk without a stable total
order is wrong for any caller, so the guarantee belongs to pagination rather
than to one use of it -- and the walk compares what it collected against the
totalItems it was promised, skipping only the purge when it came up short.
Records the short walk did return are still upserted. A record legitimately
deleted mid-walk also trips the guard; that false positive costs one cycle of
delayed purging, which is the safe direction to be wrong in.

Tests, and what they are worth:

- twin_retry_test.go drives a live relay against a failing destination, which
  nothing did before -- the existing partition test covers convergence via a
  relay RESTART, the path that always worked. It waits on an OBSERVED write
  failure rather than a timer, because an earlier version cleared the fault on
  a poll and passed against a deliberately broken build without the retry path
  running at all.
- pagination_test.go adds the first multi-page reconcile coverage. The existing
  fake always reported TotalPages: 1 and pointed at pbclient for pagination;
  pbclient only ever parsed a single page envelope. The short-fetch test is
  paired with a complete-walk test, so disabling the purge outright would not
  pass.
- Both were verified to fail against the unfixed code.

Also corrects two comments that described behaviour the code did not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…TS one

hooks/active_flag.go refreshed the PocketBase token key and mirrored `active`
onto the linked nats_user, and touched things.nebula_host not at all. A
decommissioned device therefore kept valid overlay-network membership until its
certificate expired: the console door and the NATS door closed, and the mesh
door stayed open. The flag is now mirrored onto nebula_hosts as well, which is
what pb-nebula writes into every other host's pki.blocklist.

The two cascades are independent. The previous code returned early when
nats_user was empty, so a device holding only a certificate would have had the
Nebula half skipped entirely -- extracted into mirrorActiveFlag so neither can
short-circuit the other.

Two properties of Nebula revocation are documented rather than worked around.
It has no CRL, so revocation is a fingerprint carried in every PEER's config
and takes effect when that config is redeployed and the process reloads; the
platform's job ends when the material it hands out refuses the certificate,
which is the same boundary as minting a NATS credential and not policing what
connects with it. And fingerprinting a certificate needs the certificate still
in the database, so DEACTIVATE to revoke, do not delete -- deleting a host
leaves it trusted until expiry.

Needs a pb-nebula newer than v0.1.0 (see feat/certificate-revocation there).
Against v0.1.0 the flag is mirrored correctly and no blocklist is produced, so
this half is inert but harmless until the dependency is bumped.

test-authz.sh: 172 -> 175 checks. The first version of the deactivation
assertion PASSED against a build with the Nebula cascade removed, because
host_payload does not send `active` and PocketBase bools have no schema
default -- the fixture host was already inactive, so the check could not fail.
It now forces the host active and asserts that before deactivating, which is
the same "capture the baseline immediately before the action" rule the suite
already documents. Verified to fail without the cascade.

Also corrects CLAUDE.md, which described this hook as setting `revoke` on the
linked NATS user. It never did, and the hook's own comment explains at length
why it must not: pb-nats treats `revoke` as "these credentials leaked",
rotating the key pair and handing back a WORKING replacement, and it checks
that flag before the active edge and returns early -- so setting both in one
save silently re-credentials the device you just disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…evoke properly

internal/testutil bound 5 of the 13 hooks main.go registers, while its own
comment insisted the order was "equivalent to main.go". The gap was not
neutral. RegisterActiveFlag forces active=true on every things/leaf_nodes
CREATE -- PocketBase bools have no schema default and the authRule is
`active = true`, so a device created without the field would be locked out of
its own API. Without that hook bound, a test could create an inactive device
and assert on it happily while the real binary overwrote the flag.

Which is exactly what had happened. demoseed asked for decommissioned Things
at create time, the harness allowed it, and `stone-age demo-seed` on a real
install produced ZERO inactive devices, with every fixture-count test passing.
Binding the hook made the existing test fail immediately: "no inactive things
-- the decommissioned state is unrepresented".

The route registrars and RegisterObservability stay unbound, now with the
reason written down: they bind only OnServe and this harness never serves.
Every hook that changes what a WRITE does is bound; nothing that only answers
HTTP is. That is the boundary, rather than an accident.

demoseed, two bugs at one call site:

- Deactivation moved from create-time to an update, the only edge
  hooks/active_flag.go triggers on. The record is RE-READ first, which is
  load-bearing: a record created in memory and saved carries an empty
  Original() snapshot, so `active` reads false on both sides of the
  comparison, the hook sees no edge, and nothing cascades. Loading it back
  gives it a real prior state -- the same thing an operator editing it in the
  console produces.
- The seeder no longer sets `revoke` alongside `active = false` on the
  nats_users record. pb-nats checks `revoke` FIRST and returns early, so the
  suspend branch never ran -- and revoke means "these credentials leaked": it
  rotates the key pair and writes back a fresh WORKING creds file. Every
  "decommissioned" demo Thing therefore held a live NATS credential, the exact
  failure active_flag.go warns about at length. revokeNatsUser is deleted; the
  active flip is the one mechanism.

TestDecommissionedThingsHaveTheirCredentialRevoked now asserts the EFFECT
instead of the flag: the user's public key appearing in the owning account's
revocation list, plus the linked Nebula host being inactive. It previously
checked only the field the seeder had just written in that same save, so it
could not distinguish "pb-nats suspended the user" from "pb-nats did nothing"
-- and it called t.Skip when there were no inactive things, so during the bug
it did not run at all. Verified to fail against the old revoke-and-active
write.

Also: ensure() no longer reads a database error as "not found". A transient
failure created a duplicate of an existing record; for things that is a second
row with the same code, which the UNIQUE (organization, code) index rejects on
a later run -- a seeder failing for a reason with no visible connection to the
outage that caused it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s not

The review flagged an asymmetry: with encryption on, nats_users.seed is
encrypted while nats_users.creds_file sits beside it in plaintext. The sharper
version is that creds_file IS the seed -- pb-nats builds it with
jwt.FormatUserConfig(user.JWT, user.Seed) -- so encrypting the seed column
protects nothing for user identities.

Encrypting creds_file was considered and rejected, which is the decision this
commit records rather than reverses:

- The browser is the hard blocker. ui/src/stores/nats.ts reads creds_file from
  the API and feeds it to nats-core, and a browser can never hold the
  encryption key -- so encryption forces a server-side decrypting route, it
  does not merely suggest one.
- pb-nats's EncryptField/DecryptField live in internal/, so the platform
  cannot call them at all without the library exporting a primitive.
  Reimplementing AES-GCM here is the thing CLAUDE.md already refuses
  elsewhere.
- nebula_hosts.config_yaml has the same shape, and Nebula's PKI requires the
  host key inline regardless.
- migrations/schema_update_credential_scoping.go reached the same conclusion
  for hidden:true, for the same reasons.

And what encryption does buy is real, so it is now stated precisely: a stolen
pb_data with the key held separately yields NO ability to mint new identities
-- no operator seed, no account signing keys, no CA key -- and every existing
credential. The two halves have very different remediation costs, which is the
part worth knowing before an incident: rotating NATS is central and scriptable
with a permanent revocation cutoff, while Nebula has no CRL and needs re-issue
plus a blocklist entry in every peer plus redelivery.

The only behaviour change is the encryption_at_rest readiness check, which
reported a bare "enabled for NATS and Nebula" -- accurate about the
configuration and misleading about the guarantee. It now names what it covers.

The residual risk is a hosted-tier database leak exposing every tenant's
device credentials. SECURITY.md says so, and points at the controls that
actually address it: full-disk encryption, encrypted backups, and dedicated
single-tenant deployments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five gaps, all in the guard rails rather than the product.

check-sort-fields.sh was written, worked, and was called by nothing. It guards
a failure mode that has already killed two screens for every caller: an
unknown `sort` field is a 400 raised before any rule is evaluated, the message
names no field, and it fails for superusers too, so it never looks like
authorization. It needs the same go/curl/node as the authz step above it.

The pinned-major guard checked tailwindcss, daisyui and typescript and omitted
maplibre-gl -- the one of the four whose failure is completely silent. v6
splits its tile-parsing worker out of the bundle and resolves it as a sibling
file that Vite never emits, so nothing throws, nothing reaches the console,
and the map renders as a flat sheet of theme colour that reads as a design
choice rather than a broken build.

gofmt is now a gate, which it genuinely could not be before. Two migrations
carried '' inside a doc comment, and gofmt rewrites that pair into a
typographic quote -- so running it would have corrupted the comment's meaning,
which is why the project's notes said not to add this check. Rather than
accept the rewrite, those comments were reworded to avoid the construct, and
an import-ordering slip in observe_test.go was fixed. Every tracked .go file
is now clean as git stores it, verified against the index rather than the
worktree (25 files are CRLF locally, which gofmt reports and git normalises
away).

-race is deliberately NOT added. It needs cgo, which this machine does not
have, so it could not be verified locally -- and an unverified gate that fails
on someone else's pull request is worse than a missing one. The reasoning is
left in the workflow so the next person with a Linux box can make it a
one-word change. Added instead: -count=1, because setup-go caches the build
cache and a cached pass is a memory of a result from another commit, and
go mod tidy -diff, which was clean and unguarded.

pb_public/index.html is a tracked placeholder so `go build` can satisfy its
//go:embed on a fresh clone with no Node installed, and `npm run build`
overwrites it -- so a stray `git commit -a` commits the built console into the
placeholder's slot and the next fresh clone embeds a stale hashed-asset
reference. Now checked, before the build step, since afterwards the file
legitimately differs.

And HEALTHCHECK hardcoded port 8090 while docker-entrypoint.sh made the port
configurable, so setting STONE_AGE_HTTP_PORT produced a permanently unhealthy
container that was serving correctly. Shell-form CMD on alpine expands it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stone_age_http_requests_total buckets by status class, and PocketBase's router
runs its ErrorHandler AFTER the middleware chain unwinds -- so a handler that
returns an error leaves the tracked status at 0 by the time the metrics
middleware reads it. That case returned "5xx", and EVERY authorization
rejection arrives that way: 400 on a denied create, 404 on a denied update,
401/403 from RequireAuth. So the 4xx bucket sat near-empty and a 5xx alert
fired on a platform doing exactly its job. The function's own comment discussed
those very conventions and then bucketed them wrong.

The status is now resolved with router.ToApiError, which is what ErrorHandler
itself calls before WriteHeader (tools/router/router.go), and what PocketBase's
own request logging uses in apis/middlewares.go -- so the metric reports what
the client actually received.

Split into a pure statusClassFor(status, err) so the behaviour can be asserted
at all. Part of why this went unnoticed is that testing it meant constructing a
core.RequestEvent; the table test now names each of the platform's documented
conventions as its own case.

Also deletes migrations/widen_capabilities.go, which could never have run.
Migrations sort by filename, so schema_update_drop_message_schemas.go -- which
removes thing_types.capabilities -- runs before widen_capabilities.go, which
rewrites it. GetStringSlice("capabilities") was empty for every row on fresh
and upgraded databases alike, forever, and remapCapabilities was dead code. A
file whose stated job is impossible is the counter-example to the migration
discipline the rest of that package documents carefully.

Chasing that turned up two more dead writes: seedThingTypes still set
`capabilities` and `nats_role` on thing_types, both dropped with the contract
layer, and PocketBase silently discards a write to a field that does not exist
-- so it looked like it was seeding data nobody could find. The Capabilities
fixture field goes with them. The role LOOKUP stays as a fixture check, because
roleForThingType still uses tt.Role to pick the identity for each device of
that type, and a fixture naming a missing role should fail there rather than at
the first device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… config map

createWidget called createDefaultWidget and then applyWidgetDefaults, which ran
second and won every conflict. The two were not duplicates -- they had drifted,
and the older one was overwriting the newer:

- kvtable lost its reported-state twin bucket for an empty one, which made the
  TWIN_BUCKET import and its "reads reported state" comment dead code.
- button, switch and slider lost their cmd.thing.* / twin_desired subjects for
  older placeholder subjects.
- There was no `scanner` case at all: 15 branches for 16 types, surviving only
  because the other function ran first. That is the failure mode the design
  invites.

createDefaultWidget is now the only source. The titles and $.value JSON paths
that applyWidgetDefaults contributed were carried across, so what a user gets
when adding a widget is unchanged apart from the reverted values being
restored. Net -94 lines, and the literal `// ... rest of file unchanged ...`
placeholder that sat in the deleted function goes with it.

The refactor was made safe before it was made: a Vitest suite pins
createDefaultWidget across all sixteen types first, so the merge could be
checked rather than hoped. That also settled a question the diff alone could
not -- markdown's empty title is deliberate (it renders its own heading), which
the code says and my first assertion did not know.

configComponents is now Record<WidgetType, Component> rather than
Record<string, Component>. A widget type with no config component was a modal
that opened onto nothing, with no error at build time or runtime; it is now a
compile error. Verified by removing an entry and watching vue-tsc report
TS2741: Property 'scanner' is missing.

WIDGET_TYPES is a runtime list with WidgetType derived from it. The union
existed only at compile time, so nothing could iterate the types and any "is
every type handled" check needed a second hand-maintained copy of the list.
Record<WidgetType, ...> still fails to compile on a missing type, and tests can
now walk all sixteen.

Adds the frontend test runner the project did not have: Vitest, node
environment, no component mounting. The highest-risk logic here is pure --
widget defaults, the capability map, dashboard import/export, twin drift -- and
all of it was unguarded, because `vue-tsc && vite build` stays green while any
of it is wrong. CI runs it before the bundle so a logic regression fails fast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…heck count

Companion to the platform-docs truth pass.

CLAUDE.md said bootstrap is "Three commands, in this order". It is four:
`nats export` is required by `serve --nats`, which reads the operator JWT and
resolver config from disk, and it cannot run any earlier because there is no
operator in the database until `bootstrap` has run. README.md and
docker-entrypoint.sh both already had four; only the file agents read did not.

README.md still described the contract layer as declaring "what shape its
messages take", operations as carrying "an optional schema", and the console as
having a visual builder and infer-from-sample tool for it. All three went with
`message_schemas` -- and thing_type_operations has no schema field to point at.
Replaced with what is there, plus one line on why payload shape is deliberately
not described.

The authorization check count was stale in both places that state it
(CLAUDE.md, SECURITY.md) and is now the real number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 tests, all pure logic, no component mounting. `vue-tsc && vite build` stays
green while any of this is wrong, which is why these five and not others.

twinDrift is the clearest case. Twenty lines, typed (any, any), and the
platform documentation spends several hundred words on its exact semantics:
subset for objects, exact for arrays and scalars, and NO operators, ever. None
of that was pinned. A well-meaning change to make {$gt: 30} work would have
compiled, passed the build, and quietly redefined what every desired value on
every deployment means -- so one test asserts an operator-shaped desired value
is compared as a plain value. If that test ever starts passing, someone has
begun building a rules engine inside a KV browser.

useSubscriptionManager is the module singleton every live value on every
dashboard flows through, and it had no tests at all: refcounted listeners, a
shared key per core subject, close-on-last-leave, two transports. Driven by a
fake connection and a stub window, so it needs no DOM.

The `can` map is the console's entire authorization surface and it mirrors
schema.json BY HAND, with nothing checking the mirror -- test-authz.sh proves
the server half only. The full 5x8 role/capability matrix from CLAUDE.md is
transcribed here, plus the fail-closed cases: no membership resolves to null
rather than a role (it used to fall back to 'member', which is fail-OPEN), a
membership in a DIFFERENT organization grants nothing, and `dashboard` holds no
capability at all. That last one is the probe -- a role with some authority
cannot prove an allowlist, because it passes for the wrong reason.

Dashboard import/export: a round trip, new ids so importing twice does not
collide, the storage location stripped so an imported copy cannot claim to be a
shared dashboard backed by a KV key it does not own, malformed entries skipped
rather than thrown, and the limit enforced BEFORE anything is written. The
replace strategy clears every local dashboard first, which is how a user loses
work.

localStorage is stubbed rather than left absent: saveToStorage swallows its own
errors, so without a stub these tests would pass through the failure branch and
prove nothing about the path a browser runs.

Two real defects fell out of writing them. extractJsonPath was typed
`path: string` while every caller passes a possibly-undefined jsonPath -- the
body already handled undefined, only the signature disagreed. And the
manageDefinitions comment still listed message schemas, dropped some time ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It is the gate in front of every destructive action in the console -- deleting
a Thing, revoking a credential, decommissioning a device -- and it was a plain
<div>: no role="dialog", no aria-modal, no labelling, no Escape handler, no
focus trap, and `autofocus` on the DESTRUCTIVE button. Enter on a dialog nobody
had read deleted the thing.

Now: announces itself as a modal; labels and describes itself from the title
and message it already renders; hides the decorative emoji from assistive
technology; cancels on Escape; traps Tab; and returns focus to whatever opened
it.

Two decisions worth keeping.

Focus lands on the dialog CONTAINER, not a button. Nothing is pre-selected, so
Enter cannot confirm a destructive action, and the first Tab reaches Cancel
because it comes first in the DOM. The alternative -- focusing Cancel -- reads
the same but makes Enter meaningful, and the point here is that it should not
be.

Restoring focus tolerates the target having disappeared. The action just
confirmed has usually removed the row whose button opened the dialog, so the
element to hand focus back to may no longer be in the document.

Fourteen tests cover it, and they are the one place in this suite that mounts a
component: what is under test IS the DOM contract, and dialog semantics, focus
movement and key handling cannot be asserted against a pure function. The
config now documents that boundary rather than leaving the next person to guess
it. Specs opt into jsdom per file.

Writing them caught a bug in my own implementation. The focus trap filtered
candidate elements on `offsetParent !== null`, a common visibility test that is
null for EVERY element under jsdom (no layout) and for anything inside a
position:fixed subtree in some engines -- so the candidate list came back empty
and the trap was silently a no-op. Everything in this dialog is rendered by
v-if and on screen whenever the dialog exists, so there was nothing to filter
in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gofmt -l .` lists ~25 .go files on a Windows checkout because core.autocrlf
rewrites them to CRLF in the worktree while .gitattributes keeps the committed
content LF. They are all fine, CI is clean because it checks out LF, and the
gate verifies the index rather than the worktree. Worth writing down before
someone spends an hour "fixing" them or concluding the gate is broken.

Also documents `npm test`, which did not exist until this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The decommissioning fix in a626930 mirrored `active` onto `nebula_hosts` and
stopped there, because pb-nebula v0.1.0 had no revocation concept: nothing
produced a `pki.blocklist`, so the platform half was correct and did nothing.
v0.2.0 adds it. This bump is what turns that commit into a working control.

No platform code changes. pb-nebula's `options.go`, `nebula.go` and `errors.go`
are untouched between the two tags and the feature lives entirely under its
`internal/`, so `DefaultOptions()` and `Setup()` -- the only two symbols this
repo calls -- are unchanged.

One behaviour does change, and it is a dependency's, not ours: a `nebula_hosts`
record created WITHOUT an `active` field now lands active. PocketBase bools have
no schema default, and from v0.2.0 an inactive host is one whose certificate
every peer blocklists, so a host born inactive would be refused by the whole
network from the moment it was signed.

Nothing here relied on the old behaviour -- `POST /api/org/things`
(hooks/thing_routes.go:473) and the console's Nebula host form both always sent
`active` explicitly. I checked that rather than assuming it, having first
written a changelog entry claiming this had silently excluded console-minted
hosts from the certificate-expiry check; it had not.

`scripts/test-authz.sh` pins the contract anyway, at 176 checks: it belongs to a
dependency rather than to our rules, so a downgrade or a revert upstream would
otherwise be silent, and the failure mode is devices that provision cleanly and
can never join the mesh. Verified the way the others were -- against v0.1.0 the
new check FAILS ("minted Nebula host landed inactive"), against v0.2.0 it
passes.

The suite's section 15 fixture keeps its explicit `active: true` PATCH even
though v0.2.0 makes it redundant. Its comment explained the PATCH by saying a
minted host lands inactive, which is now false; the reason to keep it is that
the deactivation assertion is only meaningful if the host was active on the line
immediately before, and that must not depend on a dependency's default.

Also updated: CLAUDE.md feature 15 and the CHANGELOG entry both said this
"requires a pb-nebula newer than v0.1.0", which is no longer a caveat.

Verified: go build (both binaries), go vet, go test -count=1 ./... all ok;
176/176 authz checks; 58 sort terms resolve; 140 frontend tests; go mod tidy
-diff clean; gofmt clean on every blob git stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@skeeeon
skeeeon merged commit 82645c7 into main Sep 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant