diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d85a16a..bf8b6c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,6 +24,21 @@ jobs:
steps:
- uses: actions/checkout@v7
+ # pb_public/index.html is a tracked PLACEHOLDER, so that `go build`
+ # can satisfy its //go:embed on a fresh clone with no Node installed.
+ # `npm run build` below overwrites it, which means a stray `git commit
+ # -a` silently commits the built console into the placeholder's slot --
+ # and the next fresh clone then embeds a stale hashed-asset reference
+ # and serves a blank page. Checked here, before the build, because
+ # after it the file legitimately differs.
+ - name: pb_public is still the placeholder, not built output
+ run: |
+ if ! grep -q "Placeholder\." pb_public/index.html; then
+ echo "::error file=pb_public/index.html::This is built output, not the tracked placeholder. Restore it with: git checkout pb_public/index.html"
+ exit 1
+ fi
+ echo "ok pb_public/index.html is the placeholder"
+
# ---------------------------------------------------------------- frontend
- uses: actions/setup-node@v7
with:
@@ -37,8 +52,8 @@ jobs:
working-directory: ui
run: npm ci
- # Two dependencies in ui/package.json are deliberately held back, and the
- # reasons are written down at the top of ui/tailwind.config.js and in
+ # Four dependencies in ui/package.json are deliberately held back, and
+ # the reasons are written down at the top of ui/tailwind.config.js and in
# CLAUDE.md. Neither has any other guard: there is no frontend test
# runner, so `vue-tsc && vite build` stays green while the console renders
# wrong. That is exactly why the check is here.
@@ -50,6 +65,14 @@ jobs:
# TypeScript 6 -> TS 7 is the native port and drops the ./lib/tsc
# subpath that vue-tsc resolves at startup, so `npm run build` dies
# before it type-checks anything.
+ # maplibre-gl 5 -> v6 splits its tile-parsing worker out of the bundle
+ # and resolves it as a sibling file, which after Vite hashes the
+ # chunk points at an asset the build never emits. NOTHING throws and
+ # nothing reaches the console: the style still loads and its
+ # background layer still paints, so water, roads and labels all
+ # vanish together and the map reads as a flat sheet of theme colour
+ # -- a design choice rather than a failure. This is the pin with the
+ # least visible failure mode and it was the one not checked here.
- name: Assert the deliberately pinned majors
working-directory: ui
run: |
@@ -65,6 +88,16 @@ jobs:
check tailwindcss 3
check daisyui 4
check typescript 6
+ check maplibre-gl 5
+
+ # Pure-logic unit tests: widget defaults, the capability map, twin drift.
+ # No DOM and no component mounting -- all of it is reachable without one,
+ # and stays green while any of it is wrong, which
+ # is the gap this closes. Runs before the build so a logic regression
+ # fails fast rather than after a 16-second bundle.
+ - name: Frontend unit tests
+ working-directory: ui
+ run: npm test
# vue-tsc, then vite build into ../pb_public. The Go build below embeds
# the result, so this step is a dependency and not just a check.
@@ -81,8 +114,36 @@ jobs:
- name: Vet
run: go vet ./...
+ # Every tracked .go file is gofmt-clean as of this commit. It was not
+ # before: two migrations carried '' inside a doc comment, which gofmt
+ # rewrites into a typographic quote -- so running it would have
+ # corrupted the comment's meaning, and the note in CLAUDE.md said not to
+ # add this gate. Those comments were reworded to avoid the construct
+ # rather than accepting the rewrite, so the gate is now safe. Keep
+ # apostrophes out of Go doc comments that also contain a quoted string.
+ - name: Formatting
+ run: |
+ unformatted="$(gofmt -l .)"
+ if [ -n "$unformatted" ]; then
+ echo "::error::gofmt would rewrite these files:"; echo "$unformatted"
+ exit 1
+ fi
+
+ - name: Module hygiene
+ run: go mod tidy -diff
+
+ # -count=1 defeats the test cache. setup-go caches the build cache
+ # between runs, and a cached PASS is not a result -- it is a memory of
+ # one, from a commit that may not be this one.
+ #
+ # -race is worth adding and is deliberately not here yet: it needs cgo,
+ # which the machine this was written on does not have, so it could not
+ # be verified locally and an unverified gate that fails on somebody
+ # else's pull request is worse than a missing one. It is a one-word
+ # change once someone can run it: this package concurrently drives an
+ # embedded NATS server, a KV relay and a background prober.
- name: Test
- run: go test ./...
+ run: go test -count=1 ./...
- name: Build
run: go build ./...
@@ -94,6 +155,15 @@ jobs:
- name: Authorization rules
run: ./scripts/test-authz.sh
+ # An unknown `sort` field is a 400 before any rule is evaluated, and the
+ # message the browser gets names no field -- it fails for superusers
+ # too, so it never looks like authorization. That killed the Members and
+ # Invitations screens for every caller once already. This script asks a
+ # real server about every `sortable:` term in the console, needs the
+ # same go/curl/node as the step above, and until now nothing ran it.
+ - name: Sortable columns resolve
+ run: ./scripts/check-sort-fields.sh
+
# ---------------------------------------------------------------- image
# Nothing built the image outside the release workflow until now, so the
# Dockerfile's first exercise was always a tag push -- which is how v0.3.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9c5b3b..341d3a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,419 @@ and this file starts where the versioned releases do.
## [Unreleased]
+### Security
+
+- **A blank organization no longer matches a blank organization context.** 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.
+
+ Both halves were reachable through ordinary use. `organizations.deleteRule`
+ permitted `owner = @request.auth.id`, and 16 of the 18 relations pointing at
+ `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` at `organization = ''`. 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 before acceptance. Chained, a deleted tenant's whole
+ inventory, its NATS account record and its Nebula CA certificate became
+ readable by a user sitting 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 in the organization being claimed — the same clause
+ every *write* rule already carried, which is why only the reads were exposed.
+ The `leaf_nodes` branches needed the identical treatment on
+ `@request.auth.organization`, which an organization delete blanks for the same
+ reason: a leaf node whose organization was deleted would otherwise have
+ matched every orphaned record on the platform. Reads remain org-scoped rather
+ than role-scoped, which is deliberate and unchanged.
+
+- **`current_organization` is no longer settable at registration.**
+ `users.updateRule` froze the field to organizations the caller holds a
+ membership in; `users.createRule` did not mention it, so an invited registrant
+ could name any organization id at signup and then read that tenant's
+ inventory, because the read rules scope on exactly that field. The anonymous
+ create branch cannot check membership even in principle, so it now refuses the
+ field; `accept-invite` fills it in from the invite once the account exists.
+
+- **Deleting an organization is a platform-operator action.** It was available to
+ the organization's owner, which is what manufactured the orphaned records
+ above. Every console route that touches this collection was already
+ operator-gated, so nothing regresses.
+
+
+### Fixed
+
+- **`ConfirmDialog` is now an actual dialog.** 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 `
`: no `role="dialog"`, no
+ `aria-modal`, no labelling, no Escape handler, no focus trap, and `autofocus`
+ on the **destructive** button, so Enter on a dialog nobody had read deleted the
+ thing.
+
+ It 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 — tolerating that element being gone, since the confirmed action has often
+ removed the row whose button opened the dialog. Focus lands on the dialog
+ container rather than a button, so nothing is pre-selected and Enter cannot
+ confirm by accident; the first Tab reaches Cancel because it comes first in the
+ DOM. Also a visible `:focus-visible` ring on the buttons, and the animations
+ respect `prefers-reduced-motion`.
+
+ Fourteen tests cover it, and they are the one place in this suite that mounts
+ a component — what is under test there *is* the DOM contract. Writing them
+ caught a bug in the implementation: the focus trap filtered candidates on
+ `offsetParent !== null`, which is null for every element under jsdom and for
+ anything inside a `position: fixed` subtree in some engines, so the trap was
+ silently a no-op.
+
+- **`leaf-sync` no longer goes deaf when the local NATS server restarts.**
+ `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 about two
+ minutes after the local bus went away the agent became a zombie — the ticker
+ kept firing, every KV write 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, where `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 has to keep trying, so the
+ connection now retries indefinitely, with disconnect, reconnect and
+ closed handlers so the state is visible in the log rather than silent.
+
+ The **initial** dial still fails fast, deliberately and unchanged: without
+ `--nats` the bus is a separate process that should already be running, and a
+ hard error at startup is how an operator learns the creds path or URL is
+ wrong. The options now live in a named function so
+ `TestLocalConnectRetriesForever` can assert the invariant that was missing —
+ it fails against the previous behaviour with `MaxReconnect = 60`.
+
+- **The twin relay now retries a failed hub write instead of dropping it.** A
+ failed write was logged and discarded, on the stated grounds that the key
+ would be "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 the supervisor only restarts the pump when the *watcher* fails. So a
+ reported value that changed during an outage, failed its hub write, and then
+ never changed again was absent from the hub **permanently and silently** — in
+ the one direction the platform takes responsibility for delivering.
+
+ Failed keys are now held 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 can never write a stale reading over a newer one; a key
+ deleted locally during the outage is relayed as a delete. Holding and retrying
+ is deliberately preferred over returning an error and letting the supervisor
+ replay: one key the hub will never accept would otherwise tear down the
+ watcher on every replay and block every other key behind it, forever.
+
+ The existing partition test only ever covered convergence via a relay
+ *restart*, which is the path that always worked. `twin_retry_test.go` drives a
+ live relay against a failing destination, and waits on an observed write
+ failure rather than a timer — an earlier version cleared the fault on a poll
+ and passed against a deliberately broken build without the retry path running
+ at all.
+
+- **A short fetch no longer purges the edge mirror.** `syncCollection` paged
+ without a 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 record's absence as an upstream delete and
+ removed it from the edge. The existing empty-fetch guard only caught a *total*
+ failure; a partial walk is the dangerous case, because one record short of a
+ 400-record collection silently deletes one live config row.
+
+ Two changes: `pbclient.List` now requests `sort=id`, because a paginated walk
+ without a stable total order is wrong for any caller and the hazard belongs to
+ pagination rather than to one use of it; and the walk compares what it
+ collected against the `totalItems` it was promised, skipping the purge when it
+ came up short. A record legitimately deleted mid-walk also trips this, which
+ is a false positive worth having — the purge waits one cycle, which is the
+ safe direction to be wrong in. Records the short walk *did* return are still
+ upserted; only the deletion pass is skipped.
+
+ Multi-page reconcile had no test at all: the existing fake always reported
+ `TotalPages: 1` and its comment pointed at pbclient, which only ever parsed a
+ single page envelope.
+
+- **Decommissioning a device now closes the Nebula door too.**
+ `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, 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, which would have skipped the Nebula half entirely for
+ any device holding only a certificate.
+
+ Two properties of Nebula revocation are worth knowing rather than being
+ surprised by. 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, the same boundary as minting a NATS credential and
+ not policing what connects with it. And fingerprinting a certificate requires
+ the certificate to still be in the database, so **deactivate to revoke; do
+ not delete**. Deleting a host leaves its certificate trusted until expiry.
+
+ This needs pb-nebula v0.2.0, which is now pinned (see **Changed** below).
+ Against v0.1.0 the flag was mirrored correctly and no blocklist was ever
+ produced, so the platform half was inert but harmless.
+
+ `CLAUDE.md` also 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.
+
+- **The test harness now agrees with the binary about what a write does.**
+ `internal/testutil` bound 5 of the 13 hooks `main.go` registers, while its own
+ comment insisted the ordering was "equivalent to main.go". The two missing
+ record hooks were 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 test could create an inactive device and
+ assert on it happily while the real binary overwrote the flag.
+
+ That is precisely what had happened. `demo-seed` 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 the fixture-count tests
+ passing throughout. Binding the hook made the existing test fail immediately
+ with "no inactive things — the decommissioned state is unrepresented".
+
+ The six route registrars and `RegisterObservability` are still not bound, 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.
+
+- **`demo-seed` produces decommissioned devices again, and revokes them
+ properly.** Two bugs, one call site. Deactivation moved from create-time (where
+ the hook overwrote it) to an update, which is the only edge
+ `hooks/active_flag.go` triggers on. And the seeder no longer reaches into the
+ `nats_users` record to set `revoke` alongside `active = false`: pb-nats checks
+ `revoke` first and returns early, so the suspend branch never ran — and
+ `revoke` means "these credentials leaked", rotating the key pair and writing
+ back a fresh **working** creds file. Every "decommissioned" demo Thing was
+ therefore holding a live NATS credential, which is the exact failure
+ `active_flag.go` warns about at length.
+
+ The record is re-read before the flip, which is load-bearing: a record created
+ in memory and saved carries an empty `Original()` snapshot, so `active` reads
+ false on both sides, the hook sees no edge, and nothing cascades.
+
+ `TestDecommissionedThingsHaveTheirCredentialRevoked` now asserts the *effect*
+ rather than 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 itself, so it could not tell
+ "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.
+
+- **`ensure` no longer treats a database error as "record not found".** A
+ transient failure created a duplicate of a record that already existed; for
+ `things` that means a second row with the same code, which the
+ `UNIQUE (organization, code)` index then rejects on a later run — a seeder
+ failing for a reason with no visible connection to the outage that caused it.
+
+- **The at-rest encryption boundary is now stated instead of implied.**
+ `nats.encryption_key` / `nebula.encryption_key` protect the material needed to
+ *mint* identities — the operator seed, account seeds and signing keys, the
+ Nebula CA key. They do not protect `nats_users.creds_file` or
+ `nebula_hosts.config_yaml`, and cannot usefully: a `.creds` file *contains* the
+ user seed by construction, Nebula requires the host key inline, and the browser
+ reads `creds_file` straight from the API to open its own NATS connection — so
+ encrypting that column would force every read through a decrypting route.
+
+ So a stolen `pb_data/data.db`, with the key held separately, yields **no
+ ability to mint new identities** and **every existing credential**. That is the
+ line the feature defends, and the two halves cost very different amounts to
+ remediate: the NATS side is a central, scriptable `regenerate` with a permanent
+ revocation cutoff; the Nebula side has no CRL, so it needs re-issue plus a
+ blocklist entry in every peer plus redelivery.
+
+ No code changes beyond the `encryption_at_rest` readiness check, which reported
+ a bare "enabled for NATS and Nebula" — accurate about the config and misleading
+ about the guarantee. It now names what it covers. The at-rest threat is
+ answered by disk encryption, encrypted backups, and single-tenant deployments,
+ which is where `SECURITY.md` now points.
+
+- **CI now runs the checks that already existed.** `scripts/check-sort-fields.sh`
+ was written, worked, and was called by nothing — guarding a failure mode that
+ had already killed the Members and Invitations screens for every caller, since
+ an unknown `sort` field is a 400 raised before any rule is evaluated, names no
+ field, and fails for superusers too. It runs on every pull request now.
+
+- **The pinned-major guard covers `maplibre-gl`.** It checked Tailwind, daisyUI
+ and TypeScript, and omitted 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.
+
+- **`gofmt` is now a gate, which it could not previously be.** Two migrations
+ carried `''` inside a doc comment, and gofmt rewrites that 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. Those comments were reworded to
+ avoid the construct rather than accepting the rewrite, and an import ordering
+ slip in `observe_test.go` was fixed, so every tracked Go file is clean and the
+ gate is safe.
+
+- **`go test -count=1`**, because `setup-go` caches the build cache between runs
+ and a cached pass is a memory of a result from some other commit. Plus
+ `go mod tidy -diff`, which was clean and unguarded.
+
+- **A guard on the `pb_public/index.html` placeholder.** It is tracked 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`
+ silently commits the built console into the placeholder's slot, and the next
+ fresh clone embeds a stale hashed-asset reference. Checked before the build
+ step, since afterwards the file legitimately differs.
+
+- **`HEALTHCHECK` honours `STONE_AGE_HTTP_PORT`.** It hardcoded 8090 while
+ `docker-entrypoint.sh` made the port configurable, so setting that variable
+ produced a permanently unhealthy container that was serving correctly.
+
+- **Every API-rule rejection was counted as a server error.**
+ `stone_age_http_requests_total` bucketed by status class, and PocketBase's
+ router runs its error handler *after* the middleware chain unwinds — so a
+ handler that returns an error leaves the tracked status at 0 when the metrics
+ middleware sees 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`. The 4xx bucket sat near-empty while a `5xx` alert
+ fired on a platform doing exactly its job. The status is now resolved through
+ `router.ToApiError`, which is what the error handler itself calls before
+ writing the header.
+
+ Split into a pure `statusClassFor(status, err)` so it can be asserted at all —
+ the bug was invisible partly because nothing could construct a
+ `core.RequestEvent` to test it.
+
+- **A second widget-defaults function was silently reverting the first.**
+ `createWidget` called `createDefaultWidget` and then `applyWidgetDefaults`,
+ which ran afterwards and won every conflict — so the newer defaults in
+ `types/dashboard.ts` were being overwritten by an older copy that had drifted:
+ `kvtable` lost its reported-state twin bucket for an empty one (making the
+ `TWIN_BUCKET` import and its "reads reported state" comment dead), and
+ `button`, `switch` and `slider` lost their `cmd.thing.*` / `twin_desired`
+ subjects for older placeholders. It also had no `scanner` case at all — 15
+ branches for 16 types — surviving only because the other function ran first.
+
+ `createDefaultWidget` is now the only source. The titles and `$.value` JSON
+ paths the second function contributed were carried across, so what a new
+ widget gets is unchanged apart from the reverted values being restored; net
+ −94 lines. The file also carried a literal `// ... rest of file unchanged ...`
+ placeholder, which went with it.
+
+- **`configComponents` is typed `Record`.** It was
+ `Record`, so a widget type with no config component was a
+ modal that opened onto nothing — no error anywhere, 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
+ every "is every type handled" question needed a second, hand-maintained copy
+ of the list. `Record` still fails to compile when a type is
+ missing, and tests can now walk all sixteen.
+
+### Changed
+
+- **pb-nebula bumped to v0.2.0**, which is what makes the Nebula half of
+ decommissioning above actually do something. Against v0.1.0 `active` was
+ mirrored onto `nebula_hosts` correctly and no `pki.blocklist` was ever
+ produced, so that half was inert. No platform code changed: pb-nebula's
+ `options.go`, `nebula.go` and `errors.go` are untouched between the two tags
+ and the whole feature lives under its `internal/`, so this is a go.mod bump.
+
+ It does change one behaviour that is not the platform's own. A `nebula_hosts`
+ record created **without** an `active` field now lands active, because
+ pb-nebula forces the flag on create — PocketBase bools have no schema default,
+ and 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 in this platform relied on the old behaviour: both
+ `POST /api/org/things` and the console's Nebula host form always sent
+ `active` explicitly. `scripts/test-authz.sh` now pins the contract anyway
+ (176 checks), because it is a dependency's guarantee rather than one of our
+ rules, and a downgrade would otherwise be silent.
+
+- **A documentation truth pass**, in this repo and in `platform-docs`. The
+ headline feature list still sold message schemas — "versioned JSON Schema" —
+ which is the worst place for a stale claim, since a buyer demos the thing they
+ were sold. The widget table listed a **PocketBase** widget that has no
+ component behind it (sixteen types, none of them `pocketbase`), the role count
+ said four, the agent's binary was called `stone-age-agent` where its own
+ install snippet says `agent`, and this file said bootstrap is three commands
+ where it is four — `nats export` is required by `serve --nats` and cannot run
+ before `bootstrap`, which is why `docker-entrypoint.sh` does all four.
+
+ The authorization check count was wrong in two places and is now correct in
+ both. `README.md` also described operations as carrying "an optional schema"
+ and the console as having an infer-from-sample tool for it; both went with
+ `message_schemas`.
+
+ ADR 0002 was **amended rather than rewritten**, since an ADR records what was
+ decided: the org-code pattern now permits a leading digit, and the option that
+ would have reserved `system` and `operator` is marked as not what shipped.
+
+ Two claims a technical buyer would break are gone: "hundreds of clients using
+ a single deployment", which sat three pages from "the Control Plane scales
+ vertically" on a single-writer SQLite database, and an unsupportable
+ superlative about security that traditional platforms "simply cannot match".
+ Both are replaced with the mechanism, and with an explicit note that there are
+ no production deployments to quote figures from.
+
+ `demo-seed` is now in the getting-started guide, having been documented only
+ here despite being the fastest path from a fresh install to something legible.
+
+- **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A
+ stock edge box previously served neither `/ready` nor `/metrics`, so the one
+ place per-site health is actually visible was off unless someone opted in —
+ which is why the `nats_local` check that would have caught the reconnect bug
+ above had no consumer. Binding was already non-fatal by design, so if the
+ port is taken (node_exporter's default is the same one) leaf-sync logs a
+ warning and carries on syncing. Set it to `""` to serve neither.
+
+- `scripts/test-authz.sh` covers 172 behaviours, up from 155. The new checks
+ exercise the blank-context read path on both the user and leaf-node branches,
+ cross-tenant *reads* (previously almost untested — the suite used the
+ second-tenant token exactly once, for a write), registration-time
+ `current_organization` injection, and organization deletion. They were each
+ verified to fail against the pre-fix rules, not merely to pass against the
+ fixed ones. The suite now also exercises an **admin** token: every
+ owner/admin rule in the platform had been proven for `owner` only, so an
+ allowlist that had lost its `admin` term would have passed the entire suite.
+
### Removed
+- **`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 worse than no file: it is the
+ counter-example to the migration discipline the rest of the package documents
+ carefully.
+
+- **Two dead writes in the demo seeder.** `seedThingTypes` still set
+ `capabilities` and `nats_role` on `thing_types`, both of which were dropped
+ with the contract layer — and PocketBase silently discards a write to a field
+ that does not exist, so this looked like it was seeding data nobody could find.
+ The `Capabilities` fixture field went with them (23 initializers). 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 role that does not exist should fail there rather than at the first
+ device.
+
+
- **`message_schemas`, and the two dead fields on `thing_types`.** The Thing Type
"contract layer" was three collections; only two of them did anything. A
message schema was never validated against — there is no JSON Schema
@@ -46,6 +457,45 @@ and this file starts where the versioned releases do.
### Added
+- **126 frontend unit tests**, across the five places most dangerous to change
+ blind. All pure logic, no component mounting:
+
+ - **`twinDrift`** — twenty lines the documentation spends several hundred words
+ specifying: subset semantics for objects, exact for arrays and scalars, and
+ no operators, ever. None of it was pinned, and the function is typed
+ `(any, any)`, so a well-meaning change to make a range work would have
+ compiled, passed the build, and quietly redefined what every desired value
+ on every deployment means. One test asserts that an operator-shaped desired
+ value is compared as a plain value — if it ever starts passing, someone has
+ begun building a rules engine inside a KV browser.
+ - **`useSubscriptionManager`** — the module singleton every live value flows
+ through, previously untested: refcounted listeners, a shared key per core
+ subject, and close-on-last-leave. Driven by a fake connection.
+ - **The `can` map** — the console's entire authorization surface, mirrored from
+ `schema.json` by hand with nothing checking the mirror. The full 5×8
+ role/capability matrix from `CLAUDE.md`, transcribed, plus the fail-closed
+ cases: no membership is `null` rather than a role, a membership in a
+ *different* organization grants nothing, and `dashboard` holds nothing at
+ all.
+ - **Dashboard import/export** — a round trip, new ids on import, the storage
+ location stripped, malformed entries skipped rather than thrown, and the
+ limit enforced *before* anything is written. The `replace` strategy clears
+ every local dashboard first, so a partial import is how a user loses work.
+ - **`createDefaultWidget`** — all sixteen types (added with the runner).
+
+ Two real defects fell out of writing them: `extractJsonPath` was typed
+ `path: string` while every caller passes a possibly-undefined `jsonPath`, and
+ the `manageDefinitions` comment still listed message schemas.
+
+- **A frontend test runner.** Vitest, Node environment, no component mounting —
+ the highest-risk logic in the console is pure (widget defaults, the capability
+ map, dashboard import/export, twin drift) and all of it was previously
+ unguarded, since `vue-tsc && vite build` stays green while any of it is wrong.
+ `npm test` runs it, and CI runs it before the bundle so a logic regression
+ fails fast. The first suite pins `createDefaultWidget` across all sixteen
+ widget types, which is what made the defaults merge above safe to attempt.
+
+
- **"Infer from sample" on a type's inventory fields.** Paste one example record
as JSON on the Thing Type or Location Type form and every key becomes a typed
field to review. The helper came from `MessageSchemaFormView`; it was never
diff --git a/CLAUDE.md b/CLAUDE.md
index a05b6b3..c408fb0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -138,12 +138,17 @@ npm run dev
- PocketBase Admin: http://localhost:8090/_/
### Bootstrap (Initial Setup)
-Three commands, in this order — the order is load-bearing:
+Four commands, in this order — the order is load-bearing:
```bash
./stone-age superuser upsert admin@example.com 'password' # PB superuser + NATS $SYS seed
./stone-age migrate up # import schema.json
./stone-age bootstrap --email admin@example.com --org "System" --operator-org "816tech"
+./stone-age nats export --output ./nats-config/ # only for serve --nats
```
+The fourth is needed only by `serve --nats`, which reads the exported operator
+JWT and resolver config from disk — but it cannot run any earlier, because there
+is no operator in the database until `bootstrap` has run. `docker-entrypoint.sh`
+does all four in this order.
`bootstrap` writes `is_operator` / `is_system_org` / `is_operator_org`, which only
exist after the schema is imported. PocketBase silently drops writes to fields
that don't exist, so running `bootstrap` first yields a platform with no operator;
@@ -489,7 +494,13 @@ app.OnRecordAfterCreateSuccess("collection").BindFunc(func(e *core.RecordEvent)
in CI, so a malformed exposition would look fine and be unscrapeable —
the same argument as `TestBuildLeafConfIsAcceptedByNATSServer`. The tests
parse the output with Prometheus's own parser and run promlint over it.
-15. **Decommissioning a device** - `things.active` / `leaf_nodes.active`, owner/admin only. The flag is enforced in three places at once, because any one of them alone is a half-measure: the `authRule` (`active = true`) blocks new logins, `hooks/active_flag.go` refreshes `tokenKey` so tokens already issued die immediately, and the same hook sets `revoke` on the linked `nats_user` so the signed NATS credential stops working. Reactivating sets `regenerate`, issuing a fresh credential — the old `.creds` stays dead, because the account JWT's revocation cutoff is permanent. Distinct from a leaf node's heartbeat status, which reports whether the edge box *is* connected, not whether it *may* connect.
+15. **Decommissioning a device** - `things.active` / `leaf_nodes.active`, owner/admin only. The flag is enforced in **four** places at once, because any one alone is a half-measure: the `authRule` (`active = true`) blocks new logins; `hooks/active_flag.go` refreshes `tokenKey` so tokens already issued die immediately; the same hook mirrors `active` onto the linked `nats_user`, which is pb-nats's durable suspend switch, so the signed NATS credential stops working; and it mirrors `active` onto the linked `nebula_host`, which is what pb-nebula writes into every other host's `pki.blocklist`. Reactivating re-mints the NATS credential — the old `.creds` stays dead, because the account JWT's revocation cutoff is permanent.
+
+ **It mirrors `active`, not `revoke`.** In pb-nats `revoke` is the "these credentials leaked" button: it rotates the key pair and hands back a *working* replacement, leaving the user active. It is also checked before the active edge and returns early, so setting both in one save silently takes the revoke path — a deactivated Thing whose NATS identity is freshly re-issued and still publishing. The hook says so at length; this line used to say `revoke` and was simply wrong.
+
+ **The Nebula half takes effect on redeploy, not instantly.** Nebula has no CRL, so revocation is a fingerprint in every *peer's* config, applied when that config is redeployed and the process reloads (SIGHUP is enough). The platform's job ends when the material it hands out refuses the certificate — the same boundary as minting a NATS credential and not policing what connects with it. It also means **deactivate, do not delete**: fingerprinting a certificate requires the certificate to still be in the database, so deleting a host leaves it trusted until expiry. Requires pb-nebula v0.2.0, which go.mod pins; against v0.1.0 the flag was mirrored and no blocklist was produced. A `nebula_hosts` record created without an `active` field lands ACTIVE from v0.2.0 on, because a host born inactive is one every peer blocklists at birth -- `scripts/test-authz.sh` pins that dependency contract.
+
+ Distinct from a leaf node's heartbeat status, which reports whether the edge box *is* connected, not whether it *may* connect.
16. **Organization code — the ecosystem's namespace root** (ADR 0002 in
`platform-docs`). The rule is **ids for storage, codes for addressing**.
@@ -595,7 +606,9 @@ is that an owner cannot leave their own organization (`ui/src/stores/auth.ts`).
Editing the **organization record itself is a platform-operator action, not an
owner one**: it carries the tenancy flags (`managed`, `is_operator_org`,
`is_system_org`) and drives NATS account and Nebula CA provisioning, so no
-tenant role has an update path to it.
+tenant role has an update path to it — and, since
+`schema_update_tenancy_sentinel.go`, no delete path either: deleting an
+organization blanks rather than cascades, orphaning its entire inventory.
Rules to follow when touching authorization:
@@ -609,7 +622,44 @@ Rules to follow when touching authorization:
had no role check at all — so `dashboard` satisfied both and could write
inventory. A branch that constrains *what* may be written still has to say *who*
may write it. Every write branch names its roles.
-- **Keep a zero-authority role in the test matrix.** Both bugs above were caught
+- **An empty string is a valid match, so a blank scope is a wildcard.** The
+ third costume of the same bug, and the one no role audit could have caught:
+ every inventory read rule was `organization = @request.auth.current_organization`,
+ both sides are TEXT defaulting to `''`, and in PocketBase `'' = ''` is true. A
+ record with a blank `organization` was therefore readable by any caller whose
+ own context was blank — no rule mis-written, no role bypassed, two sentinels
+ comparing equal. Both halves were ordinary product states: deleting an
+ organization blanks `organization` on the 16 relations into it that are
+ non-cascade AND non-required (PocketBase blanks rather than deletes, via
+ `SaveNoValidate`), and `hooks/membership_lifecycle.go` blanks
+ `current_organization` on the way out of an org. The leaf branches had the
+ identical hole on `@request.auth.organization`, which is blankable for the
+ same reason. Fixed in `migrations/schema_update_tenancy_sentinel.go` by
+ requiring a non-blank context **and** a correlated membership — the membership
+ clause is the load-bearing half, because `memberships.organization` is
+ required and cascade so it can never be `''`, which kills the sentinel by
+ construction rather than by a comparison someone may later tidy away. Note
+ every *write* rule already had that clause, which is exactly why only the
+ 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."**
+- **`current_organization` is the read boundary, so guard every path that
+ writes it.** `users.updateRule` froze it to organizations the caller holds a
+ membership in; `users.createRule` did not mention it, so an invited registrant
+ could name any organization id at signup and then read that tenant. The create
+ branch is anonymous and cannot check membership even in principle, so it
+ refuses the field outright and accept-invite fills it in afterwards. A field
+ that scopes reads needs a guard on *create* as well as update.
+- **Do not put an apostrophe in a rule comment.** PocketBase tokenizes quote
+ characters in the rule string before `//` comments are stripped, so one stray
+ apostrophe re-pairs every quote after it: a later `''` literal then opens a
+ string that nothing closes, and the whole collection fails to import with
+ `invalid quoted text`. This cost a debugging cycle — the rule looked correct
+ and the failure named a fragment of the expression rather than the comment.
+ Several existing comments contain apostrophes and are harmless only because
+ no string literal follows them. Write "the organization" rather than
+ "the org's".
+- **Keep a zero-authority role in the test matrix.** The two role bugs above were caught
by the same thing: a role that holds no capability at all, used as the probe in
`scripts/test-authz.sh`. `dashboard` is that role. Don't "simplify" the suite by
testing denials with `member` — a role with *some* authority cannot prove an
@@ -620,8 +670,9 @@ Rules to follow when touching authorization:
roles, two purposes — don't merge them to save an enum entry.
- **Reads are org-scoped, not role-scoped, and that is deliberate.** Every read
rule on `things`, `locations`, `thing_types`, `location_types`,
- `thing_type_operations` and `leaf_nodes` is `organization = current_organization`
- with no role branch, so *every* role in an org — `dashboard` included — can
+ `thing_type_operations` and `leaf_nodes` scopes on the active organization plus
+ a correlated membership in it (see the sentinel bullet above), with no ROLE
+ branch, so *every* role in an org — `dashboard` included — can
`curl` the whole inventory. `viewer` therefore reads exactly what `member`
reads; the difference between them is writes plus which screens
`ui/src/router/index.ts` navigates to. Do not describe the console's
@@ -655,6 +706,27 @@ Rules to follow when touching authorization:
identity that owns them needs them (the browser's NATS connection and the admin
download button). The read rules restrict *which rows* a caller sees. Do not add
`hidden: true` to them — it breaks both and buys nothing.
+- **At-rest encryption covers minting keys, not issued credentials, and that is
+ deliberate.** `encryption_key` protects the operator seed, account seeds and
+ signing keys, and the Nebula CA key. It does NOT protect `creds_file` or
+ `config_yaml`, and cannot usefully: a `.creds` file *contains* the user seed
+ (pb-nats `jwt.FormatUserConfig`), Nebula requires the host key inline, and
+ `ui/src/stores/nats.ts` reads `creds_file` from the API to open the browser's
+ own NATS connection — a browser can never hold the key. Encrypting the column
+ would therefore force a decrypting route plus changes in the edge agent and
+ five UI call sites, and `pb-nats`'s `EncryptField`/`DecryptField` live in
+ `internal/`, so the platform cannot even call them without the library
+ exporting a primitive. `migrations/schema_update_credential_scoping.go`
+ reached the same conclusion for `hidden: true`.
+
+ What that buys is worth knowing precisely: a stolen database with the key held
+ elsewhere yields **no ability to mint new identities** and **every existing
+ credential**. Rotating the NATS side is central and cheap (`regenerate`, and
+ the account JWT's revocation cutoff is permanent); rotating the Nebula side
+ needs re-issue plus a blocklist entry in every peer plus redelivery, because
+ there is no CRL. Don't "fix" this by encrypting the column; state the boundary
+ and let disk encryption, encrypted backups and single-tenant deployments carry
+ the at-rest threat. See SECURITY.md.
- **A leaf node reads nothing in `nats_*` or `nebula_*`.** `leaf-sync config` gets
its creds, the account JWT, and the operator JWT from `GET /api/leaf/bootstrap`
(`hooks/leaf_node_routes.go`), which reads those records with the app's own
@@ -742,9 +814,14 @@ Rules to follow when touching authorization:
and `leaf_nodes.active` exist only because `hooks/active_flag.go` gives them
teeth — the flag, the token kill, and the NATS revoke are one operation. Do not
add a status field to a device without deciding what enforces it.
-- **A device's real capability is its NATS credential, not its PocketBase
- session.** Anything that takes a Thing or leaf node out of service has to reach
- `nats_users`, or it has only closed the console door.
+- **A device's real capability is its credentials, not its PocketBase session.**
+ Anything that takes a Thing or leaf node out of service has to reach
+ `nats_users` **and** `nebula_hosts`, or it has only closed some of the doors.
+ This was a live gap until the Nebula half was added: the console door and the
+ NATS door closed while the overlay network stayed open until the certificate
+ expired. `hooks/active_flag.go` mirrors the flag to both, and the two cascades
+ are independent — a device may hold either identity, both, or neither, so
+ neither may short-circuit the other.
- **Schema changes need a new `migrations/schema_update_*.go`** — editing
`schema.json` alone reaches fresh databases only. **A new non-null column with
a live rule over it needs a backfill in the same migration**: PocketBase bools
@@ -833,6 +910,21 @@ you, so pushing an absolute one would make the login form an open redirect (the
## Testing
+- `cd ui && npm test` — Vitest. Pure logic only, node environment, no component
+ mounting except `ConfirmDialog`, where the DOM contract IS the subject. Covers
+ the five files `vue-tsc && vite build` cannot protect: `twinDrift`,
+ `useSubscriptionManager`, the `can` capability map, dashboard import/export,
+ and `createDefaultWidget`. A spec that needs a DOM opts in with
+ `// @vitest-environment jsdom` on its first line.
+- **`gofmt -l .` reports ~25 files on a Windows checkout, and they are all
+ fine.** `core.autocrlf` rewrites `.go` files to CRLF in the worktree while
+ `.gitattributes` (`*.go text eol=lf`) keeps the committed content LF, so gofmt
+ sees line endings git will never store. The CI gate is plain `gofmt -l .`, and
+ it passes because CI checks out LF. To check locally the way CI will, run it
+ against what git STORES rather than the worktree:
+ `git ls-files '*.go' | while read f; do git show ":$f" > /tmp/x.go; gofmt -l /tmp/x.go; done`.
+ Do not "fix" the files `gofmt -l .` lists here, and do not conclude the gate is
+ broken.
- `go test ./...` — Go unit tests (`internal/leafsync` has the bulk of them).
Two habits worth keeping: the readiness checks that touch NATS are tested
against a **real operator-mode `nats-server`** built in the test (see
@@ -842,7 +934,7 @@ you, so pushing an absolute one would make the login form an open redirect (the
rather than string-matched, because nothing in CI scrapes it and a malformed
body looks fine in a terminal.
- `./scripts/test-authz.sh` — **run after any API-rule change in `schema.json`.**
- Builds the binary, stands up a throwaway DB, and asserts 150 authorization
+ Builds the binary, stands up a throwaway DB, and asserts 176 authorization
behaviours against a live server. The rules are the only tenancy enforcement
in the platform and nothing else type-checks them. Add a check when you add a
rule, and bump `EXPECTED_CHECKS`. Note PocketBase answers 404 (not 403) when an
diff --git a/Dockerfile b/Dockerfile
index f2913be..fd8ff96 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -126,6 +126,6 @@ EXPOSE 8090 4222 9222
# fixed by a restart, and an orchestrator configured to restart on unhealthy
# would loop instead of surfacing the actual problem.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
- CMD wget -q -O /dev/null http://127.0.0.1:8090/api/ready || exit 1
+ CMD wget -q -O /dev/null "http://127.0.0.1:${STONE_AGE_HTTP_PORT:-8090}/api/ready" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
diff --git a/README.md b/README.md
index 51ac47a..c472be9 100644
--- a/README.md
+++ b/README.md
@@ -164,14 +164,15 @@ content. Scanning happens inside an app (the `scanner` widget here, `/staff/scan
in the helpdesk); nothing ever fetches the decoded string as a destination.
**The contract layer.** Declarative device contracts describing *where* a
-participant speaks and *what shape* its messages take, so a consumer can resolve
-both from data alone:
+participant speaks, so a consumer can resolve its subjects from data alone:
- **Thing Types** (`thing_types`) define a subject prefix and a set of operations.
- **Operations** (`thing_type_operations`) declare a capability (`publish` /
- `subscribe` / `request` / `reply`), a subject suffix, and an optional schema.
-- **Message Schemas** (`message_schemas`) are versioned JSON Schema documents.
- The console has a visual builder and an infer-from-sample tool.
+ `subscribe` / `request` / `reply`) and a subject suffix.
+
+Payload *shape* is deliberately not described. A `message_schemas` collection
+held a JSON Schema per operation and was dropped: nothing validated against it,
+so an invalid schema document saved cleanly and rendered zero fields.
**Edge sites.** Each is a `leaf_nodes` record — a special kind of thing, with one
server-provisioned NATS user. The separate [`leaf-sync`](./cmd/leaf-sync/README.md)
diff --git a/SECURITY.md b/SECURITY.md
index 325e410..8649278 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -44,7 +44,7 @@ phrased as "the UI lets role X do Y" is a UI bug; a finding phrased as "`curl` a
role X does Y" is a security bug.
Those rules are plain strings in a JSON file, with no compiler and no type
-checker. `scripts/test-authz.sh` stands up a throwaway server and asserts 140
+checker. `scripts/test-authz.sh` stands up a throwaway server and asserts 175
authorization behaviours against it, and CI runs it on every pull request. If you
find a hole, a failing check in that script is the most useful possible bug
report.
@@ -56,6 +56,37 @@ host config). What protects them is *which rows* a caller can see. A report that
one of these fields is "exposed" needs to show a caller reading a row that is not
theirs.
+**At-rest encryption covers minting keys, not issued credentials.** With
+`nats.encryption_key` and `nebula.encryption_key` set, the operator seed, the
+account seeds and signing keys, and the Nebula CA private key are encrypted in
+the database. `nats_users.creds_file` and `nebula_hosts.config_yaml` are **not**,
+and cannot usefully be: a `.creds` file *contains* the user seed by construction
+(`jwt.FormatUserConfig`), and Nebula's PKI requires the host key inline. The
+browser also reads `creds_file` straight from the API to open its own NATS
+connection, and a browser can never hold the encryption key — so encrypting that
+column would force every read through a server-side decrypting route.
+
+So the boundary a stolen `pb_data/data.db` runs into, with the key held
+separately, is this: the attacker **cannot mint new identities** — no operator
+seed, no account signing keys, no CA key — but **does** obtain every existing
+NATS credential and every overlay host config. That is the line the feature
+actually defends, and it is worth stating because the two halves have very
+different remediation costs:
+
+- **NATS**: rotate. Set `regenerate` on each `nats_users` row; the revocation
+ cutoff in the account JWT is permanent, so the old `.creds` stays dead.
+ Central, scriptable, and delivery already exists (`GET /api/leaf/bootstrap`,
+ the console download).
+- **Nebula**: re-issue *and* blocklist *and* redeliver. There is no CRL, so a
+ revoked certificate is a fingerprint in every peer's `pki.blocklist`, applied
+ when that peer's config is redeployed. This is the expensive half.
+
+Treat a `pb_data` compromise as requiring both. The controls that actually
+address it are operational rather than cryptographic — full-disk encryption on
+the host, encrypted backups, and for tenants who need the blast radius to be
+zero by construction, a dedicated single-tenant deployment with its own
+database, operator seed and CA.
+
## Out of scope
- Anything requiring PocketBase superuser access. A superuser bypasses every API
diff --git a/cmd/leaf-sync/README.md b/cmd/leaf-sync/README.md
index 746292b..897d26e 100644
--- a/cmd/leaf-sync/README.md
+++ b/cmd/leaf-sync/README.md
@@ -55,7 +55,7 @@ working directory or `/etc/leaf-sync/`.
| `nats.monitor_url` | | The leaf's own monitoring endpoint, the `http:` line `config` writes (default `http://127.0.0.1:8222`). Loopback and unauthenticated by design — how the edge reads its own server without a `$SYS` credential. Empty disables the checks and metrics that need it. |
| `output.dir` | | Where `config` writes files (default `.`). |
| `sync.interval` | | Full-reconcile cadence (default `30s`). |
-| `observability.addr` | | Serve `/ready` + `/metrics` here (default empty = not served; the checks still run and still log). |
+| `observability.addr` | `127.0.0.1:9100` | Serve `/ready` + `/metrics` here. Loopback by default; use `0.0.0.0:9100` to scrape remotely, or `""` to serve neither (the checks still run and still log). A port already in use is logged, never fatal. |
| `observability.metrics_token` | | Closes `/metrics`; accepted as Bearer or Basic-with-any-username (default empty = open). |
| `observability.interval` | | How often the readiness checks run (default `15s`). |
| `twin.enabled` | | Turn on [twin sync](#twin-sync-data-plane) (default `false`). Requires `nats.hub_domain`. |
diff --git a/cmd/leaf-sync/leaf-sync.example.yaml b/cmd/leaf-sync/leaf-sync.example.yaml
index f9e3a70..ffb6759 100644
--- a/cmd/leaf-sync/leaf-sync.example.yaml
+++ b/cmd/leaf-sync/leaf-sync.example.yaml
@@ -93,7 +93,11 @@ twin:
# metrics_token: empty means open. When set, accepted as `Authorization: Bearer
# ` or as HTTP Basic with any username and the token as the password.
observability:
- addr: "" # e.g. 127.0.0.1:9100, or 0.0.0.0:9100 to scrape remotely
+ addr: "127.0.0.1:9100" # 0.0.0.0:9100 to scrape remotely; "" serves neither.
+ # Loopback is the default because this is the only
+ # place per-site health is visible. If the port is
+ # taken (node_exporter uses it too) leaf-sync logs
+ # and carries on -- binding is never fatal.
metrics_token: ""
interval: 15s
diff --git a/config.yaml b/config.yaml
index 7836377..981e800 100644
--- a/config.yaml
+++ b/config.yaml
@@ -54,6 +54,12 @@ nats:
# characters when set; empty disables encryption. Prefer providing this via
# env var (STONE_AGE_NATS_ENCRYPTION_KEY) rather than committing the key.
# Losing the key means losing access to encrypted records.
+ #
+ # Covers the operator seed and the account seeds/signing keys — the material
+ # needed to MINT identities. It does not cover creds_file, which contains the
+ # user seed by construction and is read straight from the API by the browser
+ # to open its own NATS connection. See SECURITY.md for what a stolen database
+ # does and does not yield.
encryption_key: ""
nebula:
diff --git a/go.mod b/go.mod
index c369e7f..abdacfa 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
github.com/prometheus/common v0.70.1
github.com/skeeeon/pb-audit v0.1.0
github.com/skeeeon/pb-nats v0.2.1
- github.com/skeeeon/pb-nebula v0.1.0
+ github.com/skeeeon/pb-nebula v0.2.0
github.com/skeeeon/pb-tenancy v0.1.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
diff --git a/go.sum b/go.sum
index c762afd..eeff96a 100644
--- a/go.sum
+++ b/go.sum
@@ -108,8 +108,8 @@ github.com/skeeeon/pb-audit v0.1.0 h1:tGxclLxp/jPtwE6PyTeCECwxQuAmlv9Idhcuj20m1E
github.com/skeeeon/pb-audit v0.1.0/go.mod h1:SxFiaL6i8asVlgpdZ9LQ2SXgQrS7FjcV4Q9/11XbFvg=
github.com/skeeeon/pb-nats v0.2.1 h1:DtiD/BhdP4Z6itzg0L7LD7U39F4YpI6KVcZZcVtjac8=
github.com/skeeeon/pb-nats v0.2.1/go.mod h1:3HWYpwr/Rq+aoILi5ywiQ5e1r7e7Pxu9zvJv+++Z0xM=
-github.com/skeeeon/pb-nebula v0.1.0 h1:JpRLL9nphQJNoOBATUzhbOqQGwosIIewKUhEv5qLClg=
-github.com/skeeeon/pb-nebula v0.1.0/go.mod h1:1iDAMF48xWxMMwk56559cRu3WbVapwVHi80vN+lOQgQ=
+github.com/skeeeon/pb-nebula v0.2.0 h1:3Gr16aUmanivOd50hmrkoLelJzAnqB01I/rht2ecci0=
+github.com/skeeeon/pb-nebula v0.2.0/go.mod h1:1iDAMF48xWxMMwk56559cRu3WbVapwVHi80vN+lOQgQ=
github.com/skeeeon/pb-tenancy v0.1.0 h1:Z2kZjMUqjvm7tgccocYHX/9raqjIQATBIMzzD5+AlS4=
github.com/skeeeon/pb-tenancy v0.1.0/go.mod h1:Jfr+y+KPiIqaiT9TSDiKps0ZdzEzIJ6e8NzFnqknCyU=
github.com/slackhq/nebula v1.11.0 h1:UVSVPTFw1/QxrfH/MIm27tzxGWn2dSnXZRGh7q0VhsQ=
diff --git a/hooks/active_flag.go b/hooks/active_flag.go
index 9e4e591..ffb7011 100644
--- a/hooks/active_flag.go
+++ b/hooks/active_flag.go
@@ -10,9 +10,10 @@ import (
// ActiveFlagOptions names the collections that carry an `active` flag whose
// meaning this file enforces.
type ActiveFlagOptions struct {
- ThingCollection string
- LeafNodeCollection string
- NatsUserCollection string
+ ThingCollection string
+ LeafNodeCollection string
+ NatsUserCollection string
+ NebulaHostCollection string
}
// RegisterActiveFlag makes `active` on things and leaf_nodes mean something.
@@ -31,11 +32,12 @@ type ActiveFlagOptions struct {
// NATS user JWT held by its linked nats_users record. Nothing PocketBase does
// to the Thing touches that.
//
-// So deactivation does three things, not one:
+// So deactivation does four things, not one:
//
// active = false -> authRule blocks new logins
// -> RefreshTokenKey() invalidates every outstanding token now
// -> active = false on the linked NATS identity
+// -> active = false on the linked Nebula host
//
// and re-activation issues a fresh NATS credential, since the revocation cutoff
// in the account JWT is permanent and the old creds file stays dead.
@@ -55,6 +57,22 @@ type ActiveFlagOptions struct {
// save silently takes the revoke path — the failure mode is a deactivated Thing
// whose NATS identity is freshly re-issued and still publishing.
//
+// The Nebula half is the same mirror for the same reason, and it closed the last
+// door left open: a device taken out of service kept a valid overlay-network
+// certificate until it expired, so "decommissioned" closed the console door and
+// the NATS door and left the mesh door wide open. `active` on nebula_hosts is
+// what pb-nebula puts into every OTHER host's `pki.blocklist`, because Nebula
+// has no CRL — revocation is a fingerprint list each peer carries, not a central
+// record. Two consequences follow from that shape and neither is a bug here:
+//
+// - It takes effect when each peer's config is redeployed and reloaded. The
+// platform's job ends when the material it hands out says the certificate is
+// refused, exactly as it ends at minting a NATS credential rather than
+// policing what connects with it.
+// - Revocation needs the certificate to still be in the database to fingerprint
+// it, so DEACTIVATING a device revokes it and DELETING one does not. That is
+// worth knowing before deleting a Thing you actually want off the mesh.
+//
// A flag that turns a badge red while the device keeps publishing is worse than
// no flag, because someone will trust it during an incident.
//
@@ -97,40 +115,60 @@ func RegisterActiveFlag(app *pocketbase.PocketBase, opts ActiveFlagOptions) {
return err
}
- // Past this point the flip is committed. Cascade to NATS.
- natsUserID := e.Record.GetString("nats_user")
- if natsUserID == "" {
- return nil
- }
+ // Past this point the flip is committed. Cascade to whichever identities
+ // this device actually holds. The two are independent — a Thing may carry
+ // a NATS identity, a Nebula host, both or neither — so neither is allowed
+ // to short-circuit the other. The previous version returned early when
+ // `nats_user` was empty, which would have skipped the Nebula half for any
+ // device that had only a certificate.
+ mirrorActiveFlag(e, opts.NatsUserCollection, "nats_user", "NATS identity", now)
+ mirrorActiveFlag(e, opts.NebulaHostCollection, "nebula_host", "Nebula host", now)
+ return nil
+ })
+}
- natsUser, err := e.App.FindRecordById(opts.NatsUserCollection, natsUserID)
- if err != nil {
- log.Printf("⚠️ %s '%s' active=%v but its NATS identity %s could not be loaded: %v",
- e.Record.Collection().Name, e.Record.Id, now, natsUserID, err)
- return nil
- }
+// mirrorActiveFlag copies a device's `active` flag onto one linked identity.
+//
+// Mirror the flag and nothing else. Both libraries watch `active` on
+// OnRecordUpdate — the model hook, not the request hook — so this one save is
+// what triggers the revoke or the reissue. On the NATS side, setting
+// `regenerate` as well would be worse than redundant: pb-nats checks the active
+// edge first and returns before the regenerate branch runs, leaving the flag set
+// on the row to fire on some unrelated later save.
+//
+// Best-effort and logged, never fatal, matching RegisterLeafNodeProvisioning: a
+// hiccup in NATS or Nebula must not roll back the operator's deactivation. The
+// part that is transactional with the record write — the token kill — has
+// already succeeded by the time this runs.
+func mirrorActiveFlag(e *core.RecordEvent, collection, relationField, label string, now bool) {
+ if collection == "" {
+ return // not configured on this deployment
+ }
+ linkedID := e.Record.GetString(relationField)
+ if linkedID == "" {
+ return // nothing linked
+ }
- // Mirror the flag and nothing else. pb-nats watches `active` on
- // OnRecordUpdate (the model hook, not the request hook), so this one save
- // is what triggers the revoke or the reissue. Setting `regenerate` as well
- // would be worse than redundant: the active edge returns before the
- // regenerate branch runs, leaving the flag set on the row to fire on some
- // unrelated later save.
- natsUser.Set("active", now)
+ linked, err := e.App.FindRecordById(collection, linkedID)
+ if err != nil {
+ log.Printf("⚠️ %s '%s' active=%v but its %s %s could not be loaded: %v",
+ e.Record.Collection().Name, e.Record.Id, now, label, linkedID, err)
+ return
+ }
- if err := e.App.Save(natsUser); err != nil {
- log.Printf("❌ %s '%s' active=%v but the NATS cascade failed for identity %s: %v",
- e.Record.Collection().Name, e.Record.Id, now, natsUserID, err)
- return nil
- }
+ linked.Set("active", now)
- if now {
- log.Printf("✅ %s '%s' reactivated; NATS identity %s re-issued",
- e.Record.Collection().Name, e.Record.Id, natsUserID)
- } else {
- log.Printf("🔒 %s '%s' deactivated; tokens invalidated and NATS identity %s revoked",
- e.Record.Collection().Name, e.Record.Id, natsUserID)
- }
- return nil
- })
+ if err := e.App.Save(linked); err != nil {
+ log.Printf("❌ %s '%s' active=%v but the cascade failed for %s %s: %v",
+ e.Record.Collection().Name, e.Record.Id, now, label, linkedID, err)
+ return
+ }
+
+ if now {
+ log.Printf("✅ %s '%s' reactivated; %s %s re-enabled",
+ e.Record.Collection().Name, e.Record.Id, label, linkedID)
+ } else {
+ log.Printf("🔒 %s '%s' deactivated; tokens invalidated and %s %s revoked",
+ e.Record.Collection().Name, e.Record.Id, label, linkedID)
+ }
}
diff --git a/hooks/metrics.go b/hooks/metrics.go
index 9329fbb..5fd8566 100644
--- a/hooks/metrics.go
+++ b/hooks/metrics.go
@@ -10,6 +10,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/prometheus/client_golang/prometheus"
"platform/internal/metrics"
@@ -99,7 +100,7 @@ func (c *dbCollector) Collect(ch chan<- prometheus.Metric) {
// a legitimate value here ("no leaf nodes configured"), so emitting it on
// failure would turn a broken query into a confident wrong answer — and an
// alert on `== 0` would fire for the wrong reason. The absent series plus
- // stone_age_collector_errors_total says what actually happened.
+ // stone_age_collector_errors says what actually happened.
count := func(collection string, desc *prometheus.Desc, labels []string, exprs ...dbx.Expression) {
if collection == "" {
return
@@ -417,15 +418,35 @@ func routePattern(re *core.RequestEvent) string {
// denied create, so "how many 404s" is a question about authorization, traffic
// and genuinely missing records all at once. The class is what an alert wants;
// the audit log and the access log have the specifics.
+// A status of 0 means the response was never written: a handler returned an
+// error and the router's ErrorHandler runs AFTER the middleware chain unwinds,
+// so the tracked status is still unset when this sees it. Resolving that error
+// the way the router does is the only way to bucket it correctly.
+//
+// It used to return "5xx" for every such case, which made this metric actively
+// misleading rather than merely coarse: EVERY API-rule rejection arrives here
+// as an error with no written status — 400 on a denied create, 404 on a denied
+// update, 401/403 from RequireAuth — so ordinary authorization traffic was
+// counted as server errors. The 4xx bucket sat near-empty and a 5xx alert fired
+// on a healthy platform doing its job.
+//
+// router.ToApiError is exactly what ErrorHandler calls before WriteHeader
+// (tools/router/router.go), so this reports the status the client actually got;
+// PocketBase's own request logging resolves it the same way in
+// apis/middlewares.go.
func statusClass(re *core.RequestEvent, err error) string {
- status := re.Status()
+ return statusClassFor(re.Status(), err)
+}
+
+// statusClassFor is the whole decision, split out so it can be tested without
+// constructing a core.RequestEvent. The bug it now encodes was invisible
+// precisely because nothing could assert on it.
+func statusClassFor(status int, err error) string {
if status == 0 {
- // The response was never written — an error short-circuited the chain
- // before the status was set.
- if err != nil {
- return "5xx"
+ if err == nil {
+ return "unknown"
}
- return "unknown"
+ status = router.ToApiError(err).Status
}
return strconv.Itoa(status/100) + "xx"
}
diff --git a/hooks/metrics_status_test.go b/hooks/metrics_status_test.go
new file mode 100644
index 0000000..467c0ec
--- /dev/null
+++ b/hooks/metrics_status_test.go
@@ -0,0 +1,94 @@
+package hooks
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+// An API-rule rejection must be counted as a 4xx.
+//
+// This is the bug the split-out function exists to pin. 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
+// sees it. The previous code answered "5xx" for every such case -- and EVERY
+// authorization rejection arrives that way: 400 on a denied create, 404 on a
+// denied update, 401/403 from RequireAuth. The 4xx bucket sat near-empty while a
+// 5xx alert fired on a platform doing exactly its job.
+//
+// The statuses below are this platform's own documented conventions, which is
+// why they are the cases worth naming.
+func TestStatusClassCountsRuleRejectionsAsClientErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ err error
+ want string
+ }{
+ {
+ name: "denied create (PocketBase answers 400)",
+ err: apis.NewBadRequestError("", nil),
+ want: "4xx",
+ },
+ {
+ name: "denied update (PocketBase answers 404, not 403, deliberately)",
+ err: apis.NewNotFoundError("", nil),
+ want: "4xx",
+ },
+ {
+ name: "unauthenticated (RequireAuth)",
+ err: apis.NewUnauthorizedError("", nil),
+ want: "4xx",
+ },
+ {
+ name: "forbidden",
+ err: apis.NewForbiddenError("", nil),
+ want: "4xx",
+ },
+ {
+ name: "a genuine server error is still 5xx",
+ err: apis.NewInternalServerError("", nil),
+ want: "5xx",
+ },
+ {
+ name: "a written status wins over the error",
+ status: 201,
+ err: nil,
+ want: "2xx",
+ },
+ {
+ name: "no status and no error is not a guess",
+ status: 0,
+ err: nil,
+ want: "unknown",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := statusClassFor(tc.status, tc.err); got != tc.want {
+ t.Errorf("statusClassFor(%d, %v) = %q, want %q", tc.status, tc.err, got, tc.want)
+ }
+ })
+ }
+}
+
+// A plain error carries no status of its own. PocketBase's ErrorHandler resolves
+// it through the same ToApiError call used here, which generalises it to 400 --
+// so this asserts the bucket matches what the client is actually sent, rather
+// than what a reader might assume an unclassified error means.
+func TestStatusClassMatchesWhatTheRouterWouldSend(t *testing.T) {
+ plain := errors.New("something went wrong")
+
+ sent := router.ToApiError(plain).Status
+ want := "4xx"
+ if sent >= 500 {
+ want = "5xx"
+ }
+
+ if got := statusClassFor(0, plain); got != want {
+ t.Errorf("statusClassFor(0, plain error) = %q, but the router would send %d (%s)", got, sent, want)
+ }
+}
diff --git a/hooks/readiness.go b/hooks/readiness.go
index b50047c..81d735c 100644
--- a/hooks/readiness.go
+++ b/hooks/readiness.go
@@ -248,7 +248,11 @@ func registerPlatformChecks(app core.App, reg *health.Registry, opts Observabili
}
switch len(off) {
case 0:
- return health.OK("enabled for NATS and Nebula")
+ // Deliberately says what it covers. "enabled" on its own reads as
+ // "credentials are encrypted at rest", which is not what this buys:
+ // creds_file and config_yaml are plaintext by construction. See
+ // SECURITY.md.
+ return health.OK("enabled for NATS and Nebula (minting keys; issued credentials are plaintext by construction)")
case 2:
return health.Warn(
"disabled: NATS seeds and Nebula private keys are stored in plaintext",
diff --git a/internal/demoseed/contract.go b/internal/demoseed/contract.go
index dc62fbb..fbebbed 100644
--- a/internal/demoseed/contract.go
+++ b/internal/demoseed/contract.go
@@ -126,7 +126,6 @@ const (
type thingTypeFixture struct {
Org, Code, Name, Description string
SubjectPrefix string
- Capabilities []string
Operations []string // operation names, within the same org
Role string // nats_roles name to default to
Kind thingKind
@@ -144,7 +143,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "temp-probe", Name: "Temperature Probe", Kind: kindDevice,
Description: "Wireless probe reporting one temperature and its own battery.",
SubjectPrefix: "telemetry.{location}.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_temperature", "publish_battery", "publish_heartbeat"},
Role: "device",
Schema: objSchema(map[string]any{
@@ -159,7 +157,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "door-sensor", Name: "Dock Door Sensor", Kind: kindDevice,
Description: "Magnetic contact on a dock or zone door.",
SubjectPrefix: "event.{location}.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_door", "publish_battery", "publish_heartbeat"},
Role: "device",
Schema: objSchema(map[string]any{
@@ -173,7 +170,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "reefer-unit", Name: "Reefer Controller", Kind: kindDevice,
Description: "Trailer refrigeration controller. Takes a setpoint and echoes the one in force.",
SubjectPrefix: "asset.{location}.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"publish_temperature", "subscribe_setpoint",
"publish_setpoint_echo", "reply_diagnostics", "publish_heartbeat"},
Role: "device",
@@ -187,7 +183,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "edge-gateway", Name: "Edge Gateway", Kind: kindGateway,
Description: "Site aggregator. Runs the leaf node and rule-router.",
SubjectPrefix: "gateway.{location}.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"publish_heartbeat", "reply_diagnostics"},
Role: "gateway",
Schema: objSchema(map[string]any{
@@ -200,7 +195,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "wms-connector", Name: "WMS Connector", Kind: kindApp,
Description: "Software participant. Lifts shipment events out of the warehouse management system and answers inventory requests.",
SubjectPrefix: "app.wms.{thing}",
- Capabilities: []string{"publish", "reply"},
Operations: []string{"publish_shipment", "request_inventory"},
Role: "application",
Schema: objSchema(map[string]any{
@@ -212,7 +206,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "coldchain-rules", Name: "Cold Chain Rule Engine", Kind: kindApp,
Description: "A rule-router instance watching the temperature stream and raising excursions.",
SubjectPrefix: "app.rules.{thing}",
- Capabilities: []string{"publish", "subscribe"},
Operations: []string{"publish_heartbeat"},
Role: "application",
Schema: objSchema(map[string]any{
@@ -234,7 +227,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "access-controller", Name: "Access Controller", Kind: kindGateway,
Description: "A stone-access edge controller. Decides credential presentations locally against a mirrored policy graph, and keeps deciding when the WAN is down.",
SubjectPrefix: "acc.{location}.ctrl.{thing}",
- Capabilities: []string{"publish", "subscribe"},
Operations: []string{"publish_controller_heartbeat", "publish_access_state"},
Role: "gateway",
Schema: objSchema(map[string]any{
@@ -249,7 +241,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "access-door", Name: "Access-Controlled Door", Kind: kindDevice,
Description: "A door with a reader, a strike or maglock, and door-position monitoring. Its code is the portal code in stone-access.",
SubjectPrefix: "acc.{location}.door.{thing}",
- Capabilities: []string{"publish", "subscribe"},
Operations: []string{"publish_access_decision", "publish_access_alarm", "publish_access_state",
"subscribe_access_tap", "subscribe_access_grant", "subscribe_access_posture"},
Role: "device",
@@ -265,7 +256,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "access-gate", Name: "Access-Controlled Gate", Kind: kindDevice,
Description: "A vehicle gate on the same controller as the doors, with a much longer held-open threshold.",
SubjectPrefix: "acc.{location}.gate.{thing}",
- Capabilities: []string{"publish", "subscribe"},
Operations: []string{"publish_access_decision", "publish_access_alarm",
"subscribe_access_tap", "subscribe_access_grant"},
Role: "device",
@@ -280,7 +270,6 @@ var thingTypes = []thingTypeFixture{
{Org: "northwind", Code: "dock-display", Name: "Dock Display", Kind: kindAppliance,
Description: "Unattended screen above a dock door. Subscribes only.",
SubjectPrefix: "display.{location}.{thing}",
- Capabilities: []string{"subscribe"},
Operations: []string{"subscribe_render"},
Role: "console-readonly",
Schema: objSchema(map[string]any{
@@ -292,7 +281,6 @@ var thingTypes = []thingTypeFixture{
{Org: "ironbridge", Code: "power-meter", Name: "Panel Power Meter", Kind: kindDevice,
Description: "Three-phase meter on a distribution panel.",
SubjectPrefix: "telemetry.{location}.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_power", "publish_heartbeat"},
Role: "device",
Schema: objSchema(map[string]any{
@@ -306,7 +294,6 @@ var thingTypes = []thingTypeFixture{
{Org: "ironbridge", Code: "vib-sensor", Name: "Vibration Sensor", Kind: kindDevice,
Description: "Bearing vibration monitor. Reduces on-device and publishes a summary.",
SubjectPrefix: "telemetry.{location}.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_vibration", "publish_heartbeat"},
Role: "device",
Schema: objSchema(map[string]any{
@@ -320,7 +307,6 @@ var thingTypes = []thingTypeFixture{
{Org: "ironbridge", Code: "line-controller", Name: "Line Controller", Kind: kindDevice,
Description: "PLC front-end for one production line. Counts cycles, raises alarms, takes a mode.",
SubjectPrefix: "line.{location}.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"publish_cycle", "publish_alarm", "subscribe_line_mode",
"publish_line_mode_echo", "reply_diagnostics", "publish_heartbeat"},
Role: "device",
@@ -333,7 +319,6 @@ var thingTypes = []thingTypeFixture{
{Org: "ironbridge", Code: "edge-gateway", Name: "Edge Gateway", Kind: kindGateway,
Description: "Plant aggregator. Runs the leaf node and rule-router.",
SubjectPrefix: "gateway.{location}.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"publish_heartbeat", "reply_diagnostics"},
Role: "gateway",
Schema: objSchema(map[string]any{
@@ -346,7 +331,6 @@ var thingTypes = []thingTypeFixture{
{Org: "ironbridge", Code: "oee-analytics", Name: "OEE Analytics", Kind: kindApp,
Description: "Stream processor. Windows the cycle stream into rolling availability, performance and quality.",
SubjectPrefix: "app.oee.{thing}",
- Capabilities: []string{"publish", "subscribe"},
Operations: []string{"publish_oee", "publish_heartbeat"},
Role: "application",
Schema: objSchema(map[string]any{
@@ -358,7 +342,6 @@ var thingTypes = []thingTypeFixture{
{Org: "ironbridge", Code: "mes-connector", Name: "MES Connector", Kind: kindApp,
Description: "Software participant bridging the manufacturing execution system.",
SubjectPrefix: "app.mes.{thing}",
- Capabilities: []string{"publish", "request"},
Operations: []string{"publish_heartbeat"},
Role: "application",
Schema: objSchema(map[string]any{
@@ -370,7 +353,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "turbine-ctl", Name: "Turbine Controller", Kind: kindDevice,
Description: "Per-turbine controller. Reports generation and accepts a curtailment ceiling.",
SubjectPrefix: "turbine.{location}.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"publish_generation", "publish_alarm", "subscribe_curtail",
"publish_curtail_echo", "reply_diagnostics", "publish_heartbeat"},
Role: "device",
@@ -385,7 +367,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "feeder-relay", Name: "Feeder Protection Relay", Kind: kindDevice,
Description: "Substation feeder relay reporting measurements and protection events.",
SubjectPrefix: "telemetry.{location}.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_feeder", "publish_alarm", "publish_heartbeat"},
Role: "device",
Schema: objSchema(map[string]any{
@@ -400,7 +381,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "met-mast", Name: "Met Mast", Kind: kindDevice,
Description: "Meteorological mast. Wind speed and bearing for the whole collection area.",
SubjectPrefix: "telemetry.{location}.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_generation", "publish_heartbeat"},
Role: "device",
Schema: objSchema(map[string]any{
@@ -411,7 +391,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "edge-gateway", Name: "Edge Gateway", Kind: kindGateway,
Description: "Substation aggregator on cellular backhaul. Runs the leaf node so the site survives a WAN outage.",
SubjectPrefix: "gateway.{location}.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"publish_heartbeat", "reply_diagnostics"},
Role: "gateway",
Schema: objSchema(map[string]any{
@@ -425,7 +404,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "scada-bridge", Name: "SCADA Bridge", Kind: kindApp,
Description: "Software participant translating between the historian and the bus.",
SubjectPrefix: "app.scada.{thing}",
- Capabilities: []string{"publish", "subscribe", "reply"},
Operations: []string{"request_forecast", "publish_heartbeat"},
Role: "application",
Schema: objSchema(map[string]any{
@@ -436,7 +414,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "market-feed", Name: "ISO Market Feed", Kind: kindApp,
Description: "Software participant publishing dispatch instructions from the ISO.",
SubjectPrefix: "app.market.{thing}",
- Capabilities: []string{"publish"},
Operations: []string{"publish_dispatch", "publish_heartbeat"},
Role: "application",
Schema: objSchema(map[string]any{
@@ -447,7 +424,6 @@ var thingTypes = []thingTypeFixture{
{Org: "galewind", Code: "ops-wallboard", Name: "Operations Wallboard", Kind: kindAppliance,
Description: "Unattended screen in the operations centre. Subscribes only.",
SubjectPrefix: "display.{location}.{thing}",
- Capabilities: []string{"subscribe"},
Operations: []string{},
Role: "console-readonly",
Schema: objSchema(map[string]any{
diff --git a/internal/demoseed/seed.go b/internal/demoseed/seed.go
index 470e7cd..bb895cb 100644
--- a/internal/demoseed/seed.go
+++ b/internal/demoseed/seed.go
@@ -29,7 +29,9 @@ package demoseed
import (
"crypto/rand"
+ "database/sql"
"encoding/hex"
+ "errors"
"fmt"
mathrand "math/rand"
"sort"
@@ -170,9 +172,19 @@ func Run(app core.App, opts Options) (*Result, error) {
// new, so hand-edits to demo data survive a re-run — someone who renames a Thing
// to try something out does not have it silently reverted by the next seed.
func (s *seeder) ensure(collection, filter string, params dbx.Params, fill func(*core.Record)) (*core.Record, bool, error) {
- if existing, err := s.app.FindFirstRecordByFilter(collection, filter, params); err == nil && existing != nil {
+ // A genuine database error must not be read as "not found". Treating every
+ // error that way makes a transient failure create a duplicate of a record
+ // that already exists — and for `things` that means a second Thing with the
+ // same code, which the UNIQUE (organization, code) index would then reject
+ // on a later run, so the seeder starts failing for a reason with no visible
+ // connection to the outage that caused it.
+ existing, err := s.app.FindFirstRecordByFilter(collection, filter, params)
+ switch {
+ case err == nil && existing != nil:
s.res.mark(collection, false)
return existing, false, nil
+ case err != nil && !errors.Is(err, sql.ErrNoRows):
+ return nil, false, fmt.Errorf("look up existing %s: %w", collection, err)
}
col, err := s.app.FindCollectionByNameOrId(collection)
if err != nil {
@@ -564,8 +576,12 @@ func (s *seeder) seedThingTypes() error {
opIDs = append(opIDs, id)
}
- roleID, ok := s.roles[tt.Org+":"+tt.Role]
- if !ok {
+ // Validated, not stored. thing_types.nats_role was dropped with the rest
+ // of the contract layer, but the fixture still names a role because
+ // roleForThingType uses it to pick the identity for each THING of this
+ // type -- so a fixture naming a role that does not exist should still
+ // fail here rather than at the first device.
+ if _, ok := s.roles[tt.Org+":"+tt.Role]; !ok {
return fmt.Errorf("thing type %q references unknown NATS role %q", tt.Code, tt.Role)
}
@@ -576,9 +592,7 @@ func (s *seeder) seedThingTypes() error {
r.Set("name", tt.Name)
r.Set("description", tt.Description)
r.Set("subject_prefix", tt.SubjectPrefix)
- r.Set("capabilities", tt.Capabilities)
r.Set("operations", opIDs)
- r.Set("nats_role", roleID)
if tt.Schema != nil {
r.Set("metadata_schema", tt.Schema)
}
diff --git a/internal/demoseed/seed_test.go b/internal/demoseed/seed_test.go
index da28685..36aaafc 100644
--- a/internal/demoseed/seed_test.go
+++ b/internal/demoseed/seed_test.go
@@ -196,8 +196,17 @@ func TestEveryActiveThingCanActuallyAuthenticate(t *testing.T) {
}
}
-// A Thing's real capability is its NATS credential. Deactivating one has to
-// reach nats_users, or it has only closed the console door.
+// A Thing's real capability is its credentials. Deactivating one has to reach
+// nats_users and nebula_hosts, or it has only closed the console door.
+//
+// This asserts the EFFECT, not the flag. `active = false` on a nats_users
+// record is read by nothing in JWT generation -- pb-nats's suspend path is
+// what disconnects anyone, by adding the user's public key to the OWNING
+// ACCOUNT's revocation list and re-signing the account JWT. That list is the
+// durable evidence, and it is not a field the seeder writes, so it cannot pass
+// by accident. The previous version checked only the flag the seeder had just
+// set itself, which could not tell "pb-nats suspended the user" from "pb-nats
+// did nothing".
func TestDecommissionedThingsHaveTheirCredentialRevoked(t *testing.T) {
app := shared
@@ -206,17 +215,51 @@ func TestDecommissionedThingsHaveTheirCredentialRevoked(t *testing.T) {
t.Fatal(err)
}
if len(inactive) == 0 {
- t.Skip("no inactive things to check")
+ // Deliberately not t.Skip. This test opted out silently for as long as
+ // the seeder produced no decommissioned devices at all -- which it did,
+ // because hooks/active_flag.go forces active=true on create and the
+ // harness did not bind that hook. A test that skips when its subject is
+ // missing cannot report that its subject is missing.
+ t.Fatal("no decommissioned things, so this assertion never ran: the seeder produced none")
}
+
for _, thing := range inactive {
+ code := thing.GetString("code")
+
u, err := app.FindRecordById("nats_users", thing.GetString("nats_user"))
if err != nil {
- t.Errorf("thing %q: %v", thing.GetString("code"), err)
+ t.Errorf("thing %q: %v", code, err)
continue
}
if u.GetBool("active") {
- t.Errorf("thing %q is decommissioned but its NATS identity is still active",
- thing.GetString("code"))
+ t.Errorf("thing %q is decommissioned but its NATS identity is still active", code)
+ }
+
+ pubKey := u.GetString("public_key")
+ if pubKey == "" {
+ t.Errorf("thing %q: its NATS identity has no public key to revoke", code)
+ continue
+ }
+ acct, err := app.FindRecordById("nats_accounts", u.GetString("account_id"))
+ if err != nil {
+ t.Errorf("thing %q: cannot load the owning NATS account: %v", code, err)
+ continue
+ }
+ if !strings.Contains(acct.GetString("revocations"), pubKey) {
+ t.Errorf("thing %q is decommissioned but its public key is absent from account %q's "+
+ "revocation list -- the credential still works", code, acct.GetString("name"))
+ }
+
+ // The Nebula half. Not every Thing has a host; the ones that do must be
+ // blocklisted, or a decommissioned device keeps overlay-network access
+ // until its certificate expires.
+ if hostID := thing.GetString("nebula_host"); hostID != "" {
+ h, err := app.FindRecordById("nebula_hosts", hostID)
+ if err != nil {
+ t.Errorf("thing %q: cannot load its Nebula host: %v", code, err)
+ } else if h.GetBool("active") {
+ t.Errorf("thing %q is decommissioned but its Nebula host is still active", code)
+ }
}
}
}
diff --git a/internal/demoseed/things.go b/internal/demoseed/things.go
index 7f0ce9a..4b4d44b 100644
--- a/internal/demoseed/things.go
+++ b/internal/demoseed/things.go
@@ -64,7 +64,7 @@ func (s *seeder) thing(t thingFixture) error {
}
}
- _, _, err = s.ensure("things", "organization = {:o} && code = {:c}",
+ thing, _, err := s.ensure("things", "organization = {:o} && code = {:c}",
dbx.Params{"o": orgID, "c": t.Code}, func(r *core.Record) {
r.Set("organization", orgID)
r.Set("code", t.Code)
@@ -74,8 +74,8 @@ func (s *seeder) thing(t thingFixture) error {
r.Set("location", locID)
// The console used to omit this and every Thing it created was
// locked out by things.authRule. Set explicitly for the same reason
- // the route sets it.
- r.Set("active", !t.Inactive)
+ // the route sets it. Always true: deactivation is an update, below.
+ r.Set("active", true)
r.Set("email", fmt.Sprintf("%s@%s.thing.local", t.Code, t.Org))
r.Set("emailVisibility", true)
r.SetPassword(secret(16))
@@ -91,13 +91,47 @@ func (s *seeder) thing(t thingFixture) error {
return err
}
- // A decommissioned Thing is only decommissioned if its NATS identity is
- // revoked too — the flag on its own closes the console door and leaves the
- // device publishing. hooks/active_flag.go does this on the true->false flip,
- // which a create never is, so the seeder states it directly.
+ // Decommissioning is an UPDATE, and it goes THROUGH hooks/active_flag.go
+ // rather than around it. Two separate bugs lived in the previous version.
+ //
+ // First, `active: false` at create time was silently overwritten. That hook
+ // forces `active = true` on create, because PocketBase bools have no schema
+ // default and things.authRule is `active = true` — so a Thing created
+ // without the field would be locked out of its own API. The seeder asked for
+ // inactive Things, the hook said otherwise, and `stone-age demo-seed`
+ // produced ZERO decommissioned devices on a real install. Nothing caught it
+ // because internal/testutil did not bind that hook, so the harness and the
+ // binary disagreed about what a create does.
+ //
+ // Second, the seeder reached into the nats_users record and set `revoke`
+ // alongside `active = false`. 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, which is the precise failure hooks/active_flag.go
+ // documents at length and warns against.
+ //
+ // The true->false flip on the Thing does all of it in one place: the token
+ // key is refreshed, the linked NATS identity is suspended, and the linked
+ // Nebula host is blocklisted. Guarded on the current value so re-running the
+ // seeder does not re-flip an already-decommissioned device.
+ // The record is RE-READ first, and that is load-bearing rather than tidy.
+ // hooks/active_flag.go is edge-triggered on Original().GetBool("active"),
+ // and a record that was just created in memory and saved carries an empty
+ // original snapshot — so `active` reads as false on both sides of the
+ // comparison, the hook sees no edge, and returns without cascading. Loading
+ // the record back gives it a real prior state, which is also what an
+ // operator editing it in the console produces.
if t.Inactive {
- if err := s.revokeNatsUser(natsUserID); err != nil {
- return err
+ fresh, err := s.app.FindRecordById("things", thing.Id)
+ if err != nil {
+ return fmt.Errorf("reload thing %s before deactivating: %w", t.Code, err)
+ }
+ if fresh.GetBool("active") {
+ fresh.Set("active", false)
+ if err := s.app.Save(fresh); err != nil {
+ return fmt.Errorf("deactivate thing %s: %w", t.Code, err)
+ }
}
}
return nil
@@ -116,28 +150,6 @@ func roleForThingType(org, code string) string {
return ""
}
-// revokeNatsUser sets the revoke trigger pb-nats acts on. It adds the public key
-// to the account's revocation list and re-signs the account JWT; `active = false`
-// on the nats_users record alone is read by nothing and disconnects nobody.
-func (s *seeder) revokeNatsUser(id string) error {
- rec, err := s.app.FindRecordById("nats_users", id)
- if err != nil {
- return err
- }
- // pb-nats clears the flag as it handles it, so a set flag means it has not
- // been processed yet and a clear one means either "already done" or "never
- // asked". Checking `active` instead would re-trigger on every run.
- if !rec.GetBool("active") {
- return nil
- }
- rec.Set("revoke", true)
- rec.Set("active", false)
- if err := s.app.Save(rec); err != nil {
- return fmt.Errorf("revoke nats user %s: %w", id, err)
- }
- return nil
-}
-
// ------------------------------------------------------------- generated bulk
// fillThings tops the inventory up to Options.Things by generating devices for
diff --git a/internal/leafsync/config.go b/internal/leafsync/config.go
index aa144dc..9b4c4d3 100644
--- a/internal/leafsync/config.go
+++ b/internal/leafsync/config.go
@@ -99,7 +99,14 @@ func LoadConfig(path string) (*Config, error) {
// empty because it is not a deployment choice on a config this tool
// generated — it is the address of the file's own monitoring port.
v.SetDefault("nats.monitor_url", "http://127.0.0.1:8222")
- v.SetDefault("observability.addr", "")
+ // Loopback by default. This used to be empty, which meant a stock edge box
+ // published no /ready and no /metrics at all -- so the one place per-site
+ // health is actually visible was off unless someone opted in, and the
+ // nats_local check that would have caught a dead local bus had no consumer.
+ // Binding is never fatal (see observe.go): if something else already holds
+ // the port -- node_exporter's default is this one -- it logs and syncing
+ // continues, so the worst case of this default is a warning line.
+ v.SetDefault("observability.addr", "127.0.0.1:9100")
v.SetDefault("observability.metrics_token", "")
v.SetDefault("observability.interval", "15s")
diff --git a/internal/leafsync/connect_test.go b/internal/leafsync/connect_test.go
new file mode 100644
index 0000000..d0f6444
--- /dev/null
+++ b/internal/leafsync/connect_test.go
@@ -0,0 +1,82 @@
+package leafsync
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/nats-io/nats.go"
+)
+
+// TestLocalConnectRetriesForever pins the option whose absence turned leaf-sync
+// into a zombie about two minutes after the local bus restarted.
+//
+// nats.go's default MaxReconnect is 60, after which the connection is closed
+// permanently -- while Run keeps looping, failing every write, with no reconnect
+// and no exit for a supervisor to act on. The options are built by a named
+// function purely so this assertion is possible; an inline argument list to
+// nats.Connect cannot be inspected.
+//
+// Asserting on the resolved nats.Options rather than on source text means a
+// later edit that reorders or replaces these options is still covered.
+func TestLocalConnectRetriesForever(t *testing.T) {
+ opts := nats.GetDefaultOptions()
+ for _, apply := range localConnectOptions(&Config{CredsFile: writeStubCreds(t)}) {
+ if err := apply(&opts); err != nil {
+ t.Fatalf("applying a connect option failed: %v", err)
+ }
+ }
+
+ if opts.MaxReconnect != -1 {
+ t.Errorf("MaxReconnect = %d, want -1 (infinite). A finite value means the "+
+ "agent stops reconnecting after a local NATS restart and then spins "+
+ "forever without syncing.", opts.MaxReconnect)
+ }
+ if opts.ReconnectWait <= 0 {
+ t.Errorf("ReconnectWait = %v, want a positive backoff", opts.ReconnectWait)
+ }
+
+ // A disconnect that logs nothing is how this went unnoticed for so long: the
+ // only symptom was silence on a box nobody was scraping.
+ if opts.DisconnectedErrCB == nil {
+ t.Error("no DisconnectErrHandler set; a silent disconnect is undiagnosable")
+ }
+ if opts.ReconnectedCB == nil {
+ t.Error("no ReconnectHandler set; recovery should be visible in the log")
+ }
+ if opts.ClosedCB == nil {
+ t.Error("no ClosedHandler set; a permanently closed connection must say so")
+ }
+}
+
+// TestLocalConnectStillFailsFastOnFirstDial guards the other half of the
+// asymmetry. RetryOnFailedConnect must stay OFF: without --nats the local bus is
+// a separate process that should already be running, and a hard error at startup
+// is how an operator finds out the creds path or URL is wrong. Turning it on
+// would also hand newKVWriter a connection that is not up yet.
+func TestLocalConnectStillFailsFastOnFirstDial(t *testing.T) {
+ opts := nats.GetDefaultOptions()
+ for _, apply := range localConnectOptions(&Config{CredsFile: writeStubCreds(t)}) {
+ if err := apply(&opts); err != nil {
+ t.Fatalf("applying a connect option failed: %v", err)
+ }
+ }
+
+ if opts.RetryOnFailedConnect {
+ t.Error("RetryOnFailedConnect is on: the initial dial should fail fast so a " +
+ "bad creds path or URL is reported at startup rather than retried silently")
+ }
+}
+
+// writeStubCreds writes a credentials file for the option-application above.
+// nats.UserCredentials opens the path when the option is applied, so the file
+// has to exist -- but nothing here dials a server, so the contents are never
+// parsed and a placeholder is enough.
+func writeStubCreds(t *testing.T) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "edge.creds")
+ if err := os.WriteFile(path, []byte("-----BEGIN NATS USER JWT-----\nstub\n------END NATS USER JWT------\n"), 0o600); err != nil {
+ t.Fatalf("could not write stub creds: %v", err)
+ }
+ return path
+}
diff --git a/internal/leafsync/observe_test.go b/internal/leafsync/observe_test.go
index dbce48d..7af2eec 100644
--- a/internal/leafsync/observe_test.go
+++ b/internal/leafsync/observe_test.go
@@ -9,8 +9,8 @@ import (
"testing"
"time"
- "github.com/nats-io/nats.go"
natsserver "github.com/nats-io/nats-server/v2/server"
+ "github.com/nats-io/nats.go"
"github.com/prometheus/client_golang/prometheus"
"platform/internal/health"
diff --git a/internal/leafsync/pagination_test.go b/internal/leafsync/pagination_test.go
new file mode 100644
index 0000000..3f9e451
--- /dev/null
+++ b/internal/leafsync/pagination_test.go
@@ -0,0 +1,115 @@
+package leafsync
+
+import (
+ "context"
+ "testing"
+
+ "platform/internal/leafsync/pbclient"
+)
+
+// pagedLister serves a scripted sequence of pages, so a walk can be made to
+// come up short the way a live one does when the result set shifts underneath
+// it. The existing fakeLister always claims TotalPages: 1, so multi-page
+// reconcile was never exercised at all.
+type pagedLister struct {
+ pages [][]pbclient.Record
+ totalItems int
+ calls int
+}
+
+func (f *pagedLister) List(_ context.Context, _ string, page, perPage int, _ string) (*pbclient.ListResult, error) {
+ f.calls++
+ var items []pbclient.Record
+ if page >= 1 && page <= len(f.pages) {
+ items = f.pages[page-1]
+ }
+ return &pbclient.ListResult{
+ Page: page,
+ PerPage: perPage,
+ TotalItems: f.totalItems,
+ TotalPages: len(f.pages),
+ Items: items,
+ }, nil
+}
+
+// A walk that returns fewer records than it was promised must not purge.
+//
+// Every record the walk failed to see is indistinguishable from a record that
+// was deleted upstream, so the deletion pass would remove live config from the
+// edge mirror. One record short of a 400-record collection quietly deletes one
+// live row; there is no error, and the next cycle puts it back, so the only
+// symptom is a device that briefly cannot resolve something.
+func TestShortFetchDoesNotPurgeTheMirror(t *testing.T) {
+ // Three records exist upstream, but page 2 comes back empty -- the shape of
+ // a result set that shifted between requests.
+ lister := &pagedLister{
+ pages: [][]pbclient.Record{{rec("r1", "r1"), rec("r2", "r2")}, {}},
+ totalItems: 3,
+ }
+ kv := newFakeKV(map[string][]byte{
+ "r1": []byte(`{"id":"r1"}`),
+ "r2": []byte(`{"id":"r2"}`),
+ "r3": []byte(`{"id":"r3"}`),
+ })
+
+ if _, err := syncCollection(context.Background(), lister, kv, newSyncCache(), "things"); err != nil {
+ t.Fatalf("syncCollection: %v", err)
+ }
+
+ if _, ok := kv.store["r3"]; !ok {
+ t.Error("r3 was purged from the mirror because the walk did not return it -- " +
+ "a partial fetch was read as an upstream deletion")
+ }
+ if len(kv.store) != 3 {
+ t.Errorf("mirror holds %d keys, want 3", len(kv.store))
+ }
+}
+
+// ...and the records the short walk DID return are still written. Skipping the
+// purge must not also stop delivery of the rows in hand.
+func TestShortFetchStillUpsertsWhatItFetched(t *testing.T) {
+ lister := &pagedLister{
+ pages: [][]pbclient.Record{{rec("r1", "r1")}, {}},
+ totalItems: 2,
+ }
+ kv := newFakeKV(nil)
+
+ if _, err := syncCollection(context.Background(), lister, kv, newSyncCache(), "things"); err != nil {
+ t.Fatalf("syncCollection: %v", err)
+ }
+
+ if _, ok := kv.store["r1"]; !ok {
+ t.Error("r1 was not written: the short-fetch guard skipped the upserts as well as the purge")
+ }
+}
+
+// Pair the guard with the behaviour it must not break: a COMPLETE walk still
+// purges. Without this, disabling the purge outright would pass the test above.
+func TestCompleteMultiPageFetchStillPurges(t *testing.T) {
+ lister := &pagedLister{
+ pages: [][]pbclient.Record{{rec("r1", "r1"), rec("r2", "r2")}, {rec("r3", "r3")}},
+ totalItems: 3,
+ }
+ kv := newFakeKV(map[string][]byte{
+ "r1": []byte(`{"id":"r1"}`),
+ "r2": []byte(`{"id":"r2"}`),
+ "r3": []byte(`{"id":"r3"}`),
+ "stale": []byte(`{"id":"stale"}`),
+ })
+
+ if _, err := syncCollection(context.Background(), lister, kv, newSyncCache(), "things"); err != nil {
+ t.Fatalf("syncCollection: %v", err)
+ }
+
+ if _, ok := kv.store["stale"]; ok {
+ t.Error("a key with no upstream record survived a complete walk; the purge is not running")
+ }
+ if lister.calls != 2 {
+ t.Errorf("lister was called %d times, want 2 -- the walk did not page", lister.calls)
+ }
+ for _, k := range []string{"r1", "r2", "r3"} {
+ if _, ok := kv.store[k]; !ok {
+ t.Errorf("%s missing from the mirror after a complete walk", k)
+ }
+ }
+}
diff --git a/internal/leafsync/pbclient/client.go b/internal/leafsync/pbclient/client.go
index eb3719f..598ede7 100644
--- a/internal/leafsync/pbclient/client.go
+++ b/internal/leafsync/pbclient/client.go
@@ -138,6 +138,15 @@ func (c *Client) List(ctx context.Context, collection string, page, perPage int,
if filter != "" {
q.Set("filter", filter)
}
+ // A stable order is not optional for a paginated walk. PocketBase does not
+ // promise one, so a record inserted or deleted between two page requests can
+ // shift the window and make a page skip a record entirely -- and the caller
+ // that walks these pages then treats the missing record as deleted upstream
+ // and purges it from the edge mirror. `id` is unique, immutable and indexed,
+ // so it is the cheapest total order available. Set here rather than left to
+ // each caller because the hazard belongs to pagination itself, not to any
+ // particular use of it.
+ q.Set("sort", "id")
b, err := c.get(ctx, "/api/collections/"+url.PathEscape(collection)+"/records", q)
if err != nil {
return nil, err
diff --git a/internal/leafsync/reconcile_test.go b/internal/leafsync/reconcile_test.go
index 4d2f908..48c19b9 100644
--- a/internal/leafsync/reconcile_test.go
+++ b/internal/leafsync/reconcile_test.go
@@ -27,7 +27,9 @@ func (f *fakeLister) List(_ context.Context, _ string, page, perPage int, _ stri
if f.err != nil {
return nil, f.err
}
- // Single page is enough for these tests; pagination is covered in pbclient.
+ // Single page is enough for these tests. Multi-page walks are covered by
+ // pagedLister in pagination_test.go -- pbclient only parses one page envelope
+ // and never walked pages, so this comment used to point at nothing.
return &pbclient.ListResult{
Page: page,
PerPage: perPage,
diff --git a/internal/leafsync/sync.go b/internal/leafsync/sync.go
index b567122..9f322c0 100644
--- a/internal/leafsync/sync.go
+++ b/internal/leafsync/sync.go
@@ -35,6 +35,45 @@ var allowedCollections = map[string]bool{
const listPageSize = 500 // PocketBase per-page maximum
+// localConnectOptions builds the dial options for the leaf's local NATS server.
+//
+// MaxReconnects(-1) is the load-bearing one, and it is a function rather than
+// an inline argument list so a test can assert it is still there. nats.go
+// defaults to 60 attempts at 2s and then CLOSES the connection permanently --
+// but Run loops until its context is cancelled, so leaf-sync turned into a
+// zombie roughly two minutes after the local server went away: 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 notice. Restarting the
+// local bus is a routine, documented act -- `leaf-sync config` writes
+// nats-leaf.conf and the README then has you restart the server that reads it
+// -- so surviving it is table stakes, and an islanded site is precisely when
+// the agent must keep trying.
+//
+// The INITIAL dial still fails fast, deliberately, and that asymmetry is the
+// point: 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. Only an already-established connection retries forever.
+func localConnectOptions(cfg *Config) []nats.Option {
+ return []nats.Option{
+ nats.UserCredentials(cfg.CredsFile),
+ nats.Name("leaf-sync"),
+ nats.MaxReconnects(-1),
+ nats.ReconnectWait(2 * time.Second),
+ nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
+ log.Printf("⚠️ leaf-sync: disconnected from local NATS: %v (retrying indefinitely)", err)
+ }),
+ nats.ReconnectHandler(func(c *nats.Conn) {
+ log.Printf("leaf-sync: reconnected to local NATS at %s", c.ConnectedUrl())
+ }),
+ nats.ClosedHandler(func(_ *nats.Conn) {
+ // Unreachable while MaxReconnects is -1 unless something calls
+ // Close(), which is Run's deferred shutdown. If it ever fires for
+ // another reason, say so rather than spinning in silence.
+ log.Printf("⚠️ leaf-sync: local NATS connection closed permanently; no further syncs can succeed")
+ }),
+ }
+}
+
// Run authenticates to PocketBase as the leaf node, connects to the local leaf,
// and reconciles the configured collections into local KV on an interval until
// ctx is cancelled (e.g. on SIGINT/SIGTERM).
@@ -68,10 +107,9 @@ func Run(ctx context.Context, cfg *Config) error {
log.Printf("leaf-sync: mirroring %v every %s", collections, cfg.SyncInterval)
}
- nc, err := nats.Connect(cfg.LocalNatsURL,
- nats.UserCredentials(cfg.CredsFile),
- nats.Name("leaf-sync"),
- )
+ // The initial dial fails fast; an established connection then retries
+ // forever. See localConnectOptions for why that split matters.
+ nc, err := nats.Connect(cfg.LocalNatsURL, localConnectOptions(cfg)...)
if err != nil {
return fmt.Errorf("connect to local NATS (%s): %w", cfg.LocalNatsURL, err)
}
@@ -268,11 +306,15 @@ func syncCollection(ctx context.Context, pb recordLister, kv kvBucket, cache *sy
// `code`, which is optional and non-unique in the schema, so we must see
// every record to detect duplicate codes before choosing keys.
var records []pbclient.Record
+ expected := -1 // totalItems as of the first page; -1 until we have seen one
for page := 1; ; page++ {
res, err := pb.List(ctx, col, page, listPageSize, "")
if err != nil {
return 0, err
}
+ if expected < 0 {
+ expected = res.TotalItems
+ }
records = append(records, res.Items...)
if res.TotalPages == 0 || res.Page >= res.TotalPages {
break
@@ -287,6 +329,28 @@ func syncCollection(ctx context.Context, pb recordLister, kv kvBucket, cache *sy
return 0, nil
}
+ // Short-fetch guard, the same argument one step further in. The empty case
+ // above only catches a total failure; a PARTIAL walk is the dangerous one,
+ // because every record it failed to see looks exactly like a record that was
+ // deleted upstream, and the purge below would remove it from the edge. One
+ // record short of a 400-record collection silently deletes one live config
+ // row; the stable sort added in pbclient makes this rare, but paging is not
+ // atomic and totalItems is already on the wire, so there is no reason to
+ // infer deletion from a walk we know was incomplete.
+ //
+ // A record deleted mid-walk also lands here (fewer items than the first
+ // page promised). That is a false positive and it is fine: the purge simply
+ // waits for the next cycle, which is the safe direction to be wrong in.
+ //
+ // Only the purge is skipped. Whatever the walk DID return is still upserted,
+ // because a partial fetch is not a reason to stop delivering the records in
+ // hand -- it is only a reason not to infer deletion from their absence.
+ skipPurge := false
+ if expected > 0 && len(records) < expected {
+ log.Printf("⚠️ leaf-sync: %q returned %d of %d records; upserting those and skipping the purge this cycle", col, len(records), expected)
+ skipPurge = true
+ }
+
// Count candidate handles so a code shared by two records falls back to id.
counts := make(map[string]int)
for _, rec := range records {
@@ -355,14 +419,18 @@ func syncCollection(ctx context.Context, pb recordLister, kv kvBucket, cache *sy
// Reconcile deletions: remove KV keys whose record no longer exists upstream.
// Safe even when writes failed above — `desired` comes from the fetch, so a
- // key absent from it genuinely has no record behind it any more.
- for _, k := range keysToDelete(existing, desired) {
- if err := kv.Delete(ctx, k); err != nil {
- log.Printf("leaf-sync: kv delete %s/%s: %v", col, k, err)
- failed++
- continue
+ // key absent from it genuinely has no record behind it any more. Unless the
+ // fetch itself was short, in which case `desired` is incomplete and absence
+ // proves nothing (see the short-fetch guard above).
+ if !skipPurge {
+ for _, k := range keysToDelete(existing, desired) {
+ if err := kv.Delete(ctx, k); err != nil {
+ log.Printf("leaf-sync: kv delete %s/%s: %v", col, k, err)
+ failed++
+ continue
+ }
+ cache.forget(col, k) // key is gone; re-Put it if a record ever reuses it
}
- cache.forget(col, k) // key is gone; re-Put it if a record ever reuses it
}
// Surface partial failure so the heartbeat reports this collection as errored
diff --git a/internal/leafsync/twin.go b/internal/leafsync/twin.go
index 382df8f..88487b5 100644
--- a/internal/leafsync/twin.go
+++ b/internal/leafsync/twin.go
@@ -124,9 +124,29 @@ func relayEntry(ctx context.Context, dst twinSide, key string, val []byte, op je
return true, nil
}
+// twinRetryInterval is how often pumpReported re-offers keys whose relay to the
+// hub failed. A var, not a const, so a test can shrink it -- the retry path is
+// the whole point of the pending set and a 30s wait would make it untestable.
+var twinRetryInterval = 30 * time.Second
+
// pumpReported watches the edge's `twin` bucket and copies every change to the
// hub's. Returns when ctx is cancelled (nil) or the watcher fails (error, for
// the supervisor to back off and restart).
+//
+// Keys whose relay fails are held and retried, which is not a refinement but
+// the only thing making this direction reliable at all. The previous version
+// logged a failed write and dropped it, on the stated grounds that "a key
+// missed here is re-offered by the next watcher restart's replay" -- and it was
+// not. This watcher is 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. So a value that changed during an outage, failed its hub
+// write, and then never changed again was absent from the hub permanently and
+// silently, in the one direction the platform takes responsibility for
+// delivering.
+//
+// Retrying beats 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.
func pumpReported(ctx context.Context, src, dst twinSide) error {
w, err := src.WatchAll(ctx)
if err != nil {
@@ -134,31 +154,83 @@ func pumpReported(ctx context.Context, src, dst twinSide) error {
}
defer func() { _ = w.Stop() }()
+ // One entry per key currently failing, so this is bounded by the size of the
+ // bucket rather than by the length of the outage.
+ pending := make(map[string]struct{})
+ retry := time.NewTicker(twinRetryInterval)
+ defer retry.Stop()
+
for {
select {
case <-ctx.Done():
return nil
+ case <-retry.C:
+ retryPending(ctx, src, dst, pending)
case e, ok := <-w.Updates():
if !ok {
return errors.New("watcher closed")
}
// WatchAll sends a nil entry to mark the end of the initial replay.
- // That replay is also the resync: after a WAN outage it walks every
- // current value, so the two sides converge with no catch-up path of
- // our own to get wrong.
+ // That replay covers everything present when the pump starts; the
+ // pending set above covers everything that fails afterwards.
if e == nil {
continue
}
if _, err := relayEntry(ctx, dst, e.Key(), e.Value(), e.Operation()); err != nil {
- // Fail-soft, like the rest of this agent: log and keep the
- // stream moving. A key missed here is re-offered by the next
- // watcher restart's replay.
- log.Printf("⚠️ leaf-sync: twin relay: %v", err)
+ // Fail-soft, like the rest of this agent: log, keep the stream
+ // moving, and come back to this key on the retry tick.
+ log.Printf("⚠️ leaf-sync: twin relay: %v (will retry)", err)
+ pending[e.Key()] = struct{}{}
+ continue
}
+ delete(pending, e.Key())
}
}
}
+// retryPending re-offers each key whose relay failed earlier.
+//
+// The value is re-read from the local bucket rather than remembered from the
+// failed attempt, so a retry can never write a stale value over a newer one --
+// the device may have reported twice more while the hub was unreachable, and
+// only the current value is worth sending. A key deleted locally in the
+// meantime is relayed as a delete, which is the same tombstone-not-absence
+// distinction relayEntry already makes.
+func retryPending(ctx context.Context, src, dst twinSide, pending map[string]struct{}) {
+ if len(pending) == 0 {
+ return
+ }
+
+ before := len(pending)
+ for key := range pending {
+ if ctx.Err() != nil {
+ return
+ }
+
+ cur, err := src.Get(ctx, key)
+ switch {
+ case errors.Is(err, jetstream.ErrKeyNotFound):
+ if _, err := relayEntry(ctx, dst, key, nil, jetstream.KeyValueDelete); err != nil {
+ continue // hub still unhappy; leave it pending
+ }
+ case err != nil:
+ continue // cannot read locally right now; try again next tick
+ default:
+ if _, err := relayEntry(ctx, dst, key, cur.Value(), cur.Operation()); err != nil {
+ continue
+ }
+ }
+ delete(pending, key)
+ }
+
+ if recovered := before - len(pending); recovered > 0 {
+ log.Printf("leaf-sync: twin relay caught up on %d key(s)", recovered)
+ }
+ if len(pending) > 0 {
+ log.Printf("⚠️ leaf-sync: twin relay still behind on %d key(s)", len(pending))
+ }
+}
+
// superviseReportedPump runs the pump, restarting it with backoff if the watcher
// dies (a JetStream hiccup, a WAN drop). nats.go reconnects the connection
// underneath, but a failed watcher stays dead unless something restarts it.
diff --git a/internal/leafsync/twin_retry_test.go b/internal/leafsync/twin_retry_test.go
new file mode 100644
index 0000000..3761f50
--- /dev/null
+++ b/internal/leafsync/twin_retry_test.go
@@ -0,0 +1,180 @@
+package leafsync
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/nats-io/nats.go/jetstream"
+)
+
+// errHubDown stands in for whatever the hub returns while it is unreachable.
+var errHubDown = errors.New("hub unreachable")
+
+// setWriteErr flips the fake's write failure on or off.
+func (f *fakeTwinKV) setWriteErr(err error) {
+ f.mu.Lock()
+ f.writeErr = err
+ f.mu.Unlock()
+}
+
+// writeFailCount reports how many writes the fake has rejected.
+func (f *fakeTwinKV) writeFailCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.writeFails
+}
+
+// withShortRetry shrinks the relay's retry interval for the duration of a test.
+func withShortRetry(t *testing.T, d time.Duration) {
+ t.Helper()
+ previous := twinRetryInterval
+ twinRetryInterval = d
+ t.Cleanup(func() { twinRetryInterval = previous })
+}
+
+// runRelayUntil starts the reported pump and stops it once cond holds, or fails
+// the test after timeout. Polling beats a fixed sleep here because the retry
+// tick is what is under test.
+func runRelayUntil(t *testing.T, local, hub *fakeTwinKV, timeout time.Duration, cond func() bool) {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() { defer wg.Done(); _ = pumpReported(ctx, local, hub) }()
+
+ deadline := time.Now().Add(timeout)
+ met := false
+ for time.Now().Before(deadline) {
+ if cond() {
+ met = true
+ break
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ cancel()
+ wg.Wait()
+ if !met {
+ t.Fatalf("condition never held within %s", timeout)
+ }
+}
+
+// A write the hub rejects must be retried until it lands.
+//
+// This is the bug the pending set exists for. The previous relay logged a failed
+// write and dropped it, on the stated grounds that the key would be "re-offered
+// by the next watcher restart's replay" -- but the watcher is on the LOCAL
+// bucket, which does not die when the hub does, and superviseReportedPump only
+// restarts the pump when the WATCHER fails. So a value that changed during an
+// outage, failed its hub write, and never changed again was absent from the hub
+// permanently and silently.
+//
+// Note the existing partition test covers convergence via a relay RESTART,
+// which is exactly the path that always worked. Nothing exercised a live relay
+// against a failing destination.
+func TestRelayRetriesAFailedWriteUntilTheHubAcceptsIt(t *testing.T) {
+ withShortRetry(t, 20*time.Millisecond)
+
+ local := newFakeTwinKV(map[string][]byte{"thing.S01.setpoint": []byte("21")})
+ hub := newFakeTwinKV(nil)
+ hub.setWriteErr(errHubDown)
+
+ // Recover ONLY after the hub has actually rejected a write. Clearing the
+ // error on a timer instead lets the very first attempt succeed, and the test
+ // then passes without the retry path ever running -- which it did, against a
+ // deliberately broken build, before this was made to wait on a real failure.
+ runRelayUntil(t, local, hub, 2*time.Second, func() bool {
+ if hub.writeFailCount() > 0 {
+ hub.setWriteErr(nil)
+ }
+ v, ok := hub.get("thing.S01.setpoint")
+ return ok && v == "21"
+ })
+
+ if hub.writeFailCount() == 0 {
+ t.Fatal("the hub never rejected a write, so the retry path was not exercised")
+ }
+
+ if v, ok := hub.get("thing.S01.setpoint"); !ok || v != "21" {
+ t.Errorf("hub has %q (present=%v), want 21 -- the failed write was never retried", v, ok)
+ }
+}
+
+// A retry must send the value the device reports NOW, not the one that failed.
+//
+// The device keeps reporting while the hub is unreachable, so remembering the
+// failed value and replaying it later would write a stale reading over a newer
+// one. retryPending re-reads from the local bucket for exactly this reason.
+func TestRelayRetrySendsTheCurrentValueNotTheFailedOne(t *testing.T) {
+ local := newFakeTwinKV(map[string][]byte{"thing.S01.temp": []byte("21")})
+ hub := newFakeTwinKV(nil)
+ hub.setWriteErr(errHubDown)
+
+ // The first attempt fails and the key is held.
+ if _, err := relayEntry(context.Background(), hub, "thing.S01.temp", []byte("21"), jetstream.KeyValuePut); err == nil {
+ t.Fatal("expected the relay to fail while the hub is down")
+ }
+ pending := map[string]struct{}{"thing.S01.temp": {}}
+
+ // The device reports again while the hub is still down.
+ local.store["thing.S01.temp"] = []byte("25")
+
+ // Hub recovers; the retry should carry 25.
+ hub.setWriteErr(nil)
+ retryPending(context.Background(), local, hub, pending)
+
+ if v, ok := hub.get("thing.S01.temp"); !ok || v != "25" {
+ t.Errorf("hub has %q (present=%v), want 25 -- a retry replayed the stale failed value", v, ok)
+ }
+ if len(pending) != 0 {
+ t.Errorf("%d key(s) still pending after a successful retry", len(pending))
+ }
+}
+
+// A key deleted locally during the outage must be relayed as a delete, not
+// resurrected. A KV delete is a tombstone, not an absence: dropping it would
+// leave the key gone at the edge and live at the hub forever.
+func TestRelayRetryRelaysADeleteForAKeyRemovedDuringTheOutage(t *testing.T) {
+ local := newFakeTwinKV(nil) // already gone locally
+ hub := newFakeTwinKV(map[string][]byte{"thing.S01.temp": []byte("21")})
+
+ pending := map[string]struct{}{"thing.S01.temp": {}}
+ retryPending(context.Background(), local, hub, pending)
+
+ if _, ok := hub.get("thing.S01.temp"); ok {
+ t.Error("hub still holds a key that was deleted at the edge during the outage")
+ }
+ if len(pending) != 0 {
+ t.Errorf("%d key(s) still pending after the delete was relayed", len(pending))
+ }
+}
+
+// A key the hub will never accept must not block every other key behind it.
+// This is why the pump holds failures and keeps going, rather than returning an
+// error and letting the supervisor replay: a permanent per-key rejection would
+// otherwise tear down the watcher on every replay, forever.
+func TestRelayRetryKeepsAPoisonKeyFromBlockingOthers(t *testing.T) {
+ local := newFakeTwinKV(nil)
+ hub := newFakeTwinKV(nil)
+
+ // One key the hub refuses, one it accepts. poisonHub fails only the former.
+ hub.setWriteErr(errHubDown)
+ pending := map[string]struct{}{"thing.S01.bad": {}}
+ local.store["thing.S01.bad"] = []byte("nope")
+ retryPending(context.Background(), local, hub, pending)
+ if len(pending) != 1 {
+ t.Fatalf("expected the failing key to stay pending, got %d", len(pending))
+ }
+
+ // A healthy key still relays while the other is stuck.
+ hub.setWriteErr(nil)
+ local.store["thing.S02.temp"] = []byte("19")
+ if _, err := relayEntry(context.Background(), hub, "thing.S02.temp", []byte("19"), jetstream.KeyValuePut); err != nil {
+ t.Fatalf("a healthy key failed to relay while another was pending: %v", err)
+ }
+ if v, ok := hub.get("thing.S02.temp"); !ok || v != "19" {
+ t.Errorf("hub has %q (present=%v) for the healthy key, want 19", v, ok)
+ }
+}
diff --git a/internal/leafsync/twin_test.go b/internal/leafsync/twin_test.go
index 7120f07..ad9290b 100644
--- a/internal/leafsync/twin_test.go
+++ b/internal/leafsync/twin_test.go
@@ -50,6 +50,12 @@ type fakeTwinKV struct {
puts []string
deletes []string
getErr error
+
+ // writeErr makes Put and Delete fail, modelling an unreachable hub.
+ // writeFails counts attempts it rejected, so a test can wait for a real
+ // failure instead of guessing at timing.
+ writeErr error
+ writeFails int
}
func newFakeTwinKV(seed map[string][]byte) *fakeTwinKV {
@@ -74,6 +80,12 @@ func (f *fakeTwinKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry
func (f *fakeTwinKV) Put(_ context.Context, key string, value []byte) (uint64, error) {
f.mu.Lock()
+ if f.writeErr != nil {
+ err := f.writeErr
+ f.writeFails++
+ f.mu.Unlock()
+ return 0, err
+ }
f.puts = append(f.puts, key)
f.store[key] = value
f.rev++
@@ -85,6 +97,12 @@ func (f *fakeTwinKV) Put(_ context.Context, key string, value []byte) (uint64, e
func (f *fakeTwinKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
f.mu.Lock()
+ if f.writeErr != nil {
+ err := f.writeErr
+ f.writeFails++
+ f.mu.Unlock()
+ return err
+ }
f.deletes = append(f.deletes, key)
delete(f.store, key)
f.rev++
@@ -329,9 +347,10 @@ func TestRelayRestartIsIdempotent(t *testing.T) {
}
}
-// Edge writes made while the relay was down reach the hub on restart — the
-// WatchAll replay is the resync, so there is no catch-up path of our own to get
-// wrong. Hub-side keys the edge has never heard of are left alone: the relay is
+// Edge writes made while the relay was down reach the hub on restart, via the
+// WatchAll replay. That covers a relay RESTART; a relay that stays up while the
+// HUB is down is a different path, and is covered by the pending-set tests in
+// twin_retry_test.go. Hub-side keys the edge has never heard of are left alone: the relay is
// an upsert of what this site knows, not a reconcile of the whole bucket, so one
// site cannot purge another's state.
func TestRelayConvergesAfterPartitionWithoutPurgingOtherSites(t *testing.T) {
diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go
index be8b034..e152991 100644
--- a/internal/testutil/testutil.go
+++ b/internal/testutil/testutil.go
@@ -157,6 +157,47 @@ func NewApp(dataDir string) (*pocketbase.PocketBase, error) {
NatsRoleCollection: natsOpts.RoleCollectionName,
})
+ // These two were missing, and their absence was not neutral -- it made the
+ // harness DISAGREE with production about what a write does.
+ //
+ // RegisterActiveFlag forces `active = true` on every things/leaf_nodes
+ // CREATE, because PocketBase bools have no schema default and the authRule is
+ // `active = true`. Without it bound here, a test could create an inactive
+ // device and assert on it happily while the real binary overwrote the flag --
+ // which is exactly what happened: internal/demoseed asked for inactive Things
+ // at create time, the harness let it, and `stone-age demo-seed` on a real
+ // install produced none.
+ //
+ // RegisterMembershipLifecycle clears a departing member's
+ // current_organization, which the inventory read rules scope on.
+ hooks.RegisterActiveFlag(app, hooks.ActiveFlagOptions{
+ ThingCollection: "things",
+ LeafNodeCollection: "leaf_nodes",
+ NatsUserCollection: natsOpts.UserCollectionName,
+ NebulaHostCollection: nebulaOpts.HostCollectionName,
+ })
+ hooks.RegisterMembershipLifecycle(app, hooks.MembershipLifecycleOptions{
+ MembershipCollection: tenancyOpts.MembershipsCollection,
+ UserCollection: "users",
+ })
+
+ // DELIBERATELY NOT REGISTERED, and this is the harness's actual boundary
+ // rather than an oversight:
+ //
+ // RegisterLeafNodeRoutes, RegisterCredentialRoutes,
+ // RegisterNatsAccountRoutes, RegisterThingRoutes,
+ // RegisterClientConfigRoutes, RegisterObservability
+ //
+ // Every one of those binds ONLY app.OnServe (observability also OnTerminate),
+ // and this harness never serves -- it does record CRUD against a bootstrapped
+ // app. Binding them would add no behaviour, so a test that needs to exercise
+ // a route has to stand up a served app or use scripts/test-authz.sh, which is
+ // what that script exists for. Observability would additionally start a
+ // background prober that dials NATS, which no test has.
+ //
+ // So: every hook that changes what a WRITE does is bound here; nothing that
+ // only answers HTTP is. Say which of those two a new registrar is before
+ // deciding it belongs.
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("bootstrap: %w", err)
}
diff --git a/main.go b/main.go
index 3044205..0de4cab 100644
--- a/main.go
+++ b/main.go
@@ -373,9 +373,10 @@ func main() {
// new logins; this invalidates outstanding tokens and revokes the device's
// NATS identity, which is where its real capability lives.
hooks.RegisterActiveFlag(app, hooks.ActiveFlagOptions{
- ThingCollection: "things",
- LeafNodeCollection: "leaf_nodes",
- NatsUserCollection: natsOptions.UserCollectionName,
+ ThingCollection: "things",
+ LeafNodeCollection: "leaf_nodes",
+ NatsUserCollection: natsOptions.UserCollectionName,
+ NebulaHostCollection: nebulaOptions.HostCollectionName,
})
// Closes a departing member's tenant context. The inventory read rules trust
diff --git a/migrations/schema_update_authz_hardening.go b/migrations/schema_update_authz_hardening.go
index b862654..7d8af12 100644
--- a/migrations/schema_update_authz_hardening.go
+++ b/migrations/schema_update_authz_hardening.go
@@ -10,11 +10,11 @@ import (
// schema_update_authz_hardening re-imports the embedded schema.json so existing
// deployments pick up tightened authorization rules:
//
-// - things / nats_users / nebula_hosts deleteRule: was "@request.auth.id != ''",
+// - things / nats_users / nebula_hosts deleteRule: was any authenticated caller,
// which let ANY authenticated identity (any org's user, or a thing/identity
// auth record) delete ANY record cross-tenant. Now scoped to the caller's
// active organization, mirroring each collection's updateRule.
-// - audit_logs list/view: was "@request.auth.id != ''", exposing every tenant's
+// - audit_logs list/view: was any authenticated caller, exposing every tenant's
// audit trail (including before/after change payloads) to any authenticated
// identity. The collection has no organization field, so reads are now
// restricted to operators.
diff --git a/migrations/schema_update_org_code.go b/migrations/schema_update_org_code.go
index 3994c3f..686c851 100644
--- a/migrations/schema_update_org_code.go
+++ b/migrations/schema_update_org_code.go
@@ -29,7 +29,7 @@ import (
// The index is PARTIAL, matching the five that schema_update_unique_org_code.go
// added. Not a hedge: it is what makes this one migration instead of two. A
// column cannot be backfilled before it exists, and a total unique index cannot
-// be imported over existing rows that have no code, because SQLite treats '' as
+// be imported over existing rows that have no code, because SQLite treats an empty string as
// a value rather than as NULL. A partial index imports cleanly at any backfill
// state, and hooks.RegisterOrgCode makes blanks unreachable from here forward.
//
diff --git a/migrations/schema_update_tenancy_sentinel.go b/migrations/schema_update_tenancy_sentinel.go
new file mode 100644
index 0000000..c387085
--- /dev/null
+++ b/migrations/schema_update_tenancy_sentinel.go
@@ -0,0 +1,147 @@
+package migrations
+
+import (
+ "log"
+
+ "github.com/pocketbase/pocketbase/core"
+ m "github.com/pocketbase/pocketbase/migrations"
+)
+
+// schema_update_tenancy_sentinel closes a cross-tenant read that the allowlist
+// discipline could not see, because it is not a role bug at all.
+//
+// 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 whose organization was blank was readable by any authenticated caller
+// whose organization context was also blank -- no role check bypassed, no rule
+// mis-written, just two sentinels comparing equal.
+//
+// Both halves were reachable through ordinary product use, which is what made
+// it exploitable rather than theoretical:
+//
+// - Blank `organization` on a record: organizations.deleteRule used to permit
+// `owner = @request.auth.id`, and 16 of the 18 relations pointing at
+// organizations are non-cascade AND non-required. PocketBase blanks those
+// rather than deleting the rows (core/record_model.go, via SaveNoValidate),
+// so deleting an organization orphaned every thing, location, type, leaf
+// node, nats_account and nebula_ca with a blank organization.
+// - Blank `current_organization` on a caller: the default for a freshly
+// registered invitee before acceptance, and set deliberately on member
+// removal by hooks/membership_lifecycle.go -- the same file whose comment
+// calls this field "the entire read boundary".
+//
+// Chained, an org owner deleting their organization exposed that tenant's whole
+// inventory, its NATS account record and its Nebula CA certificate to any user
+// sitting in the blank-context state, in any other tenant.
+//
+// Three changes, all rules:
+//
+// 1. The eight affected read rules (things, locations, thing_types,
+// location_types, thing_type_operations, leaf_nodes, nats_accounts,
+// nebula_ca) now require a non-blank context AND a membership in the
+// organization being claimed. The membership clause is the load-bearing
+// half: memberships.organization is required AND cascadeDelete, so it can
+// never be blank, which kills the sentinel by construction rather than by a
+// test someone might later "simplify" away. This is the shape every WRITE
+// rule in the file already had -- which is exactly why the writes were
+// never vulnerable and only the reads were.
+//
+// The leaf_nodes branches on those rules needed the same treatment for the
+// same reason: leaf_nodes.organization is itself non-cascade and
+// non-required, so an org delete blanks it too, and a leaf node whose
+// organization was deleted would have matched every orphaned record on the
+// platform.
+//
+// Note this does NOT make reads role-scoped -- there is still no role
+// branch, so every role in an org still reads the whole inventory, which
+// CLAUDE.md documents as deliberate. It makes them membership-scoped, which
+// is what "org-scoped" was always supposed to mean.
+//
+// 2. users.createRule no longer accepts `current_organization`. The update
+// rule already froze it to organizations the caller holds a membership in;
+// the create rule guarded only `is_operator` and the invite email match, so
+// an invited registrant could name any organization's id and land inside a
+// tenant they had no membership in -- then read it, because the read rules
+// scope on precisely that field. This branch is anonymous and cannot check
+// membership even in principle (there is no auth record yet), so the fix is
+// to refuse the field: accept-invite fills it in from the invite once the
+// account exists, and pb-tenancy only writes it when empty.
+//
+// 3. organizations.deleteRule is platform-operator only, matching updateRule.
+// An owner branch on delete was strictly worse than one on update given the
+// orphaning above, and every console route that touches this collection is
+// already operator-gated, so nothing regresses.
+//
+// No field changes and no data migration. Rules are applied normally by a
+// re-import -- it is only field DEFINITIONS that freeze when a name matches and
+// an id does not -- so a re-import is sufficient here.
+//
+// Orphans already in the database are reported, not deleted. After this
+// migration they are unreachable through the API, which is the security fix;
+// removing them is a judgement call about customer data that belongs to an
+// operator with context, not to a migration running unattended at boot.
+func init() {
+ m.Register(func(app core.App) error {
+ if len(SchemaJSON) == 0 {
+ log.Println("⚠️ SchemaJSON is empty, skipping tenancy sentinel update")
+ return nil
+ }
+
+ if err := app.ImportCollectionsByMarshaledJSON(SchemaJSON, false); err != nil {
+ return err
+ }
+
+ log.Println("✅ Tenancy sentinel closed: blank organization context no longer scopes a read; current_organization is not settable at registration; deleting an organization is operator-only")
+
+ reportOrphanedTenantRecords(app)
+
+ return nil
+ }, func(app core.App) error {
+ // Down: no-op, same as the other rule migrations here. Reverting would
+ // drag every unrelated rule back to its previous state, and the thing
+ // being reverted to is a cross-tenant read.
+ return nil
+ })
+}
+
+// reportOrphanedTenantRecords lists records whose organization is blank.
+//
+// These are the rows the sentinel match exposed. They are now unreadable
+// through the API, but they are still wrong: an orphaned nats_account describes
+// an account whose organization is gone, and an orphaned leaf_node still holds
+// a credential. An upgrading operator needs to know they exist.
+//
+// The collection list is derived from the schema rather than hardcoded, so a
+// collection added later is covered without anyone remembering to add it here.
+func reportOrphanedTenantRecords(app core.App) {
+ collections, err := app.FindAllCollections()
+ if err != nil {
+ log.Printf("⚠️ Could not enumerate collections to check for orphaned records: %v", err)
+ return
+ }
+
+ total := 0
+ for _, col := range collections {
+ field := col.Fields.GetByName("organization")
+ if field == nil || field.Type() != "relation" {
+ continue
+ }
+
+ orphans, err := app.FindRecordsByFilter(col.Name, "organization = ''", "", 0, 0)
+ if err != nil {
+ log.Printf("⚠️ Could not check %s for orphaned records: %v", col.Name, err)
+ continue
+ }
+ if len(orphans) == 0 {
+ continue
+ }
+
+ total += len(orphans)
+ log.Printf("🔎 %s: %d record(s) with a blank organization", col.Name, len(orphans))
+ }
+
+ if total > 0 {
+ log.Printf("⚠️ %d orphaned record(s) found, left in place. Until this migration these were readable by any caller whose own organization context was blank. They are now unreachable through the API. They were almost certainly produced by deleting an organization, which used to blank rather than cascade; review and remove them deliberately.", total)
+ }
+}
diff --git a/migrations/widen_capabilities.go b/migrations/widen_capabilities.go
deleted file mode 100644
index 44d8ff7..0000000
--- a/migrations/widen_capabilities.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package migrations
-
-import (
- "log"
-
- "github.com/pocketbase/pocketbase/core"
- m "github.com/pocketbase/pocketbase/migrations"
-)
-
-// widen_capabilities rewrites the values stored in thing_types.capabilities from
-// the legacy pub/sub/req-reply set to the new publish/subscribe/request/reply set.
-// The schema.json import (handled by the initial_schema migration) widens the enum;
-// this migration rewrites existing row data to match.
-func init() {
- m.Register(func(app core.App) error {
- collection, err := app.FindCollectionByNameOrId("thing_types")
- if err != nil {
- // Collection not present yet; nothing to migrate.
- return nil
- }
-
- records, err := app.FindAllRecords(collection.Id)
- if err != nil {
- return err
- }
-
- migrated := 0
- for _, rec := range records {
- caps := rec.GetStringSlice("capabilities")
- if len(caps) == 0 {
- continue
- }
- next, changed := remapCapabilities(caps)
- if !changed {
- continue
- }
- rec.Set("capabilities", next)
- if err := app.Save(rec); err != nil {
- return err
- }
- migrated++
- }
-
- if migrated > 0 {
- log.Printf("✅ Rewrote capabilities on %d thing_types record(s)", migrated)
- }
- return nil
- }, nil)
-}
-
-func remapCapabilities(old []string) ([]string, bool) {
- seen := make(map[string]struct{}, len(old)+1)
- out := make([]string, 0, len(old)+1)
- changed := false
- add := func(v string) {
- if _, ok := seen[v]; ok {
- return
- }
- seen[v] = struct{}{}
- out = append(out, v)
- }
- for _, v := range old {
- switch v {
- case "pub":
- changed = true
- add("publish")
- case "sub":
- changed = true
- add("subscribe")
- case "req-reply":
- changed = true
- add("request")
- add("reply")
- default:
- add(v)
- }
- }
- return out, changed
-}
diff --git a/schema.json b/schema.json
index ddc8901..99d0184 100644
--- a/schema.json
+++ b/schema.json
@@ -970,8 +970,8 @@
},
{
"id": "pbc_704572500",
- "listRule": "// If a Thing is authenticating as itself (e.g. via SDK), it only sees its own record.\n// If a User is authenticating, they see all things belonging to their active organization.\n// A leaf node mirrors all things in its own organization.\n(@request.auth.collectionName = \"things\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && organization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
- "viewRule": "// If a Thing is authenticating as itself (e.g. via SDK), it only sees its own record.\n// If a User is authenticating, they see all things belonging to their active organization.\n// A leaf node mirrors all things in its own organization.\n(@request.auth.collectionName = \"things\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && organization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
+ "listRule": "// If a Thing is authenticating as itself (e.g. via SDK), it only sees its own record.\n// If a User is authenticating, they see all things belonging to their active organization.\n// A leaf node mirrors all things in its own organization.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"things\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
+ "viewRule": "// If a Thing is authenticating as itself (e.g. via SDK), it only sees its own record.\n// If a User is authenticating, they see all things belonging to their active organization.\n// A leaf node mirrors all things in its own organization.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"things\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
"createRule": "// Only users (not things) can create records, in their own active organization.\n// Members may add inventory; only owner/admin may attach a NATS or Nebula\n// identity to it, or set the active flag.\n//\n// nats_user / nebula_host are owner/admin only: a Thing can read the credential\n// of its own linked identity, so a member able to re-point those relations at a\n// privileged identity and then authenticate as the Thing would have a\n// credential-theft path.\n//\n// active is owner/admin only for the same reason delete is: taking a device off\n// the network revokes its NATS identity (hooks/active_flag.go). Members create\n// and edit inventory; disabling it is a management action.\n//\n// The member branch names the roles it admits. Restricting the FIELDS without\n// naming the ROLE let `dashboard` -- the least privileged role, which has no inventory\n// authority at all -- create and edit Things.\n@request.auth.collectionName = \"users\" &&\n@request.body.organization = @request.auth.current_organization &&\n(\n // Owner/admin: may assign the NATS / Nebula identity links.\n (@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))\n ||\n // Member: inventory fields only (name, description, location, metadata,\n // floorplan_position). Attaching an identity is an admin action.\n ((@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" ||\n @request.auth.memberships_via_user.role ?= \"admin\" ||\n @request.auth.memberships_via_user.role ?= \"member\")) &&\n @request.body.nats_user:changed = false &&\n @request.body.nebula_host:changed = false &&\n @request.body.active:changed = false)\n)",
"updateRule": "// 1. Only users can update things.\n// 2. The thing must belong to the user's current organization.\n// 3. The organization field cannot be modified (no \"teleporting\" between orgs).\n// 4. nats_user / nebula_host are owner/admin only: a Thing can read the\n// credential of its own linked identity, so a member able to re-point those\n// relations at a privileged identity and then authenticate as the Thing would\n// have a credential-theft path.\n// 5. active is owner/admin only for the same reason delete is: flipping it\n// revokes the Thing's NATS identity and kills its outstanding auth tokens\n// (hooks/active_flag.go). A member who could clear it could take any device in\n// the organization off the network.\n// 6. The member branch names the roles it admits. Restricting the FIELDS without\n// naming the ROLE let `dashboard` -- the least privileged role, which has no inventory\n// authority at all -- create and edit Things.\n// 7. Freeze `code`: a sibling app resolves a ticket to this device by\n// (organization, code), and the code may be printed on a label screwed to\n// it. Changing it orphans every history that points here. Same term and\n// the same reason as leaf_nodes.code -- see ADR 0002 in platform-docs.\n@request.auth.collectionName = \"users\" &&\norganization = @request.auth.current_organization &&\n@request.body.organization:changed = false &&\n@request.body.code:changed = false &&\n(\n // Owner/admin: may assign the NATS / Nebula identity links.\n (@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))\n ||\n // Member: inventory fields only (name, description, location, metadata,\n // floorplan_position). Attaching an identity is an admin action.\n ((@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" ||\n @request.auth.memberships_via_user.role ?= \"admin\" ||\n @request.auth.memberships_via_user.role ?= \"member\")) &&\n @request.body.nats_user:changed = false &&\n @request.body.nebula_host:changed = false &&\n @request.body.active:changed = false)\n)",
"deleteRule": "// Owner/admin only. `things` was the sole collection left with a member-level\n// delete -- locations, thing_types, location_types,\n// thing_type_operations, leaf_nodes, nats_roles and nebula_networks all gate it.\n//\n// Deleting a Thing is high-impact and not undoable from the UI: it orphans any\n// NATS or Nebula identity attached to it, and leaf-sync propagates the deletion\n// into every edge node local KV mirror. Members create and edit inventory;\n// removing it is a management action.\n@request.auth.collectionName = \"users\" &&\norganization = @request.auth.current_organization &&\n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
@@ -1281,7 +1281,7 @@
"id": "_pb_users_auth_",
"listRule": "// 1. Every user can see their own record\nid = @request.auth.id ||\n// 2. Operators can see all users (for owner selection, user management)\n@request.auth.is_operator = true ||\n// 3. Org Admins/Owners can see users in their current org\n(\n @request.auth.current_organization != \"\" &&\n @request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\") &&\n memberships_via_user.organization ?= @request.auth.current_organization\n)",
"viewRule": "// 1. Every user can see their own record\nid = @request.auth.id ||\n// 2. Operators can see all users\n@request.auth.is_operator = true ||\n// 3. Org Admins/Owners can see users in their current org\n(\n @request.auth.current_organization != \"\" &&\n @request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\") &&\n memberships_via_user.organization ?= @request.auth.current_organization\n)",
- "createRule": "// Operators can create users (for org onboarding).\n@request.auth.is_operator = true\n||\n// Anonymous self-registration -- but ONLY for an address that already has a\n// pending invite. AcceptInviteView has to create the account unauthenticated\n// (it cannot call /api/tenancy/accept-invite, which requires auth, until the\n// account exists), so this branch must stay open. accept-invite deletes the\n// invite row, so the gate closes behind itself.\n// Exact-match on email, consistent with the accept-invite handler's own check.\n// NOTE: enabling users.oauth2 will route first-time OAuth2 logins through this\n// same rule; uninvited addresses would be rejected here.\n(\n @request.auth.id = ''\n && @request.body.is_operator:isset = false\n && @collection.invites.email ?= @request.body.email\n)",
+ "createRule": "// Operators can create users (for org onboarding).\n@request.auth.is_operator = true\n||\n// Anonymous self-registration -- but ONLY for an address that already has a\n// pending invite. AcceptInviteView has to create the account unauthenticated\n// (it cannot call /api/tenancy/accept-invite, which requires auth, until the\n// account exists), so this branch must stay open. accept-invite deletes the\n// invite row, so the gate closes behind itself.\n// Exact-match on email, consistent with the accept-invite handler's own check.\n// NOTE: enabling users.oauth2 will route first-time OAuth2 logins through this\n// same rule; uninvited addresses would be rejected here.\n(\n @request.auth.id = ''\n && @request.body.is_operator:isset = false\n && @collection.invites.email ?= @request.body.email\n // current_organization is NOT settable at registration. The update rule\n // freezes it to organizations you hold a membership in, but this branch is\n // anonymous and could not check membership even in principle -- there is no\n // auth record yet. Leaving it unset is correct: accept-invite fills it in\n // from the invite once the account exists. Without this clause an invited\n // registrant could name the id of any organization and then read that\n // inventory, because the read rules scope on exactly this field.\n && (@request.body.current_organization:isset = false || @request.body.current_organization = \"\")\n)",
"updateRule": "// Only your own record.\nid = @request.auth.id\n&&\n// is_operator is NOT grantable through the API. Without this clause any\n// authenticated user can PATCH themselves to platform operator, which reads\n// every tenant's data (including audit_logs). Granting it is superuser\n// (admin panel) or `bootstrap` CLI only -- both bypass API rules.\n@request.body.is_operator:isset = false\n&&\n// You may only switch into an organization you are actually a member of.\n(\n @request.body.current_organization:isset = false\n || @request.auth.memberships_via_user.organization ?= @request.body.current_organization\n)",
"deleteRule": "// Users can only delete their own account.\nid = @request.auth.id",
"name": "users",
@@ -1849,8 +1849,8 @@
},
{
"id": "pbc_143112578",
- "listRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
- "viewRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
+ "listRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
+ "viewRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
"createRule": "// Only Admins or Owners can create types for their active organization.\n@request.auth.collectionName = \"users\" && \n@request.body.organization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
"updateRule": "// 1. Must be Admin/Owner.\n// 2. Must belong to active org.\n// 3. Prevent organization field tampering.\n// 4. Freeze `code`: it is the join key a seeded or mirrored catalog\n// matches on, so changing it silently re-points records of this type.\n// Same term and the same reason as leaf_nodes.code -- see ADR 0002 in\n// platform-docs.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\")) && \n@request.body.organization:changed = false &&\n@request.body.code:changed = false",
"deleteRule": "// Only Admins or Owners can delete types.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
@@ -1964,8 +1964,8 @@
},
{
"id": "pbc_1942858786",
- "listRule": "(@request.auth.collectionName = \"users\" && organization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
- "viewRule": "(@request.auth.collectionName = \"users\" && organization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
+ "listRule": "// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
+ "viewRule": "// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
"createRule": "// 1. Must be authenticated as a user holding an inventory role in the active\n// organization. This rule had no role check at all, which let `dashboard` create\n// locations.\n// 2. The organization assigned to the new record must match the active context.\n@request.auth.collectionName = \"users\" &&\n@request.body.organization = @request.auth.current_organization &&\n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" ||\n @request.auth.memberships_via_user.role ?= \"admin\" ||\n @request.auth.memberships_via_user.role ?= \"member\"))",
"updateRule": "// 1. Must be authenticated as a user holding an inventory role in the active\n// organization (see createRule -- this had no role check either).\n// 2. Must belong to the current active organization.\n// 3. Cannot change the organization ID (prevents moving data between tenants).\n// 4. Freeze `code`: a sibling app resolves a ticket to this site by\n// (organization, code), and the code may be printed on a label. Changing\n// it orphans every history that points here. Same term and the same\n// reason as leaf_nodes.code -- see ADR 0002 in platform-docs.\n@request.auth.collectionName = \"users\" &&\norganization = @request.auth.current_organization &&\n@request.body.organization:changed = false &&\n@request.body.code:changed = false &&\n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization &&\n (@request.auth.memberships_via_user.role ?= \"owner\" ||\n @request.auth.memberships_via_user.role ?= \"admin\" ||\n @request.auth.memberships_via_user.role ?= \"member\"))",
"deleteRule": "// High-impact actions like deletion should usually be restricted to management roles.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
@@ -2249,8 +2249,8 @@
},
{
"id": "pbc_4029046258",
- "listRule": "// Operators can see all NATS accounts\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n(@request.auth.collectionName = \"users\" &&\norganization = @request.auth.current_organization)\n\n// NOTE: no leaf_nodes branch -- see nats_users. The account JWT reaches an edge\n// box through GET /api/leaf/bootstrap.",
- "viewRule": "// Operators can see all NATS accounts\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n(@request.auth.collectionName = \"users\" &&\norganization = @request.auth.current_organization)\n\n// NOTE: no leaf_nodes branch -- see nats_users. The account JWT reaches an edge\n// box through GET /api/leaf/bootstrap.",
+ "listRule": "// Operators can see all NATS accounts\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization)\n\n// NOTE: no leaf_nodes branch -- see nats_users. The account JWT reaches an edge\n// box through GET /api/leaf/bootstrap.",
+ "viewRule": "// Operators can see all NATS accounts\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization)\n\n// NOTE: no leaf_nodes branch -- see nats_users. The account JWT reaches an edge\n// box through GET /api/leaf/bootstrap.",
"createRule": null,
"updateRule": "// Platform operators only.\n//\n// This rule used to have an owner/admin branch that froze the six max_* limit\n// fields and organization, commented \"can only change rotate_keys\". It was a\n// deny-list, and it leaked: it still permitted writes to jwt (the signed account\n// JWT), revocations (which user JWTs the account rejects), public_key,\n// signing_public_key, signing_keys, name, description and active -- and any field\n// added later would have been tenant-writable too, silently.\n//\n// A rule cannot express \"this one field and nothing else\", so the three key\n// operations an owner/admin legitimately needs moved to a route that sets exactly\n// one field per call: POST /api/org/nats-account/keys with action = rotate |\n// add_signing | remove_signing (hooks/nats_account_routes.go).\n//\n// The limits stay operator-only on purpose: they are the resource envelope the\n// tenant was sold, so raising them is not a tenant action.\n@request.auth.is_operator = true",
"deleteRule": null,
@@ -3078,8 +3078,8 @@
},
{
"id": "pbc_93572460",
- "listRule": "// Operators can see all Nebula CAs\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization)",
- "viewRule": "// Operators can see all Nebula CAs\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization)",
+ "listRule": "// Operators can see all Nebula CAs\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization)",
+ "viewRule": "// Operators can see all Nebula CAs\n@request.auth.is_operator = true\n||\n// Regular users: only records belonging to their active organization context\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization)",
"createRule": null,
"updateRule": "// Platform operators only.\n//\n// As with nats_accounts.updateRule, the previous owner/admin branch was a\n// deny-list -- it froze validity_years, curve and organization and was commented\n// \"can only change rotate_keys\", a field that DOES NOT EXIST on this collection.\n// What it actually permitted was writing name, certificate and expires_at: an\n// owner or admin could replace the organization's Nebula CA certificate, the trust\n// anchor for its entire overlay network.\n//\n// There is no tenant-triggered CA rotation today because there is no trigger field\n// for one. Rolling a CA is an operator operation. If that changes, add a route\n// (see hooks/nats_account_routes.go) rather than a branch here.\n@request.auth.is_operator = true",
"deleteRule": null,
@@ -3352,7 +3352,7 @@
"viewRule": "@request.auth.id != '' && (@request.auth.is_operator = true || (@collection.memberships.user.id ?= @request.auth.id && @collection.memberships.organization.id ?= id))",
"createRule": "@request.auth.id != '' && @request.auth.is_operator = true",
"updateRule": "// Platform operators only. An organization record carries the tenancy flags\n// (managed / is_operator_org / is_system_org) and drives NATS account + Nebula CA\n// provisioning, so it is not tenant-editable: an org owner has no update path.\n//\n// The owner branch that used to live here had no UI behind it anyway -- the only\n// view that writes this collection is admin/OrganizationFormView, whose route is\n// operator-gated.\n//\n// Freeze `code`: it roots the operator-signed subject rewrite and is printed\n// on physical labels, so changing it silently orphans both -- the subject keeps\n// matching the wildcard filter and simply stops arriving. Same term and same\n// reason as leaf_nodes.code. A typo is a superuser fix, deliberately.\n@request.auth.is_operator = true &&\n@request.body.code:changed = false",
- "deleteRule": "@request.auth.is_operator = true || owner = @request.auth.id",
+ "deleteRule": "// Platform operators only, matching updateRule. An owner branch here was\n// worse than one on update: 16 of the 18 relations into organizations are\n// non-cascade AND non-required, so PocketBase blanks them rather than\n// deleting the rows, orphaning every thing, location, leaf node,\n// nats_account and nebula_ca at organization = ''. Combined with the\n// sentinel match that is a cross-tenant read. Deleting a tenant is a\n// support action, not a self-service one.\n@request.auth.is_operator = true",
"name": "organizations",
"type": "base",
"fields": [
@@ -3515,8 +3515,8 @@
},
{
"id": "pbc_247972991",
- "listRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
- "viewRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
+ "listRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
+ "viewRule": "// Users see their active organization's types; a leaf node mirrors its own organization's types.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
"createRule": "// Only Admins or Owners can create types for their active organization.\n@request.auth.collectionName = \"users\" && \n@request.body.organization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
"updateRule": "// 1. Must be Admin/Owner.\n// 2. Must belong to active org.\n// 3. Prevent organization field tampering.\n// 4. Freeze `code`: it is the join key a seeded or mirrored catalog\n// matches on, so changing it silently re-points records of this type.\n// Same term and the same reason as leaf_nodes.code -- see ADR 0002 in\n// platform-docs.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\")) && \n@request.body.organization:changed = false &&\n@request.body.code:changed = false",
"deleteRule": "// Only Admins or Owners can delete types.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
@@ -4043,8 +4043,8 @@
},
{
"id": "pbc_1722283898",
- "listRule": "// Users see their active organization's operations; a leaf node mirrors its own organization's operations.\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
- "viewRule": "// Users see their active organization's operations; a leaf node mirrors its own organization's operations.\n(@request.auth.collectionName = \"users\" && \norganization = @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && organization = @request.auth.organization)",
+ "listRule": "// Users see their active organization's operations; a leaf node mirrors its own organization's operations.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
+ "viewRule": "// Users see their active organization's operations; a leaf node mirrors its own organization's operations.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization) || \n(@request.auth.collectionName = \"leaf_nodes\" && @request.auth.organization != \"\" && organization = @request.auth.organization)",
"createRule": "@request.auth.collectionName = \"users\" && \n@request.body.organization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
"updateRule": "organization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\")) && \n@request.body.organization:changed = false",
"deleteRule": "organization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
@@ -4164,8 +4164,8 @@
},
{
"id": "pbc_3920100277",
- "listRule": "// A leaf node authenticating as itself sees only its own record.\n// A user sees all leaf nodes belonging to their active organization.\n(@request.auth.collectionName = \"leaf_nodes\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && organization = @request.auth.current_organization)",
- "viewRule": "// A leaf node authenticating as itself sees only its own record.\n// A user sees all leaf nodes belonging to their active organization.\n(@request.auth.collectionName = \"leaf_nodes\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && organization = @request.auth.current_organization)",
+ "listRule": "// A leaf node authenticating as itself sees only its own record.\n// A user sees all leaf nodes belonging to their active organization.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"leaf_nodes\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization)",
+ "viewRule": "// A leaf node authenticating as itself sees only its own record.\n// A user sees all leaf nodes belonging to their active organization.\n// Sentinel guard: in PocketBase an empty string equals an empty string, so a\n// blank organization must never scope a read. Do not put an apostrophe in a\n// rule comment: quotes are tokenized before comments are stripped, so one\n// stray apostrophe re-pairs every quote after it and the rule stops parsing.\n// See migrations/schema_update_tenancy_sentinel.go.\n(@request.auth.collectionName = \"leaf_nodes\" && id = @request.auth.id) || \n(@request.auth.collectionName = \"users\" && @request.auth.current_organization != \"\" && organization = @request.auth.current_organization && @request.auth.memberships_via_user.organization ?= @request.auth.current_organization)",
"createRule": "// Only Admins or Owners can create leaf nodes for their active organization.\n@request.auth.collectionName = \"users\" && \n@request.body.organization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
"updateRule": "// 1. Must be Admin/Owner.\n// 2. Must belong to active org.\n// 3. Prevent organization field tampering.\n// 4. Freeze `code`: it is the KV key prefix and the JetStream domain suffix,\n// so changing it silently orphans everything the edge already wrote.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\")) && \n@request.body.organization:changed = false &&\n@request.body.code:changed = false",
"deleteRule": "// Only Admins or Owners can delete leaf nodes.\norganization = @request.auth.current_organization && \n(@request.auth.memberships_via_user.organization ?= @request.auth.current_organization && \n (@request.auth.memberships_via_user.role ?= \"owner\" || @request.auth.memberships_via_user.role ?= \"admin\"))",
diff --git a/scripts/test-authz.sh b/scripts/test-authz.sh
index f4a5f4e..470cac8 100755
--- a/scripts/test-authz.sh
+++ b/scripts/test-authz.sh
@@ -29,7 +29,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
PORT="${PORT:-18099}"
API="http://127.0.0.1:$PORT/api"
-EXPECTED_CHECKS=155 # bump when you add a check; guards against silent early exits
+EXPECTED_CHECKS=176 # bump when you add a check; guards against silent early exits
SU_EMAIL="su@authz.test"
SU_PASS="SuperSecret123!"
@@ -413,6 +413,21 @@ expect "member cannot mint a Nebula host identity" "403|400|404" "$RCODE" "$RBOD
req POST /collections/nebula_hosts/records "$TA" "$(host_payload owner-host 10.42.0.12)"
expect "owner CAN mint a Nebula host (same payload)" 200 "$RCODE" "$RBODY"
HOST=$(j "$RBODY" id)
+
+# A minted host must be born ACTIVE, and that is a DEPENDENCY contract rather
+# than a platform rule -- which is why it is asserted here. host_payload above
+# never sends `active`, PocketBase bools have no schema default, and pb-nebula
+# writes an inactive host's certificate fingerprint into every peer's
+# pki.blocklist. So a host that landed inactive would be refused by the whole
+# network from the moment its certificate was signed: `POST /api/org/things`
+# would provision devices that can never join the mesh, and nothing in the
+# platform would report a fault. pb-nebula >= v0.2.0 forces the flag on create;
+# this check is what notices if that is reverted or the dependency downgraded.
+if [ "$(j "$RBODY" active)" = "true" ]; then
+ ok "a minted Nebula host is born active (inactive means blocklisted at birth)"
+else
+ no "minted Nebula host landed inactive -- every peer will blocklist its certificate"
+fi
req GET "/collections/nebula_hosts/records/$HOST" "$TB"
expect "member cannot read a host's config_yaml (it embeds the private key)" "403|400|404" "$RCODE" "$RBODY"
@@ -849,6 +864,27 @@ expect "an active thing CAN authenticate" 200 "$RCODE" "$RBODY"
TT=$(j "$RBODY" token)
[ -z "$TT" ] && die "thing login failed: $RBODY"
+# Link a Nebula host as well, so deactivation has both identities to reach. A
+# device that holds only a certificate is the case the cascade used to skip:
+# the hook returned early when nats_user was empty.
+#
+# The host is forced ACTIVE first, and that is asserted here rather than
+# assumed. Section 8 now pins that pb-nebula mints hosts active, but this
+# fixture deliberately does not lean on that: the deactivation assertion below
+# is only meaningful if the host was active on the line immediately before it,
+# and it once passed against a build with no Nebula cascade at all -- because
+# host_payload omits the flag and PocketBase bools have no schema default, so
+# the host was already inactive and there was nothing for the cascade to change.
+# A check that cannot fail is worse than no check.
+req PATCH "/collections/things/records/$THING" "$SU" "{\"nebula_host\":\"$HOST\"}"
+THING_HOST=$(j "$RBODY" nebula_host)
+req PATCH "/collections/nebula_hosts/records/$HOST" "$SU" '{"active":true}'
+if [ "$THING_HOST" = "$HOST" ] && [ "$(j "$RBODY" active)" = "true" ]; then
+ ok "fixture: the thing holds an ACTIVE Nebula host as well as a NATS identity"
+else
+ no "fixture setup failed -- thing/nebula_host link or the host active flag is wrong"
+fi
+
# Who may flip it. Same roles as delete: taking a device out of service revokes
# its credential, so it is a management action, not inventory editing.
req PATCH "/collections/things/records/$THING" "$TG" '{"active":false}'
@@ -879,6 +915,21 @@ else
no "linked nats_user still active -- the NATS cascade did not fire"
fi
+# Effect 4: the overlay-network certificate is revoked too. Nebula has no CRL,
+# so `active = false` on the host is what pb-nebula writes into every OTHER
+# host's pki.blocklist. Without this the console door and the NATS door closed
+# and the mesh door stayed open until the certificate expired on its own.
+#
+# This asserts the platform half -- the flag reaching nebula_hosts. Whether a
+# blocklist then appears in peer configs is pb-nebula behaviour and is tested
+# there, against Nebula's own CA pool.
+req GET "/collections/nebula_hosts/records/$HOST" "$SU"
+if [ "$(j "$RBODY" active)" = "false" ]; then
+ ok "deactivation revoked the thing's linked Nebula host"
+else
+ no "linked nebula_host still active -- a decommissioned device keeps mesh access"
+fi
+
# Reactivation must issue a FRESH credential: the revocation cutoff in the
# account JWT is permanent, so re-enabling without re-minting would leave a
# device that looks enabled and cannot connect. Baseline is read on the line
@@ -898,6 +949,12 @@ if [ "$(j "$RBODY" active)" = "true" ] && [ -n "$(j "$RBODY" creds_file)" ] \
else
no "nats_user not re-issued on reactivation -- device would look enabled and fail to connect"
fi
+req GET "/collections/nebula_hosts/records/$HOST" "$SU"
+if [ "$(j "$RBODY" active)" = "true" ]; then
+ ok "reactivation put the Nebula host back on the mesh"
+else
+ no "nebula_host still inactive after reactivation -- it stays blocklisted by its peers"
+fi
# things.manageRule. Without it, `password` on update requires `oldPassword`
# (forms/record_upsert.go), which nobody holds for a device -- so a Thing's
@@ -1151,6 +1208,167 @@ fi
# scraped. metrics.token closes it; this asserts the default, not a rule.
RAWCODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/metrics")
expect "metrics are scrapeable without a session (metrics.token is unset here)" 200 "$RAWCODE" ""
+
+echo ""
+echo "=== 20. a blank organization is not a tenancy match ==="
+# Every inventory read rule scoped on `organization = @request.auth.
+# current_organization`. Both sides are TEXT defaulting to an empty string, and
+# in PocketBase an empty string equals an empty string -- so an orphaned record
+# was readable by anyone whose own context was blank. Neither state was exotic:
+# deleting an organization blanks `organization` on 16 non-cascade relations,
+# and hooks/membership_lifecycle.go blanks `current_organization` on the way out
+# of an org.
+#
+# ORPHAN is a dedicated record that stays blank for the whole section. An
+# earlier draft blanked and then restored the shared THING fixture, which left
+# the leaf-node checks below with nothing orphaned to find -- so they passed
+# against the unfixed rules too. A check that cannot fail is worse than no
+# check, so the orphan is separate and permanent.
+req POST /collections/things/records "$SU" \
+ "{\"email\":\"orphan@test.local\",\"password\":\"Password123!\",\"passwordConfirm\":\"Password123!\",\"emailVisibility\":true,\"name\":\"Orphan Thing\",\"code\":\"ORPH\",\"organization\":\"$ORG\"}"
+ORPHAN=$(j "$RBODY" id)
+[ -z "$ORPHAN" ] && die "orphan thing create failed: $RBODY"
+req PATCH "/collections/things/records/$ORPHAN" "$SU" '{"organization":""}'
+if [ -z "$(j "$RBODY" organization)" ]; then
+ ok "fixture: a thing now has a blank organization (what an org delete leaves behind)"
+else
+ no "fixture setup failed -- could not blank the orphan organization"
+fi
+
+# A user who is authenticated but sits in no organization: a fresh invitee
+# before acceptance, or anyone just removed from a tenant.
+DRIFT=$(mkuser drifter@test.local)
+[ -z "$DRIFT" ] && die "drifter create failed: $RBODY"
+TD=$(login drifter@test.local)
+[ -z "$TD" ] && die "drifter login failed: $RBODY"
+
+req GET "/collections/things/records" "$TD"
+DRIFT_THINGS=$(jn "$RBODY" 'o.items ? o.items.length : "err"')
+if [ "$DRIFT_THINGS" = "0" ] || [[ "$RCODE" =~ ^(403|404)$ ]]; then
+ ok "blank-context user sees no things (got ${DRIFT_THINGS:-$RCODE})"
+else
+ no "blank-context user read $DRIFT_THINGS thing(s) -- the sentinel still matches"
+fi
+req GET "/collections/nats_accounts/records" "$TD"
+DRIFT_ACCTS=$(jn "$RBODY" 'o.items ? o.items.length : "err"')
+if [ "$DRIFT_ACCTS" = "0" ] || [[ "$RCODE" =~ ^(403|404)$ ]]; then
+ ok "blank-context user sees no nats_accounts (got ${DRIFT_ACCTS:-$RCODE})"
+else
+ no "blank-context user read $DRIFT_ACCTS nats_account(s) -- account JWTs are exposed"
+fi
+
+# Pair the denials with an allow on the same collection, or a blanket refusal
+# would pass both of the above.
+req GET "/collections/things/records" "$TA"
+OWNER_THINGS=$(jn "$RBODY" 'o.items ? o.items.length : "err"')
+if [ "$OWNER_THINGS" != "0" ] && [ "$OWNER_THINGS" != "err" ]; then
+ ok "owner with a real context still reads her own inventory ($OWNER_THINGS row(s))"
+else
+ no "owner reads nothing -- the guard is too tight (got ${OWNER_THINGS:-$RCODE})"
+fi
+# ...and the orphan is not in what she reads, because it belongs to nobody now.
+if grep -q "\"$ORPHAN\"" <<<"$RBODY"; then
+ no "the owner can see the orphaned record -- a blank organization matched a real one"
+else
+ ok "the orphan is invisible to the owner too (it belongs to no organization)"
+fi
+
+# The collapse had a second door: leaf_nodes.organization is itself non-cascade
+# and non-required, so an org delete blanks it too, and the leaf branch compares
+# it against the record's own blank column. ORPHAN is still blank here.
+req PATCH "/collections/leaf_nodes/records/$LEAF" "$SU" '{"organization":""}'
+req GET "/collections/things/records" "$TL"
+LEAF_ORPHAN=$(jn "$RBODY" 'o.items ? o.items.length : "err"')
+if [ "$LEAF_ORPHAN" = "0" ] || [[ "$RCODE" =~ ^(403|404)$ ]]; then
+ ok "leaf node with a blanked organization mirrors nothing (got ${LEAF_ORPHAN:-$RCODE})"
+else
+ no "blank-org leaf node read $LEAF_ORPHAN thing(s) -- the leaf branch still collapses"
+fi
+req PATCH "/collections/leaf_nodes/records/$LEAF" "$SU" "{\"organization\":\"$ORG\"}"
+req GET "/collections/things/records" "$TL"
+LEAF_OK=$(jn "$RBODY" 'o.items ? o.items.length : "err"')
+if [ "$LEAF_OK" != "0" ] && [ "$LEAF_OK" != "err" ]; then
+ ok "leaf node with its organization restored mirrors again ($LEAF_OK row(s))"
+else
+ no "restored leaf node mirrors nothing -- the guard broke normal edge sync"
+fi
+echo ""
+echo "=== 20b. cross-tenant reads (the class the suite never covered) ==="
+# Eve's token appeared exactly once in this file before, for a write. Every
+# read denial across a tenant boundary was untested -- which is the class both
+# halves of the sentinel bug fell into.
+req POST /collections/things/records "$SU" \
+ "{\"email\":\"thing2@other.local\",\"password\":\"Password123!\",\"passwordConfirm\":\"Password123!\",\"emailVisibility\":true,\"name\":\"Other Thing\",\"code\":\"TH2\",\"organization\":\"$ORG2\"}"
+THING2=$(j "$RBODY" id)
+[ -z "$THING2" ] && die "second-org thing create failed: $RBODY"
+
+req GET "/collections/things/records?perPage=200" "$TE"
+if grep -q "\"$THING\"" <<<"$RBODY"; then
+ no "another tenant's owner can read this org's thing"
+else
+ ok "another tenant's owner cannot see this org's thing"
+fi
+if grep -q "\"$THING2\"" <<<"$RBODY"; then
+ ok "...and CAN see her own org's thing (so the deny is scoping, not a refusal)"
+else
+ no "eve cannot read her own org's thing -- the read rule is broken, not strict"
+fi
+req GET "/collections/things/records/$THING" "$TE"
+expect "another tenant's owner cannot view this org's thing by id" "403|404" "$RCODE" "$RBODY"
+
+echo ""
+echo "=== 20c. current_organization is not settable at registration ==="
+# users.updateRule froze this field to orgs you hold a membership in; the create
+# rule guarded only is_operator and the invite email match. An invited
+# registrant could therefore name any organization's id and then read its
+# inventory, because the read rules scope on exactly this field.
+req POST /collections/invites/records "$TA" \
+ "{\"email\":\"injector@test.local\",\"organization\":\"$ORG\",\"role\":\"member\"}"
+expect "owner can invite the registrant used below" 200 "$RCODE" "$RBODY"
+req POST /collections/users/records "" \
+ "{\"email\":\"injector@test.local\",\"password\":\"Password123!\",\"passwordConfirm\":\"Password123!\",\"name\":\"Injector\",\"emailVisibility\":true,\"current_organization\":\"$ORG2\"}"
+expect "invited signup cannot name an organization it has no membership in" "403|400|404" "$RCODE" "$RBODY"
+req POST /collections/users/records "" \
+ '{"email":"injector@test.local","password":"Password123!","passwordConfirm":"Password123!","name":"Injector","emailVisibility":true}'
+expect "the same signup WITHOUT current_organization still works" 200 "$RCODE" "$RBODY"
+
+echo ""
+echo "=== 20d. an admin token, and deleting an organization ==="
+# Every owner/admin rule in this suite was proven for `owner` only -- an
+# allowlist that had lost its "admin" term would have passed all of them.
+# A dedicated fixture. Section 17 deletes bob's membership and clears his
+# organization context, so reusing him here would fail for a reason that has
+# nothing to do with the rule under test -- which is the same trap as capturing
+# a baseline too early.
+ADM=$(mkuser admin2@test.local)
+[ -z "$ADM" ] && die "admin user create failed: $RBODY"
+req POST /collections/memberships/records "$SU" \
+ "{\"user\":\"$ADM\",\"organization\":\"$ORG\",\"role\":\"admin\"}"
+[ -z "$(j "$RBODY" id)" ] && die "admin membership create failed: $RBODY"
+req PATCH "/collections/users/records/$ADM" "$SU" "{\"current_organization\":\"$ORG\"}"
+TADM=$(login admin2@test.local)
+[ -z "$TADM" ] && die "admin login failed: $RBODY"
+req POST /collections/thing_types/records "$TADM" \
+ "{\"name\":\"AdminType\",\"code\":\"AT1\",\"organization\":\"$ORG\"}"
+expect "admin (not just owner) CAN create thing_types" 200 "$RCODE" "$RBODY"
+req POST /collections/nats_roles/records "$TADM" \
+ "{\"name\":\"admin-role\",\"organization\":\"$ORG\",\"publish_permissions\":[\"a.>\"],\"subscribe_permissions\":[\"a.>\"]}"
+expect "admin CAN create a nats_role" 200 "$RCODE" "$RBODY"
+
+# Deleting an organization blanks 16 non-cascade relations rather than removing
+# the rows, which is what manufactured the orphan above. It is an operator
+# action, matching updateRule; a disposable org keeps the fixtures intact.
+req POST /collections/organizations/records "$SU" \
+ "{\"name\":\"DisposableOrg\",\"owner\":\"$ALICE\",\"active\":true}"
+ORG3=$(j "$RBODY" id)
+[ -z "$ORG3" ] && die "disposable org create failed: $RBODY"
+sleep 2 # let its account/CA provisioning settle before deleting it
+req DELETE "/collections/organizations/records/$ORG3" "$TA"
+expect "an org owner cannot delete the organization record" "403|400|404" "$RCODE" "$RBODY"
+req DELETE "/collections/organizations/records/$ORG3" "$SU"
+expect "an operator CAN delete it (so the deny is a role check, not a broken rule)" "200|204" "$RCODE" "$RBODY"
+req PATCH "/collections/users/records/$ALICE" "$SU" "{\"current_organization\":\"$ORG\"}"
+
# ----------------------------------------------------------------------- result
TOTAL=$((PASS + FAIL))
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 16804f8..47e787b 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -35,12 +35,15 @@
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.8",
+ "@vue/test-utils": "^2.5.0",
"autoprefixer": "^10.4.27",
"daisyui": "^4.4.0",
+ "jsdom": "^29.1.1",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.0",
"typescript": "^6.0.3",
"vite": "^8.2.1",
+ "vitest": "^4.1.11",
"vue-tsc": "^3.2.5"
}
},
@@ -57,6 +60,57 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/generator": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
@@ -166,6 +220,177 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
+ "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz",
+ "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
@@ -484,6 +709,13 @@
"node": ">= 8"
}
},
+ "node_modules/@one-ini/wasm": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz",
+ "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -725,6 +957,38 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -824,6 +1088,129 @@
"vue": "^3.2.25"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
@@ -1034,6 +1421,27 @@
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
+ "node_modules/@vue/test-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.0.tgz",
+ "integrity": "sha512-6Clu5EKR/r6cDPYrKsu+8wenciWJJ3rhS9OEGsfDlZeZIhlJeEPGIZQHxE4lHRJCzPSq3EWMsFxQUqCvrbHQuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-beautify": "^2.0.0",
+ "vue-component-type-helpers": "^3.0.0"
+ },
+ "peerDependencies": {
+ "@vue/compiler-dom": "3.x",
+ "@vue/server-renderer": "3.x",
+ "vue": "3.x"
+ },
+ "peerDependenciesMeta": {
+ "@vue/server-renderer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vueuse/core": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
@@ -1072,6 +1480,16 @@
"vue": "^3.5.0"
}
},
+ "node_modules/abbrev": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz",
+ "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -1143,6 +1561,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -1213,6 +1641,16 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.11.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
@@ -1226,6 +1664,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1248,6 +1696,19 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -1335,6 +1796,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1418,6 +1889,24 @@
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"license": "MIT"
},
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/css-selector-tokenizer": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz",
@@ -1429,6 +1918,20 @@
"fastparse": "^1.1.2"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1478,6 +1981,20 @@
"url": "https://opencollective.com/daisyui"
}
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
@@ -1497,6 +2014,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1552,6 +2076,35 @@
"zrender": "6.1.0"
}
},
+ "node_modules/editorconfig": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz",
+ "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@one-ini/wasm": "0.2.1",
+ "commander": "^14.0.3",
+ "minimatch": "~10.2.4",
+ "semver": "^7.7.4"
+ },
+ "bin": {
+ "editorconfig": "bin/editorconfig"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/editorconfig/node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.408",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz",
@@ -1587,6 +2140,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1603,6 +2163,16 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exsolve": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
@@ -1735,6 +2305,24 @@
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
+ "node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1781,12 +2369,32 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/interactjs": {
"version": "1.10.28",
"resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.28.tgz",
@@ -1867,6 +2475,13 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -1877,6 +2492,76 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-beautify": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz",
+ "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "config-chain": "^1.1.13",
+ "editorconfig": "^3.0.2",
+ "glob": "^13.0.6",
+ "js-cookie": "^3.0.8",
+ "nopt": "^10.0.1"
+ },
+ "bin": {
+ "css-beautify": "js/bin/css-beautify.js",
+ "html-beautify": "js/bin/html-beautify.js",
+ "js-beautify": "js/bin/js-beautify.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-cookie": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
+ "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsep": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
@@ -2254,6 +2939,16 @@
"node": ">=8"
}
},
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2336,6 +3031,13 @@
"node": ">= 20"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2360,6 +3062,22 @@
"node": ">=8.6"
}
},
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -2369,6 +3087,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -2450,6 +3178,22 @@
"node": ">=18"
}
},
+ "node_modules/nopt": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz",
+ "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^5.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -2486,6 +3230,20 @@
"node": ">= 6"
}
},
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -2522,6 +3280,32 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2545,6 +3329,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2827,12 +3628,29 @@
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -2925,6 +3743,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -3030,18 +3858,51 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3051,6 +3912,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -3113,6 +3988,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3174,6 +4056,23 @@
"node": ">=0.8"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3225,6 +4124,36 @@
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
+ "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.12"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
+ "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3238,6 +4167,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -3277,6 +4232,16 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
+ "node_modules/undici": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
+ "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -3506,6 +4471,109 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -3534,6 +4602,13 @@
}
}
},
+ "node_modules/vue-component-type-helpers": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
+ "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vue-echarts": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.1.0.tgz",
@@ -3651,18 +4726,83 @@
"typescript": ">=5.0.0"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -3677,6 +4817,23 @@
"node": ">=8"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
diff --git a/ui/package.json b/ui/package.json
index 7af722e..a7819cf 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@maplibre/maplibre-gl-leaflet": "^0.1.4",
@@ -36,12 +38,15 @@
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.8",
+ "@vue/test-utils": "^2.5.0",
"autoprefixer": "^10.4.27",
"daisyui": "^4.4.0",
+ "jsdom": "^29.1.1",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.0",
"typescript": "^6.0.3",
"vite": "^8.2.1",
+ "vitest": "^4.1.11",
"vue-tsc": "^3.2.5"
}
}
diff --git a/ui/src/components/common/ConfirmDialog.spec.ts b/ui/src/components/common/ConfirmDialog.spec.ts
new file mode 100644
index 0000000..27b8861
--- /dev/null
+++ b/ui/src/components/common/ConfirmDialog.spec.ts
@@ -0,0 +1,221 @@
+// @vitest-environment jsdom
+import { DOMWrapper, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import { nextTick } from 'vue'
+
+import ConfirmDialog from './ConfirmDialog.vue'
+
+// The exception to this suite's no-mounting rule, and the reason is the subject:
+// what is under test IS the DOM contract. Dialog semantics, focus movement and
+// key handling cannot be asserted against a pure function, and this component
+// is the gate in front of every destructive action in the console — deleting a
+// Thing, revoking a credential, decommissioning a device.
+//
+// It was a plain
with no role, no aria, no Escape handler, no focus trap,
+// and `autofocus` on the destructive button.
+
+function mountDialog(props: Record = {}) {
+ return mount(ConfirmDialog, {
+ attachTo: document.body,
+ props: {
+ modelValue: true,
+ title: 'Delete thing?',
+ message: 'This cannot be undone.',
+ ...props,
+ },
+ })
+}
+
+// The component teleports to , so its markup is NOT inside the mounted
+// wrapper's tree -- wrapper.find() cannot see any of it. Query the document and
+// wrap, so .trigger() still flushes Vue's queue.
+function q(selector: string) {
+ const el = document.querySelector(selector)
+ if (!el) throw new Error('not found in document: ' + selector)
+ return new DOMWrapper(el as Element)
+}
+
+function qAll(selector: string) {
+ return Array.from(document.querySelectorAll(selector)) as HTMLElement[]
+}
+
+// Teleported markup lives in and jsdom does not reset it between tests,
+// so a leaked dialog from one test is visible to the next.
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+function dialog(_wrapper?: unknown) {
+ return q('[role="dialog"]')
+}
+
+describe('ConfirmDialog accessibility', () => {
+ it('announces itself as a modal dialog', () => {
+ const wrapper = mountDialog()
+ const el = dialog(wrapper)
+
+ expect(el.exists()).toBe(true)
+ expect(el.attributes('aria-modal')).toBe('true')
+ wrapper.unmount()
+ })
+
+ it('labels and describes itself from the title and message it renders', () => {
+ const wrapper = mountDialog()
+ const el = dialog(wrapper)
+
+ const labelId = el.attributes('aria-labelledby')!
+ const describedById = el.attributes('aria-describedby')!
+ expect(document.getElementById(labelId)?.textContent).toContain('Delete thing?')
+ expect(document.getElementById(describedById)?.textContent).toContain('This cannot be undone.')
+ wrapper.unmount()
+ })
+
+ // Two dialogs in ONE app, which is the only case that can occur: useId is
+ // unique per app instance, so mounting two separate apps would give both the
+ // same counter and prove nothing about the console, where there is one app.
+ it('gives two dialogs in the same app distinct label ids', () => {
+ const host = mount(
+ {
+ components: { ConfirmDialog },
+ template:
+ '
',
+ },
+ { attachTo: document.body },
+ )
+
+ const dialogs = qAll('[role="dialog"]')
+ expect(dialogs).toHaveLength(2)
+ expect(dialogs[0].getAttribute('aria-labelledby')).not.toBe(
+ dialogs[1].getAttribute('aria-labelledby'),
+ )
+ host.unmount()
+ })
+
+ it('hides the decorative icon from assistive technology', () => {
+ const wrapper = mountDialog()
+ expect(q('.confirm-icon').attributes('aria-hidden')).toBe('true')
+ wrapper.unmount()
+ })
+})
+
+describe('ConfirmDialog focus behaviour', () => {
+ // The container takes focus, NOT a button. `autofocus` used to sit on the
+ // destructive action, so Enter on an unread dialog deleted the thing.
+ it('focuses the dialog itself, so Enter cannot confirm by accident', async () => {
+ const wrapper = mountDialog({ modelValue: false })
+ await wrapper.setProps({ modelValue: true })
+ await nextTick()
+
+ expect(document.activeElement).toBe(dialog(wrapper).element)
+ expect((document.activeElement as HTMLElement).tagName).not.toBe('BUTTON')
+ wrapper.unmount()
+ })
+
+ it('puts no autofocus on the destructive button', () => {
+ const wrapper = mountDialog()
+ expect(q('.btn-confirm').attributes('autofocus')).toBeUndefined()
+ wrapper.unmount()
+ })
+
+ it('returns focus to whatever opened it', async () => {
+ const opener = document.createElement('button')
+ document.body.appendChild(opener)
+ opener.focus()
+ expect(document.activeElement).toBe(opener)
+
+ const wrapper = mountDialog({ modelValue: false })
+ await wrapper.setProps({ modelValue: true })
+ await nextTick()
+ expect(document.activeElement).not.toBe(opener)
+
+ await wrapper.setProps({ modelValue: false })
+ await nextTick()
+ expect(document.activeElement).toBe(opener)
+
+ wrapper.unmount()
+ opener.remove()
+ })
+
+ // The confirmed action often removes the row whose button opened the dialog,
+ // so restoring focus has to tolerate the target being gone.
+ it('does not throw when the element that opened it is gone', async () => {
+ const opener = document.createElement('button')
+ document.body.appendChild(opener)
+ opener.focus()
+
+ const wrapper = mountDialog({ modelValue: false })
+ await wrapper.setProps({ modelValue: true })
+ await nextTick()
+ opener.remove()
+
+ await expect(wrapper.setProps({ modelValue: false })).resolves.not.toThrow()
+ wrapper.unmount()
+ })
+
+ // aria-modal claims the rest of the page is inert. Without trapping Tab,
+ // focus walks out of the dialog into a page the user cannot see but can still
+ // activate — so the claim would be false.
+ it('wraps Tab from the last control back to the first', async () => {
+ const wrapper = mountDialog()
+ const el = dialog(wrapper)
+ const buttons = qAll('[role="dialog"] button')
+ const first = buttons[0]
+ const last = buttons[buttons.length - 1]
+
+ last.focus()
+ await el.trigger('keydown', { key: 'Tab' })
+ expect(document.activeElement).toBe(first)
+
+ wrapper.unmount()
+ })
+
+ it('wraps Shift+Tab from the first control back to the last', async () => {
+ const wrapper = mountDialog()
+ const el = dialog(wrapper)
+ const buttons = qAll('[role="dialog"] button')
+ const first = buttons[0]
+ const last = buttons[buttons.length - 1]
+
+ first.focus()
+ await el.trigger('keydown', { key: 'Tab', shiftKey: true })
+ expect(document.activeElement).toBe(last)
+
+ wrapper.unmount()
+ })
+})
+
+describe('ConfirmDialog dismissal', () => {
+ it('cancels on Escape', async () => {
+ const wrapper = mountDialog()
+ await dialog(wrapper).trigger('keydown', { key: 'Escape' })
+
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
+ expect(wrapper.emitted('confirm')).toBeUndefined()
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
+ wrapper.unmount()
+ })
+
+ it('does not confirm on Escape', async () => {
+ const wrapper = mountDialog()
+ await dialog(wrapper).trigger('keydown', { key: 'Escape' })
+ expect(wrapper.emitted('confirm')).toBeUndefined()
+ wrapper.unmount()
+ })
+
+ it('still confirms and cancels by click', async () => {
+ const wrapper = mountDialog()
+
+ await q('.btn-confirm').trigger('click')
+ expect(wrapper.emitted('confirm')).toHaveLength(1)
+
+ await q('.btn-secondary').trigger('click')
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
+ wrapper.unmount()
+ })
+
+ it('renders nothing when closed', () => {
+ const wrapper = mountDialog({ modelValue: false })
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ wrapper.unmount()
+ })
+})
diff --git a/ui/src/components/common/ConfirmDialog.vue b/ui/src/components/common/ConfirmDialog.vue
index 53c277c..e05cdaa 100644
--- a/ui/src/components/common/ConfirmDialog.vue
+++ b/ui/src/components/common/ConfirmDialog.vue
@@ -1,29 +1,53 @@