From f903acf150d77d434c5e56363e9f90c3c7942d2e Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:06:36 -0500 Subject: [PATCH 01/14] fix(authz): a blank organization is not a tenancy match Every inventory read rule scoped on `organization = @request.auth.current_organization`. Both sides are TEXT columns whose zero value is the empty string, and in PocketBase an empty string equals an empty string -- so a record with a blank organization was readable by any authenticated caller whose own context was also blank. No role check was bypassed and no rule was mis-written; two sentinels compared equal, which is why the allowlist discipline could not see it. Both halves were reachable through ordinary use. organizations.deleteRule permitted `owner = @request.auth.id`, and 16 of the 18 relations into organizations are non-cascade AND non-required -- PocketBase blanks those rather than deleting the rows -- so an owner deleting their organization orphaned every thing, location, type, leaf node, nats_account and nebula_ca. On the other side, hooks/membership_lifecycle.go blanks current_organization deliberately when a membership is removed, and it is also the default for a freshly registered invitee. Chained, a deleted tenant's whole inventory, its NATS account record and its Nebula CA certificate became readable by a user in the blank-context state in any other tenant. Signed credentials were never exposed: nats_users and nebula_hosts require a correlated membership with an owner/admin role, and memberships.organization is required and cascade so it can never be blank. That is the row-scoping design holding. The eight affected read rules now require a non-blank context AND a correlated membership -- the clause every write rule already carried, which is exactly why only the reads were exposed. The leaf_nodes branches needed the same treatment on @request.auth.organization, which an org delete blanks for the same reason: a leaf node whose organization was deleted would otherwise have matched every orphaned record on the platform. Reads stay org-scoped rather than role-scoped, unchanged and deliberate. Also here: - users.createRule no longer accepts current_organization. The update rule froze it to organizations the caller holds a membership in; the create rule did not mention it, so an invited registrant could name any organization id at signup and then read that tenant. The anonymous branch cannot check membership even in principle, so it refuses the field; accept-invite fills it in afterwards. - Deleting an organization is operator-only, matching updateRule. It was available to the owner, which is what manufactured the orphans above. Every console route touching this collection was already operator-gated. - test-authz.sh: 155 -> 172 checks. Each new check was verified to FAIL against the pre-fix rules rather than merely to pass against the fixed ones -- an earlier draft of the leaf-node check passed against the bug because it restored the shared fixture before the check ran, so the orphan is now a dedicated permanent record. The suite also exercises an admin token for the first time: every owner/admin rule had been proven for owner only, so an allowlist that had lost its "admin" term would have passed everything. - CLAUDE.md records the class ("what does this rule do when both sides are the zero value"), and the parsing trap found on the way: an apostrophe in a rule comment re-pairs every quote after it, because PocketBase tokenizes quotes before stripping comments, and the collection then fails to import with `invalid quoted text`. - The authz check count was stale in two places (CLAUDE.md said 150, SECURITY.md said 140). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 62 +++++++ CLAUDE.md | 50 +++++- SECURITY.md | 2 +- migrations/schema_update_tenancy_sentinel.go | 147 +++++++++++++++++ schema.json | 36 ++-- scripts/test-authz.sh | 163 ++++++++++++++++++- 6 files changed, 435 insertions(+), 25 deletions(-) create mode 100644 migrations/schema_update_tenancy_sentinel.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c5b3b..786b122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,68 @@ 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. + + +### Changed + +- `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 - **`message_schemas`, and the two dead fields on `thing_types`.** The Thing Type diff --git a/CLAUDE.md b/CLAUDE.md index a05b6b3..d39d952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -595,7 +595,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 +611,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 +659,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 @@ -842,7 +882,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 172 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/SECURITY.md b/SECURITY.md index 325e410..f08398b 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 172 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. 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/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..73a1ddc 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=172 # bump when you add a check; guards against silent early exits SU_EMAIL="su@authz.test" SU_PASS="SuperSecret123!" @@ -1151,6 +1151,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)) From b016e2f0fa9a72585d0b30296bafd8f0a68b422e Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:06:51 -0500 Subject: [PATCH 02/14] fix(leaf-sync): reconnect to the local bus forever, not 60 times nats.Connect was called with no reconnect options, so nats.go's defaults applied: 60 attempts at 2s, after which the connection is CLOSED permanently. Run loops until its context is cancelled, so roughly two minutes after the local server went away the agent became a zombie -- the ticker kept firing, every KV Put failed, the heartbeat failed, the twin relay failed, and nothing ever reconnected or exited for a supervisor to act on. This landed on the default topology, a separately supervised nats-server, and on the documented setup flow: `leaf-sync config` writes nats-leaf.conf and the next step restarts the server that reads it. An islanded site is precisely when the agent must keep trying, so an established connection now retries indefinitely, with disconnect/reconnect/closed handlers so the state is visible in the log instead of silent. The INITIAL dial still fails fast, deliberately and unchanged: without --nats the bus is a separate process that should already be up, and a hard error at startup is how an operator learns the creds path or URL is wrong. Turning on RetryOnFailedConnect would also hand newKVWriter a connection that is not up yet, so a test pins that it stays off. The options moved into localConnectOptions so the invariant can be asserted at all -- an inline argument list to nats.Connect cannot be inspected. TestLocalConnectRetriesForever reports `MaxReconnect = 60` against the previous behaviour, which is the bug reproduced as a test rather than a claim that the fix works. observability.addr now defaults to 127.0.0.1:9100 instead of empty. A stock edge box served neither /ready nor /metrics, so the only place per-site health is visible was off unless someone opted in -- which is why the nats_local check that would have caught this bug had no consumer. Binding was already non-fatal by design, so a port clash (node_exporter uses the same one) logs a warning and syncing continues. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 32 +++++++++++ cmd/leaf-sync/README.md | 2 +- cmd/leaf-sync/leaf-sync.example.yaml | 6 +- internal/leafsync/config.go | 9 ++- internal/leafsync/connect_test.go | 82 ++++++++++++++++++++++++++++ internal/leafsync/sync.go | 46 ++++++++++++++-- 6 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 internal/leafsync/connect_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 786b122..cc6cd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,8 +61,40 @@ and this file starts where the versioned releases do. operator-gated, so nothing regresses. +### Fixed + +- **`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`. + ### Changed +- **`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 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/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/sync.go b/internal/leafsync/sync.go index b567122..776c2cb 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) } From ba90e15dbb6913a45477971facf75a327f5923ed Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:16:04 -0500 Subject: [PATCH 03/14] fix(leaf-sync): retry a failed twin relay, and never purge on a short fetch Two ways the edge could silently lose data it was responsible for delivering. The twin relay dropped a failed hub write, on the stated grounds that "a key missed here is re-offered by the next watcher restart's replay". It was not. The watcher runs on the LOCAL bucket, which does not die when the hub or the WAN does, and superviseReportedPump only restarts the pump when the WATCHER fails. A reported value that changed during an outage, failed its hub write, and then never changed again was therefore absent from the hub permanently and without a trace. Failed keys are now held in a pending set and retried on a ticker. The value is re-read from the local bucket at retry time rather than remembered from the failed attempt, so a retry cannot write a stale reading over a newer one -- the device may have reported twice more while the hub was unreachable. A key deleted locally in the meantime is relayed as a delete, keeping relayEntry's tombstone-not-absence distinction. Holding and retrying is deliberately chosen over returning an error and letting the supervisor replay: a single key the hub will never accept would otherwise tear down the watcher on every replay and block every other key behind it, forever. Separately, syncCollection paged with no sort order, so a record inserted or deleted between two page requests could shift the window and make the walk skip a record -- and the deletion pass then read that absence as an upstream delete and removed it from the edge. The empty-fetch guard only caught a total failure; a partial walk is the dangerous case, because one record short of a 400-record collection quietly deletes one live config row and the next cycle puts it back, so the only symptom is a device that briefly cannot resolve something. pbclient.List now requests sort=id -- a paginated walk without a stable total order is wrong for any caller, so the guarantee belongs to pagination rather than to one use of it -- and the walk compares what it collected against the totalItems it was promised, skipping only the purge when it came up short. Records the short walk did return are still upserted. A record legitimately deleted mid-walk also trips the guard; that false positive costs one cycle of delayed purging, which is the safe direction to be wrong in. Tests, and what they are worth: - twin_retry_test.go drives a live relay against a failing destination, which nothing did before -- the existing partition test covers convergence via a relay RESTART, the path that always worked. It waits on an OBSERVED write failure rather than a timer, because an earlier version cleared the fault on a poll and passed against a deliberately broken build without the retry path running at all. - pagination_test.go adds the first multi-page reconcile coverage. The existing fake always reported TotalPages: 1 and pointed at pbclient for pagination; pbclient only ever parsed a single page envelope. The short-fetch test is paired with a complete-walk test, so disabling the purge outright would not pass. - Both were verified to fail against the unfixed code. Also corrects two comments that described behaviour the code did not have. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 45 +++++++ internal/leafsync/pagination_test.go | 115 +++++++++++++++++ internal/leafsync/pbclient/client.go | 9 ++ internal/leafsync/reconcile_test.go | 4 +- internal/leafsync/sync.go | 44 +++++-- internal/leafsync/twin.go | 86 +++++++++++-- internal/leafsync/twin_retry_test.go | 180 +++++++++++++++++++++++++++ internal/leafsync/twin_test.go | 25 +++- 8 files changed, 490 insertions(+), 18 deletions(-) create mode 100644 internal/leafsync/pagination_test.go create mode 100644 internal/leafsync/twin_retry_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cc6cd0e..c61aa5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,51 @@ and this file starts where the versioned releases do. `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. + ### Changed - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A 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 776c2cb..9f322c0 100644 --- a/internal/leafsync/sync.go +++ b/internal/leafsync/sync.go @@ -306,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 @@ -325,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 { @@ -393,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) { From a6269309df775c2667cda27d08e294a2bc09b369 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:38:06 -0500 Subject: [PATCH 04/14] fix(decommission): revoke the Nebula certificate too, not just the NATS one hooks/active_flag.go refreshed the PocketBase token key and mirrored `active` onto the linked nats_user, and touched things.nebula_host not at all. A decommissioned device therefore kept valid overlay-network membership until its certificate expired: the console door and the NATS door closed, and the mesh door stayed open. The flag is now mirrored onto nebula_hosts as well, which is what pb-nebula writes into every other host's pki.blocklist. The two cascades are independent. The previous code returned early when nats_user was empty, so a device holding only a certificate would have had the Nebula half skipped entirely -- extracted into mirrorActiveFlag so neither can short-circuit the other. Two properties of Nebula revocation are documented rather than worked around. It has no CRL, so revocation is a fingerprint carried in every PEER's config and takes effect when that config is redeployed and the process reloads; the platform's job ends when the material it hands out refuses the certificate, which is the same boundary as minting a NATS credential and not policing what connects with it. And fingerprinting a certificate needs the certificate still in the database, so DEACTIVATE to revoke, do not delete -- deleting a host leaves it trusted until expiry. Needs a pb-nebula newer than v0.1.0 (see feat/certificate-revocation there). Against v0.1.0 the flag is mirrored correctly and no blocklist is produced, so this half is inert but harmless until the dependency is bumped. test-authz.sh: 172 -> 175 checks. The first version of the deactivation assertion PASSED against a build with the Nebula cascade removed, because host_payload does not send `active` and PocketBase bools have no schema default -- the fixture host was already inactive, so the check could not fail. It now forces the host active and asserts that before deactivating, which is the same "capture the baseline immediately before the action" rule the suite already documents. Verified to fail without the cascade. Also corrects CLAUDE.md, which described this hook as setting `revoke` on the linked NATS user. It never did, and the hook's own comment explains at length why it must not: pb-nats treats `revoke` as "these credentials leaked", rotating the key pair and handing back a WORKING replacement, and it checks that flag before the active edge and returns early -- so setting both in one save silently re-credentials the device you just disabled. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 32 ++++++++++++ CLAUDE.md | 19 ++++++-- hooks/active_flag.go | 110 ++++++++++++++++++++++++++++-------------- main.go | 7 +-- scripts/test-authz.sh | 41 +++++++++++++++- 5 files changed, 165 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c61aa5b..e7ccdbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,38 @@ and this file starts where the versioned releases do. `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 a pb-nebula newer than v0.1.0. Against v0.1.0 the flag is mirrored + correctly and no blocklist is produced, so the platform half is inert but + harmless until the dependency is bumped. + + `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. + ### Changed - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A diff --git a/CLAUDE.md b/CLAUDE.md index d39d952..0e6bf47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -489,7 +489,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 newer than v0.1.0; against v0.1.0 the flag is mirrored and no blocklist is produced. + + 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**. @@ -782,9 +788,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 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/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/scripts/test-authz.sh b/scripts/test-authz.sh index 73a1ddc..bd87dd7 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=172 # bump when you add a check; guards against silent early exits +EXPECTED_CHECKS=175 # bump when you add a check; guards against silent early exits SU_EMAIL="su@authz.test" SU_PASS="SuperSecret123!" @@ -849,6 +849,24 @@ 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. PocketBase bools have no schema default and host_payload above does +# not send the flag, so a minted host lands inactive -- which meant the +# deactivation assertion below passed against a build with no Nebula cascade +# at all. 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 +897,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 +931,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 From 78499cee01abf26d79bacd7ea43b368e52ccd99b Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:49:54 -0500 Subject: [PATCH 05/14] fix(testutil,demoseed): make the harness agree with the binary, and revoke properly internal/testutil bound 5 of the 13 hooks main.go registers, while its own comment insisted the order was "equivalent to main.go". The gap was not neutral. RegisterActiveFlag forces active=true on every things/leaf_nodes CREATE -- PocketBase bools have no schema default and the authRule is `active = true`, so a device created without the field would be locked out of its own API. Without that hook bound, a test could create an inactive device and assert on it happily while the real binary overwrote the flag. Which is exactly what had happened. demoseed asked for decommissioned Things at create time, the harness allowed it, and `stone-age demo-seed` on a real install produced ZERO inactive devices, with every fixture-count test passing. Binding the hook made the existing test fail immediately: "no inactive things -- the decommissioned state is unrepresented". The route registrars and RegisterObservability stay unbound, now with the reason written down: they bind only OnServe and this harness never serves. Every hook that changes what a WRITE does is bound; nothing that only answers HTTP is. That is the boundary, rather than an accident. demoseed, two bugs at one call site: - Deactivation moved from create-time to an update, the only edge hooks/active_flag.go triggers on. The record is RE-READ first, which is load-bearing: a record created in memory and saved carries an empty Original() snapshot, so `active` reads false on both sides of the comparison, the hook sees no edge, and nothing cascades. Loading it back gives it a real prior state -- the same thing an operator editing it in the console produces. - The seeder no longer sets `revoke` alongside `active = false` on the nats_users record. pb-nats checks `revoke` FIRST and returns early, so the suspend branch never ran -- and revoke means "these credentials leaked": it rotates the key pair and writes back a fresh WORKING creds file. Every "decommissioned" demo Thing therefore held a live NATS credential, the exact failure active_flag.go warns about at length. revokeNatsUser is deleted; the active flip is the one mechanism. TestDecommissionedThingsHaveTheirCredentialRevoked now asserts the EFFECT instead of the flag: the user's public key appearing in the owning account's revocation list, plus the linked Nebula host being inactive. It previously checked only the field the seeder had just written in that same save, so it could not distinguish "pb-nats suspended the user" from "pb-nats did nothing" -- and it called t.Skip when there were no inactive things, so during the bug it did not run at all. Verified to fail against the old revoke-and-active write. Also: ensure() no longer reads a database error as "not found". A transient failure created a duplicate of an existing record; for things that is a second row with the same code, which the UNIQUE (organization, code) index rejects on a later run -- a seeder failing for a reason with no visible connection to the outage that caused it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 48 ++++++++++++++++++++++ internal/demoseed/seed.go | 14 ++++++- internal/demoseed/seed_test.go | 55 ++++++++++++++++++++++--- internal/demoseed/things.go | 74 ++++++++++++++++++++-------------- internal/testutil/testutil.go | 41 +++++++++++++++++++ 5 files changed, 194 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ccdbf..f841b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,54 @@ and this file starts where the versioned releases do. 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. + ### Changed - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A diff --git a/internal/demoseed/seed.go b/internal/demoseed/seed.go index 470e7cd..45d90cc 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 { 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/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) } From f235d0b264bb8f547ee89552b343299e31aa29a8 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:51:39 -0500 Subject: [PATCH 06/14] docs(security): state what at-rest encryption covers, and what it does not The review flagged an asymmetry: with encryption on, nats_users.seed is encrypted while nats_users.creds_file sits beside it in plaintext. The sharper version is that creds_file IS the seed -- pb-nats builds it with jwt.FormatUserConfig(user.JWT, user.Seed) -- so encrypting the seed column protects nothing for user identities. Encrypting creds_file was considered and rejected, which is the decision this commit records rather than reverses: - The browser is the hard blocker. ui/src/stores/nats.ts reads creds_file from the API and feeds it to nats-core, and a browser can never hold the encryption key -- so encryption forces a server-side decrypting route, it does not merely suggest one. - pb-nats's EncryptField/DecryptField live in internal/, so the platform cannot call them at all without the library exporting a primitive. Reimplementing AES-GCM here is the thing CLAUDE.md already refuses elsewhere. - nebula_hosts.config_yaml has the same shape, and Nebula's PKI requires the host key inline regardless. - migrations/schema_update_credential_scoping.go reached the same conclusion for hidden:true, for the same reasons. And what encryption does buy is real, so it is now stated precisely: a stolen pb_data with the key held separately yields NO ability to mint new identities -- no operator seed, no account signing keys, no CA key -- and every existing credential. The two halves have very different remediation costs, which is the part worth knowing before an incident: rotating NATS is central and scriptable with a permanent revocation cutoff, while Nebula has no CRL and needs re-issue plus a blocklist entry in every peer plus redelivery. The only behaviour change is the encryption_at_rest readiness check, which reported a bare "enabled for NATS and Nebula" -- accurate about the configuration and misleading about the guarantee. It now names what it covers. The residual risk is a hosted-tier database leak exposing every tenant's device credentials. SECURITY.md says so, and points at the controls that actually address it: full-disk encryption, encrypted backups, and dedicated single-tenant deployments. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 ++++++++++++++++++++++ CLAUDE.md | 21 +++++++++++++++++++++ SECURITY.md | 31 +++++++++++++++++++++++++++++++ config.yaml | 6 ++++++ hooks/readiness.go | 6 +++++- 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f841b6e..93a2f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,28 @@ and this file starts where the versioned releases do. `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. + ### Changed - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A diff --git a/CLAUDE.md b/CLAUDE.md index 0e6bf47..5db8d6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -701,6 +701,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 diff --git a/SECURITY.md b/SECURITY.md index f08398b..aaeb1e7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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/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/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", From 53a1104f8e1cbe60bd9dc43967ec6199254b5571 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Tue, 8 Sep 2026 23:56:27 -0500 Subject: [PATCH 07/14] ci: run the checks that already existed, and unblock the gofmt gate Five gaps, all in the guard rails rather than the product. check-sort-fields.sh was written, worked, and was called by nothing. It guards a failure mode that has already killed two screens for every caller: an unknown `sort` field is a 400 raised before any rule is evaluated, the message names no field, and it fails for superusers too, so it never looks like authorization. It needs the same go/curl/node as the authz step above it. The pinned-major guard checked tailwindcss, daisyui and typescript and omitted maplibre-gl -- the one of the four whose failure is completely silent. v6 splits its tile-parsing worker out of the bundle and resolves it as a sibling file that Vite never emits, so nothing throws, nothing reaches the console, and the map renders as a flat sheet of theme colour that reads as a design choice rather than a broken build. gofmt is now a gate, which it genuinely could not be before. Two migrations carried '' inside a doc comment, and gofmt rewrites that pair into a typographic quote -- so running it would have corrupted the comment's meaning, which is why the project's notes said not to add this check. Rather than accept the rewrite, those comments were reworded to avoid the construct, and an import-ordering slip in observe_test.go was fixed. Every tracked .go file is now clean as git stores it, verified against the index rather than the worktree (25 files are CRLF locally, which gofmt reports and git normalises away). -race is deliberately NOT added. It needs cgo, which this machine does not have, so it could not be verified locally -- and an unverified gate that fails on someone else's pull request is worse than a missing one. The reasoning is left in the workflow so the next person with a Linux box can make it a one-word change. Added instead: -count=1, because setup-go caches the build cache and a cached pass is a memory of a result from another commit, and go mod tidy -diff, which was clean and unguarded. pb_public/index.html is a tracked placeholder so `go build` can satisfy its //go:embed on a fresh clone with no Node installed, and `npm run build` overwrites it -- so a stray `git commit -a` commits the built console into the placeholder's slot and the next fresh clone embeds a stale hashed-asset reference. Now checked, before the build step, since afterwards the file legitimately differs. And HEALTHCHECK hardcoded port 8090 while docker-entrypoint.sh made the port configurable, so setting STONE_AGE_HTTP_PORT produced a permanently unhealthy container that was serving correctly. Shell-form CMD on alpine expands it. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 67 ++++++++++++++++++++- CHANGELOG.md | 36 +++++++++++ Dockerfile | 2 +- internal/leafsync/observe_test.go | 2 +- migrations/schema_update_authz_hardening.go | 4 +- migrations/schema_update_org_code.go | 2 +- 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d85a16a..71f42b6 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,7 @@ jobs: check tailwindcss 3 check daisyui 4 check typescript 6 + check maplibre-gl 5 # 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 +105,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 +146,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 93a2f86..733ca0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -232,6 +232,42 @@ and this file starts where the versioned releases do. 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. + ### Changed - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A 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/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/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. // From 4131abeec43023ae5aa45db47a80877458962513 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Wed, 9 Sep 2026 00:02:25 -0500 Subject: [PATCH 08/14] fix(metrics): stop counting every rule rejection as a server error stone_age_http_requests_total buckets by status class, and PocketBase's router runs its ErrorHandler AFTER the middleware chain unwinds -- so a handler that returns an error leaves the tracked status at 0 by the time the metrics middleware reads it. That case returned "5xx", and EVERY authorization rejection arrives that way: 400 on a denied create, 404 on a denied update, 401/403 from RequireAuth. So the 4xx bucket sat near-empty and a 5xx alert fired on a platform doing exactly its job. The function's own comment discussed those very conventions and then bucketed them wrong. The status is now resolved with router.ToApiError, which is what ErrorHandler itself calls before WriteHeader (tools/router/router.go), and what PocketBase's own request logging uses in apis/middlewares.go -- so the metric reports what the client actually received. Split into a pure statusClassFor(status, err) so the behaviour can be asserted at all. Part of why this went unnoticed is that testing it meant constructing a core.RequestEvent; the table test now names each of the platform's documented conventions as its own case. Also deletes migrations/widen_capabilities.go, which could never have run. Migrations sort by filename, so schema_update_drop_message_schemas.go -- which removes thing_types.capabilities -- runs before widen_capabilities.go, which rewrites it. GetStringSlice("capabilities") was empty for every row on fresh and upgraded databases alike, forever, and remapCapabilities was dead code. A file whose stated job is impossible is the counter-example to the migration discipline the rest of that package documents carefully. Chasing that turned up two more dead writes: seedThingTypes still set `capabilities` and `nats_role` on thing_types, both dropped with the contract layer, and PocketBase silently discards a write to a field that does not exist -- so it looked like it was seeding data nobody could find. The Capabilities fixture field goes with them. The role LOOKUP stays as a fixture check, because roleForThingType still uses tt.Role to pick the identity for each device of that type, and a fixture naming a missing role should fail there rather than at the first device. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 35 ++++++++++++ hooks/metrics.go | 35 +++++++++--- hooks/metrics_status_test.go | 94 ++++++++++++++++++++++++++++++++ internal/demoseed/contract.go | 24 -------- internal/demoseed/seed.go | 10 ++-- migrations/widen_capabilities.go | 79 --------------------------- 6 files changed, 163 insertions(+), 114 deletions(-) create mode 100644 hooks/metrics_status_test.go delete mode 100644 migrations/widen_capabilities.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 733ca0a..af91862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,21 @@ and this file starts where the versioned releases do. `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. + ### Changed - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A @@ -290,6 +305,26 @@ and this file starts where the versioned releases do. ### 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 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/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 45d90cc..bb895cb 100644 --- a/internal/demoseed/seed.go +++ b/internal/demoseed/seed.go @@ -576,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) } @@ -588,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/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 -} From 8b0086922c38e7ed9d2df00827732a38a7d3b1fa Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Wed, 9 Sep 2026 00:09:52 -0500 Subject: [PATCH 09/14] fix(dashboard): one source of widget defaults, and a compiler-checked config map createWidget called createDefaultWidget and then applyWidgetDefaults, which ran second and won every conflict. The two were not duplicates -- they had drifted, and the older one was overwriting the newer: - kvtable lost its reported-state twin bucket for an empty one, which made the TWIN_BUCKET import and its "reads reported state" comment dead code. - button, switch and slider lost their cmd.thing.* / twin_desired subjects for older placeholder subjects. - There was no `scanner` case at all: 15 branches for 16 types, surviving only because the other function ran first. That is the failure mode the design invites. createDefaultWidget is now the only source. The titles and $.value JSON paths that applyWidgetDefaults contributed were carried across, so what a user gets when adding a widget is unchanged apart from the reverted values being restored. Net -94 lines, and the literal `// ... rest of file unchanged ...` placeholder that sat in the deleted function goes with it. The refactor was made safe before it was made: a Vitest suite pins createDefaultWidget across all sixteen types first, so the merge could be checked rather than hoped. That also settled a question the diff alone could not -- markdown's empty title is deliberate (it renders its own heading), which the code says and my first assertion did not know. configComponents is now Record rather than Record. A widget type with no config component was a modal that opened onto nothing, with no error at build time or runtime; it is now a compile error. Verified by removing an entry and watching vue-tsc report TS2741: Property 'scanner' is missing. WIDGET_TYPES is a runtime list with WidgetType derived from it. The union existed only at compile time, so nothing could iterate the types and any "is every type handled" check needed a second hand-maintained copy of the list. Record still fails to compile on a missing type, and tests can now walk all sixteen. Adds the frontend test runner the project did not have: Vitest, node environment, no component mounting. The highest-risk logic here is pure -- widget defaults, the capability map, dashboard import/export, twin drift -- and all of it was unguarded, because `vue-tsc && vite build` stays green while any of it is wrong. CI runs it before the bundle so a logic regression fails fast. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 9 + CHANGELOG.md | 37 +++ ui/package-lock.json | 382 ++++++++++++++++++++++ ui/package.json | 5 +- ui/src/composables/useWidgetForm.ts | 7 +- ui/src/composables/useWidgetOperations.ts | 101 +----- ui/src/types/dashboard.spec.ts | 97 ++++++ ui/src/types/dashboard.ts | 64 +++- ui/vitest.config.ts | 22 ++ 9 files changed, 608 insertions(+), 116 deletions(-) create mode 100644 ui/src/types/dashboard.spec.ts create mode 100644 ui/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71f42b6..bf8b6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,15 @@ jobs: 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. - name: Build the console diff --git a/CHANGELOG.md b/CHANGELOG.md index af91862..4bda584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -283,6 +283,34 @@ and this file starts where the versioned releases do. 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 - **`observability.addr` defaults to `127.0.0.1:9100`** instead of empty. A @@ -358,6 +386,15 @@ and this file starts where the versioned releases do. ### Added +- **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/ui/package-lock.json b/ui/package-lock.json index 16804f8..9060807 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -41,6 +41,7 @@ "tailwindcss": "^3.4.0", "typescript": "^6.0.3", "vite": "^8.2.1", + "vitest": "^4.1.11", "vue-tsc": "^3.2.5" } }, @@ -725,6 +726,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 +857,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", @@ -1143,6 +1299,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", @@ -1335,6 +1501,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 +1594,13 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "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", @@ -1587,6 +1770,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 +1793,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", @@ -2486,6 +2686,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", @@ -3042,6 +3256,13 @@ "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 +3272,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", @@ -3174,6 +3409,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 +3477,16 @@ "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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3506,6 +3768,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", @@ -3663,6 +4028,23 @@ "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", diff --git a/ui/package.json b/ui/package.json index 7af722e..2439682 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", @@ -42,6 +44,7 @@ "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/composables/useWidgetForm.ts b/ui/src/composables/useWidgetForm.ts index 22046aa..f12b8fa 100644 --- a/ui/src/composables/useWidgetForm.ts +++ b/ui/src/composables/useWidgetForm.ts @@ -54,7 +54,12 @@ export interface UseWidgetFormOptions { // COMPONENT MAPPING // ============================================================================ -const configComponents: Record = { +// Record rather than Record, which is the whole +// point: a widget type with no config component here is a compile error instead +// of a config modal that opens onto nothing. Nothing in this console renders an +// error for a missing entry — the modal just comes up empty — and there is no +// runtime check anywhere, so the type is the only thing that can catch it. +const configComponents: Record = { text: ConfigText, chart: ConfigChart, stat: ConfigStat, diff --git a/ui/src/composables/useWidgetOperations.ts b/ui/src/composables/useWidgetOperations.ts index 0875a08..aa78c02 100644 --- a/ui/src/composables/useWidgetOperations.ts +++ b/ui/src/composables/useWidgetOperations.ts @@ -154,8 +154,12 @@ export function useWidgetOperations() { function createWidget(type: WidgetType) { const position = { x: 0, y: 100 } + // createDefaultWidget is the ONLY source of a new widget's defaults. + // A second applyWidgetDefaults used to run here and win on every + // conflict, which quietly reverted the newer values above -- kvtable + // lost its reported-state twin bucket, and button, switch and slider + // lost the cmd.thing/twin_desired subjects for older placeholders. const widget = createDefaultWidget(type, position) - applyWidgetDefaults(widget, type) dashboardStore.addWidget(widget) if (natsStore.isConnected && needsSubscription(type, widget)) { subscribeWidget(widget.id) @@ -163,101 +167,6 @@ export function useWidgetOperations() { return widget } - function applyWidgetDefaults(widget: WidgetConfig, type: WidgetType) { - // ... rest of file unchanged ... - switch (type) { - case 'text': - widget.title = 'Text Widget' - widget.dataSource = { type: 'subscription', subject: 'test.subject' } - widget.jsonPath = '$.value' - break - case 'chart': - widget.title = 'Chart Widget' - widget.dataSource = { type: 'subscription', subject: 'test.subject' } - widget.jsonPath = '$.value' - widget.chartConfig = { chartType: 'line' } - break - case 'button': - widget.title = 'Button Widget' - widget.buttonConfig = { label: 'Send', publishSubject: 'button.clicked', payload: '{"val": 1}' } - break - case 'kv': - widget.title = 'KV Widget' - widget.dataSource = { type: 'kv', kvBucket: 'my-bucket', kvKey: 'my-key' } - break - case 'switch': - widget.title = 'Switch Control' - break - case 'slider': - widget.title = 'Slider Control' - break - case 'stat': - widget.title = 'Stat Card' - widget.dataSource = { type: 'subscription', subject: 'metrics.value' } - widget.jsonPath = '$.value' - break - case 'gauge': - widget.title = 'Gauge Meter' - widget.dataSource = { type: 'subscription', subject: 'sensor.value' } - widget.jsonPath = '$.value' - break - case 'map': - widget.title = 'Map Widget' - widget.mapConfig = { center: { lat: 39.8283, lon: -98.5795 }, zoom: 4, markers: [] } - break - case 'console': - widget.title = 'Console Stream' - widget.dataSource = { type: 'subscription', subject: '>', subjects: ['>'] } - widget.consoleConfig = { fontSize: 12, showTimestamp: true } - widget.buffer.maxCount = 200 - break - case 'publisher': - widget.title = 'Publisher' - widget.publisherConfig = { defaultSubject: 'test.subject', defaultPayload: '{\n "action": "test"\n}', history: [], timeout: 2000 } - break - case 'status': - widget.title = 'Status' - widget.dataSource = { type: 'subscription', subject: 'service.status' } - widget.statusConfig = { - mappings: [ - { id: '1', value: 'online', color: 'var(--color-success)', label: 'Online', blink: false }, - { id: '2', value: 'error', color: 'var(--color-error)', label: 'Error', blink: true } - ], - defaultColor: 'var(--color-info)', defaultLabel: 'Unknown', showStale: true, stalenessThreshold: 60000, staleColor: 'var(--muted)', staleLabel: 'Stale' - } - break - case 'markdown': - widget.title = '' - widget.dataSource = { type: 'subscription', subject: '' } - widget.markdownConfig = { content: '### Hello World\n\nThis is a **markdown** widget.\n\nYou can use variables like {{device_id}}.' } - break - case 'kvtable': - widget.title = 'KV Table' - widget.kvtableConfig = { - kvBucket: '', - keyPattern: '>', - columns: [ - { id: 'col_1', label: 'Key', path: '__key_suffix__', format: 'text' } - ], - defaultSortDirection: 'desc', - maxRows: 500 - } - break - case 'streamtable': - widget.title = 'Stream Table' - widget.dataSource = { type: 'subscription', subjects: [] } - widget.buffer.maxCount = 200 - widget.streamtableConfig = { - columns: [ - { id: 'col_subject', label: 'Subject', path: '__subject__', format: 'text' }, - { id: 'col_timestamp', label: 'Time', path: '__timestamp__', format: 'relative-time' }, - ], - defaultSortDirection: 'desc', - } - break - } - } - function deleteWidget(widgetId: string) { unsubscribeWidget(widgetId, false) dashboardStore.removeWidget(widgetId) diff --git a/ui/src/types/dashboard.spec.ts b/ui/src/types/dashboard.spec.ts new file mode 100644 index 0000000..efeaf18 --- /dev/null +++ b/ui/src/types/dashboard.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_WIDGET_SIZES, + WIDGET_TYPES, + createDefaultWidget, + type WidgetType, +} from './dashboard' +import { TWIN_BUCKET } from '@/utils/twin' + +const POSITION = { x: 0, y: 100 } + +// Every widget type, from one list, so a new type cannot be tested by +// forgetting to test it. +const ALL: readonly WidgetType[] = WIDGET_TYPES + +describe('createDefaultWidget', () => { + // A widget the palette can add but that gets no defaults renders as an empty + // body with no error -- the failure mode this whole file exists to catch, + // since `vue-tsc && vite build` stays green through it. + it.each(ALL)('produces a usable widget for %s', (type) => { + const w = createDefaultWidget(type, POSITION) + + expect(w.type).toBe(type) + expect(w.id).toMatch(/^widget_/) + // markdown is deliberately untitled — it renders its own heading, so a + // title bar above it is duplication. Every other type needs one, because a + // widget with no title is unidentifiable on a grid of them. + if (type !== 'markdown') { + expect(w.title).toBeTruthy() + } + expect(w.w).toBe(DEFAULT_WIDGET_SIZES[type].w) + expect(w.h).toBe(DEFAULT_WIDGET_SIZES[type].h) + expect(w.x).toBe(POSITION.x) + expect(w.y).toBe(POSITION.y) + }) + + // Each type's own config object has to be present, or its config modal opens + // onto undefined and the widget cannot be set up at all. + const CONFIG_KEY: Partial>> = { + chart: 'chartConfig', + text: 'textConfig', + button: 'buttonConfig', + kv: 'kvConfig', + switch: 'switchConfig', + slider: 'sliderConfig', + stat: 'statConfig', + gauge: 'gaugeConfig', + map: 'mapConfig', + console: 'consoleConfig', + publisher: 'publisherConfig', + status: 'statusConfig', + markdown: 'markdownConfig', + kvtable: 'kvtableConfig', + streamtable: 'streamtableConfig', + scanner: 'scannerConfig', + } + + it.each(ALL)('gives %s its own config object', (type) => { + const key = CONFIG_KEY[type] + expect(key, `no config key mapped for ${type}`).toBeTruthy() + const w = createDefaultWidget(type, POSITION) + expect(w[key!], `${type} has no ${String(key)}`).toBeDefined() + }) + + // The kvtable default is documented behaviour, not an arbitrary value: it + // points at the reported-state twin bucket so the widget shows something the + // moment it is added. This was silently overwritten with an empty bucket by a + // second defaults function that ran afterwards. + it('points a new kvtable at the reported-state twin bucket', () => { + const w = createDefaultWidget('kvtable', POSITION) + expect(w.kvtableConfig?.kvBucket).toBe(TWIN_BUCKET) + expect(w.kvtableConfig?.keyPattern).toBe('thing.>') + }) + + // Types that read a value out of a payload need a data source and a path, or + // the widget is added in a state that cannot resolve anything. + it.each(['text', 'stat', 'gauge', 'chart'] as const)( + '%s is created with a subscription data source', + (type) => { + const w = createDefaultWidget(type, POSITION) + expect(w.dataSource?.type).toBe('subscription') + }, + ) + + // Buffered types must carry a bound. An unbounded buffer on a kiosk dashboard + // is a slow memory leak. + it.each(['console', 'streamtable'] as const)('%s is created with a bounded buffer', (type) => { + const w = createDefaultWidget(type, POSITION) + expect(w.buffer?.maxCount).toBeGreaterThan(0) + }) + + it('gives every type a distinct id', () => { + const ids = new Set(ALL.map((t) => createDefaultWidget(t, POSITION).id)) + expect(ids.size).toBe(ALL.length) + }) +}) diff --git a/ui/src/types/dashboard.ts b/ui/src/types/dashboard.ts index 9539b0e..e336dc0 100644 --- a/ui/src/types/dashboard.ts +++ b/ui/src/types/dashboard.ts @@ -4,24 +4,35 @@ import { TWIN_BUCKET, TWIN_DESIRED_BUCKET } from '@/utils/twin' /** * Widget Types + * + * ONE list, at runtime, with the type derived from it — rather than a bare + * string union. Adding a widget type means editing several places that are not + * compiler-checked (a component switch, the palette, a handful of `string[]` + * membership tests), and a union alone cannot be iterated, so nothing could + * assert "every type is handled" without a hand-maintained second copy of the + * list. This makes the list itself the source: `Record` still + * fails to compile when a type is missing, and a test can now walk every type. */ -export type WidgetType = - | 'chart' - | 'text' - | 'button' - | 'kv' - | 'kvtable' - | 'streamtable' - | 'switch' - | 'slider' - | 'stat' - | 'gauge' - | 'map' - | 'console' - | 'publisher' - | 'status' - | 'markdown' - | 'scanner' +export const WIDGET_TYPES = [ + 'chart', + 'text', + 'button', + 'kv', + 'kvtable', + 'streamtable', + 'switch', + 'slider', + 'stat', + 'gauge', + 'map', + 'console', + 'publisher', + 'status', + 'markdown', + 'scanner', +] as const + +export type WidgetType = (typeof WIDGET_TYPES)[number] export type DataSourceType = 'subscription' | 'consumer' | 'kv' export type ChartType = 'line' | 'bar' | 'pie' | 'gauge' @@ -488,9 +499,13 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: switch (type) { case 'chart': + base.title = 'Chart Widget' + base.jsonPath = '$.value' base.chartConfig = { chartType: 'line' } break case 'text': + base.title = 'Text Widget' + base.jsonPath = '$.value' base.textConfig = { fontSize: 24, thresholds: [] } break case 'button': @@ -501,8 +516,10 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: actionType: 'publish', timeout: 1000 } + base.title = 'Button Widget' break case 'kv': + base.title = 'KV Widget' base.kvConfig = { displayFormat: 'json', thresholds: [] } break case 'switch': @@ -518,6 +535,7 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: offPayload: { state: 'off' }, labels: { on: 'ON', off: 'OFF' } } + base.title = 'Switch Control' break case 'slider': base.sliderConfig = { @@ -530,8 +548,11 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: valueTemplate: '{{value}}', unit: '%' } + base.title = 'Slider Control' break case 'stat': + base.title = 'Stat Card' + base.jsonPath = '$.value' base.dataSource = { type: 'subscription', subject: 'metrics.value' } base.statConfig = { unit: '', @@ -541,6 +562,8 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: } break case 'gauge': + base.title = 'Gauge Meter' + base.jsonPath = '$.value' base.dataSource = { type: 'subscription', subject: 'sensor.value' } base.gaugeConfig = { min: 0, @@ -554,6 +577,7 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: } break case 'map': + base.title = 'Map Widget' base.mapConfig = { center: { lat: 39.8283, lon: -98.5795 }, zoom: 4, @@ -561,9 +585,13 @@ export function createDefaultWidget(type: WidgetType, position: { x: number; y: } break case 'console': + base.title = 'Console Stream' base.dataSource = { type: 'subscription', subject: '>', subjects: ['>'] } base.consoleConfig = { fontSize: 12, showTimestamp: true } - base.buffer.maxCount = 100 + // 200, matching streamtable. The 100 here was overridden by a second + // defaults function that ran afterwards, so 200 is what a console widget + // has actually had. + base.buffer.maxCount = 200 break case 'publisher': base.title = 'Publisher' diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000..1d664cc --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vitest/config' + +// Node environment and no component mounting, deliberately. +// +// The highest-risk logic in this console is pure: widget default construction, +// the role/capability map, dashboard import/export, twin drift comparison. All +// of it is reachable without a DOM, and `vue-tsc && vite build` stays green +// while any of it is wrong, so this is the cheapest guard available. Mounting +// components would need jsdom and would test rendering rather than the +// decisions the rendering depends on. +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + }, +}) From 0f6852ab7e815c1c210636b014b8f1416d4a8172 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Wed, 9 Sep 2026 00:14:24 -0500 Subject: [PATCH 10/14] docs: correct the bootstrap step count, the contract layer, and the check count Companion to the platform-docs truth pass. CLAUDE.md said bootstrap is "Three commands, in this order". It is four: `nats export` is required by `serve --nats`, which reads the operator JWT and resolver config from disk, and it cannot run any earlier because there is no operator in the database until `bootstrap` has run. README.md and docker-entrypoint.sh both already had four; only the file agents read did not. README.md still described the contract layer as declaring "what shape its messages take", operations as carrying "an optional schema", and the console as having a visual builder and infer-from-sample tool for it. All three went with `message_schemas` -- and thing_type_operations has no schema field to point at. Replaced with what is there, plus one line on why payload shape is deliberately not described. The authorization check count was stale in both places that state it (CLAUDE.md, SECURITY.md) and is now the real number. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ CLAUDE.md | 9 +++++++-- README.md | 11 ++++++----- SECURITY.md | 2 +- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bda584..66b6d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -313,6 +313,35 @@ and this file starts where the versioned releases do. ### Changed +- **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 — diff --git a/CLAUDE.md b/CLAUDE.md index 5db8d6b..63e921d 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; @@ -914,7 +919,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 172 authorization + Builds the binary, stands up a throwaway DB, and asserts 175 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/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 aaeb1e7..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 172 +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. From 335961078f00308712a6b9263f8c51f1a47a84c8 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Wed, 9 Sep 2026 00:19:41 -0500 Subject: [PATCH 11/14] test(ui): cover the five files most dangerous to change blind 126 tests, all pure logic, no component mounting. `vue-tsc && vite build` stays green while any of this is wrong, which is why these five and not others. twinDrift is the clearest case. Twenty lines, typed (any, any), and the platform documentation spends several hundred words on its exact semantics: subset for objects, exact for arrays and scalars, and NO operators, ever. None of that was pinned. A well-meaning change to make {$gt: 30} work would have compiled, passed the build, and quietly redefined what every desired value on every deployment means -- so one test asserts an operator-shaped desired value is compared as a plain value. If that test ever starts passing, someone has begun building a rules engine inside a KV browser. useSubscriptionManager is the module singleton every live value on every dashboard flows through, and it had no tests at all: refcounted listeners, a shared key per core subject, close-on-last-leave, two transports. Driven by a fake connection and a stub window, so it needs no DOM. The `can` map is the console's entire authorization surface and it mirrors schema.json BY HAND, with nothing checking the mirror -- test-authz.sh proves the server half only. The full 5x8 role/capability matrix from CLAUDE.md is transcribed here, plus the fail-closed cases: no membership resolves to null rather than a role (it used to fall back to 'member', which is fail-OPEN), a membership in a DIFFERENT organization grants nothing, and `dashboard` holds no capability at all. That last one is the probe -- a role with some authority cannot prove an allowlist, because it passes for the wrong reason. Dashboard import/export: a round trip, new ids so importing twice does not collide, the storage location stripped so an imported copy cannot claim to be a shared dashboard backed by a KV key it does not own, malformed entries skipped rather than thrown, and the limit enforced BEFORE anything is written. The replace strategy clears every local dashboard first, which is how a user loses work. localStorage is stubbed rather than left absent: saveToStorage swallows its own errors, so without a stub these tests would pass through the failure branch and prove nothing about the path a browser runs. Two real defects fell out of writing them. extractJsonPath was typed `path: string` while every caller passes a possibly-undefined jsonPath -- the body already handled undefined, only the signature disagreed. And the manageDefinitions comment still listed message schemas, dropped some time ago. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 30 +++ .../useSubscriptionManager.spec.ts | 181 ++++++++++++++++ ui/src/composables/useSubscriptionManager.ts | 5 +- ui/src/stores/auth.spec.ts | 169 +++++++++++++++ ui/src/stores/auth.ts | 2 +- ui/src/stores/dashboard.spec.ts | 204 ++++++++++++++++++ ui/src/utils/twin.spec.ts | 132 ++++++++++++ 7 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 ui/src/composables/useSubscriptionManager.spec.ts create mode 100644 ui/src/stores/auth.spec.ts create mode 100644 ui/src/stores/dashboard.spec.ts create mode 100644 ui/src/utils/twin.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b6d0f..83eb1e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -415,6 +415,36 @@ 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 diff --git a/ui/src/composables/useSubscriptionManager.spec.ts b/ui/src/composables/useSubscriptionManager.spec.ts new file mode 100644 index 0000000..faf2990 --- /dev/null +++ b/ui/src/composables/useSubscriptionManager.spec.ts @@ -0,0 +1,181 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useNatsStore } from '@/stores/nats' +import { useSubscriptionManager } from './useSubscriptionManager' + +// Every live value on every dashboard flows through this module, and it was +// wholly untested: refcounted listeners, a shared key for core subjects, two +// different transports, and reconnect handling. `vue-tsc && vite build` stays +// green through any bug in here. +// +// Node environment, so `window` does not exist -- the manager attaches its +// reconnect/close listeners to it. A stub is enough: nothing here asserts on +// event delivery, only that subscribing does not require a DOM. +const listeners = new Map void>>() +function stubWindow() { + listeners.clear() + ;(globalThis as any).window = { + addEventListener(type: string, fn: (e: unknown) => void) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(fn) + }, + removeEventListener(type: string, fn: (e: unknown) => void) { + listeners.get(type)?.delete(fn) + }, + dispatchEvent() { + return true + }, + } +} + +// A core subscription is an async iterable that can be closed. That is the +// whole surface the manager uses, so it is the whole surface the fake needs. +function fakeSubscription() { + let closed = false + return { + unsubscribeCalls: 0, + isClosed: () => closed, + unsubscribe() { + this.unsubscribeCalls++ + closed = true + }, + async *[Symbol.asyncIterator]() { + // Never yields, never returns: a live subscription with no traffic. The + // manager consumes this in a detached loop, so it must not be awaited. + await new Promise(() => {}) + }, + } +} + +function fakeConnection() { + const subs: ReturnType[] = [] + return { + subs, + subscribeCalls: [] as string[], + subscribe(subject: string) { + this.subscribeCalls.push(subject) + const s = fakeSubscription() + subs.push(s) + return s + }, + } +} + +function setup() { + setActivePinia(createPinia()) + stubWindow() + const nc = fakeConnection() + const nats = useNatsStore() + nats.nc = nc as never + return { nc, manager: useSubscriptionManager() } +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('useSubscriptionManager', () => { + it('opens one NATS subscription for two widgets on the same subject', async () => { + const { nc, manager } = setup() + const config = { type: 'subscription' as const, subject: 'sensor.temp' } + + await manager.subscribe('widget-a', config) + await manager.subscribe('widget-b', config) + + // Core subjects are keyed by subject alone, deliberately: two widgets + // watching the same subject should share one server-side subscription. + expect(nc.subscribeCalls).toEqual(['sensor.temp']) + expect(manager.isSubscribed('widget-a', config)).toBe(true) + expect(manager.isSubscribed('widget-b', config)).toBe(true) + }) + + it('keeps the subscription open until the LAST widget leaves', async () => { + const { nc, manager } = setup() + const config = { type: 'subscription' as const, subject: 'sensor.temp' } + + await manager.subscribe('widget-a', config) + await manager.subscribe('widget-b', config) + + manager.unsubscribe('widget-a', config) + expect(nc.subs[0].unsubscribeCalls).toBe(0) + expect(manager.isSubscribed('widget-b', config)).toBe(true) + + manager.unsubscribe('widget-b', config) + expect(nc.subs[0].unsubscribeCalls).toBe(1) + expect(manager.isSubscribed('widget-b', config)).toBe(false) + }) + + it('does not close a shared subscription twice', async () => { + const { nc, manager } = setup() + const config = { type: 'subscription' as const, subject: 'sensor.temp' } + + await manager.subscribe('widget-a', config) + manager.unsubscribe('widget-a', config) + manager.unsubscribe('widget-a', config) + + expect(nc.subs[0].unsubscribeCalls).toBe(1) + }) + + it('opens separate subscriptions for different subjects', async () => { + const { nc, manager } = setup() + + await manager.subscribe('a', { type: 'subscription', subject: 'sensor.temp' }) + await manager.subscribe('b', { type: 'subscription', subject: 'sensor.humidity' }) + + expect(nc.subscribeCalls).toEqual(['sensor.temp', 'sensor.humidity']) + }) + + it('does nothing when there is no connection', async () => { + setActivePinia(createPinia()) + stubWindow() + const manager = useSubscriptionManager() + const config = { type: 'subscription' as const, subject: 'sensor.temp' } + + await manager.subscribe('widget-a', config) + expect(manager.isSubscribed('widget-a', config)).toBe(false) + }) + + it('ignores a data source with no subject rather than subscribing to undefined', async () => { + const { nc, manager } = setup() + await manager.subscribe('widget-a', { type: 'subscription' }) + expect(nc.subscribeCalls).toEqual([]) + }) + + it('closes everything on cleanupAll', async () => { + const { nc, manager } = setup() + + await manager.subscribe('a', { type: 'subscription', subject: 'one' }) + await manager.subscribe('b', { type: 'subscription', subject: 'two' }) + manager.cleanupAll() + + expect(nc.subs.map((s) => s.unsubscribeCalls)).toEqual([1, 1]) + }) +}) + +// Every widget that reads a field out of a payload goes through this, so a +// change in its behaviour changes what every dashboard displays. +describe('extractJsonPath', () => { + it('reads a value at a JSON path', () => { + const { manager } = setup() + expect(manager.extractJsonPath({ value: 21.5 }, '$.value')).toBe(21.5) + expect(manager.extractJsonPath({ a: { b: 'x' } }, '$.a.b')).toBe('x') + }) + + it('returns the whole payload when no path is given', () => { + const { manager } = setup() + const payload = { value: 1 } + expect(manager.extractJsonPath(payload, undefined)).toEqual(payload) + expect(manager.extractJsonPath(payload, '')).toEqual(payload) + }) + + it('returns undefined for a path that does not resolve', () => { + const { manager } = setup() + expect(manager.extractJsonPath({ value: 1 }, '$.missing')).toBeUndefined() + }) + + it('does not throw on a malformed path', () => { + const { manager } = setup() + expect(() => manager.extractJsonPath({ value: 1 }, '$[')).not.toThrow() + }) +}) diff --git a/ui/src/composables/useSubscriptionManager.ts b/ui/src/composables/useSubscriptionManager.ts index e9786bb..5511bcf 100644 --- a/ui/src/composables/useSubscriptionManager.ts +++ b/ui/src/composables/useSubscriptionManager.ts @@ -370,7 +370,10 @@ export function useSubscriptionManager() { stats.subscriptionErrors = 0 } - function extractJsonPath(data: any, path: string): any { + // `path` is optional because every caller's is: WidgetListener.jsonPath is + // optional, and a widget bound to a whole payload has none. The body already + // handled undefined; only the signature disagreed. + function extractJsonPath(data: any, path?: string): any { if (!path || path === '$') return data try { return JSONPath({ path, json: data, wrap: false }) } catch { return null } } diff --git a/ui/src/stores/auth.spec.ts b/ui/src/stores/auth.spec.ts new file mode 100644 index 0000000..8e49603 --- /dev/null +++ b/ui/src/stores/auth.spec.ts @@ -0,0 +1,169 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useAuthStore } from './auth' + +// The `can` map is the console's entire authorization surface, and it mirrors +// the API rules in schema.json BY HAND. Nothing checked the mirror: the server +// half is proven by scripts/test-authz.sh, and this half had no guard at all, +// so a role gaining or losing a capability here was a silent change. +// +// The matrix below is the table in CLAUDE.md, transcribed. If a change makes a +// cell here fail, either the table is out of date or the change is wrong -- +// and either way it should be a decision rather than a surprise. +type Role = 'owner' | 'admin' | 'member' | 'viewer' | 'dashboard' +type Capability = + | 'manageMembers' + | 'manageInfrastructure' + | 'manageDefinitions' + | 'manageLeafNodes' + | 'manageMessaging' + | 'viewInventory' + | 'manageInventory' + | 'decommissionInventory' + +const ORG = 'org_1' + +function storeFor(role: Role | null) { + setActivePinia(createPinia()) + const auth = useAuthStore() + auth.user = { id: 'u1' } as never + if (role) { + auth.memberships = [{ id: 'm1', organization: ORG, user: 'u1', role }] as never + auth.currentOrgId = ORG + } + return auth +} + +// true = the role holds the capability. +const MATRIX: Record> = { + owner: { + manageMembers: true, + manageInfrastructure: true, + manageDefinitions: true, + manageLeafNodes: true, + manageMessaging: true, + viewInventory: true, + manageInventory: true, + decommissionInventory: true, + }, + admin: { + manageMembers: true, + manageInfrastructure: true, + manageDefinitions: true, + manageLeafNodes: true, + manageMessaging: true, + viewInventory: true, + manageInventory: true, + decommissionInventory: true, + }, + member: { + manageMembers: false, + manageInfrastructure: false, + manageDefinitions: false, + manageLeafNodes: false, + manageMessaging: false, + viewInventory: true, + manageInventory: true, + decommissionInventory: false, + }, + // Read-only staff: every inventory screen, no write control anywhere. + viewer: { + manageMembers: false, + manageInfrastructure: false, + manageDefinitions: false, + manageLeafNodes: false, + manageMessaging: false, + viewInventory: true, + manageInventory: false, + decommissionInventory: false, + }, + // The zero-authority role, and the reason it exists. It is the probe: an + // allowlist that has quietly become a deny-list lets `dashboard` through, and + // a role holding SOME authority cannot prove that, because it passes for the + // wrong reason. + dashboard: { + manageMembers: false, + manageInfrastructure: false, + manageDefinitions: false, + manageLeafNodes: false, + manageMessaging: false, + viewInventory: false, + manageInventory: false, + decommissionInventory: false, + }, +} + +const ROLES = Object.keys(MATRIX) as Role[] +const CAPABILITIES = Object.keys(MATRIX.owner) as Capability[] + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('auth capability map', () => { + for (const role of ROLES) { + describe(role, () => { + for (const capability of CAPABILITIES) { + const expected = MATRIX[role][capability] + it(`${expected ? 'holds' : 'does not hold'} ${capability}`, () => { + const auth = storeFor(role) + expect(auth.can[capability]).toBe(expected) + }) + } + }) + } + + it('holds nothing at all for the dashboard role', () => { + const auth = storeFor('dashboard') + expect(Object.values(auth.can).some(Boolean)).toBe(false) + }) + + // owner and admin are deliberately identical in the rules; the only + // difference lives elsewhere (an owner cannot leave their own organization). + it('gives owner and admin identical capabilities', () => { + expect(storeFor('owner').can).toEqual(storeFor('admin').can) + }) + + // viewer reads exactly what member reads. The difference between them is + // writes -- not reads, which the API rules do not role-scope at all. + it('differs between member and viewer only on write capabilities', () => { + const member = storeFor('member').can + const viewer = storeFor('viewer').can + expect(viewer.viewInventory).toBe(member.viewInventory) + expect(viewer.manageInventory).toBe(false) + expect(member.manageInventory).toBe(true) + }) +}) + +describe('userRole', () => { + // This used to fall back to 'member', which is a fail-OPEN default: a user + // whose memberships had not loaded, or whose membership was just deleted, was + // handed member capabilities and offered controls the API then refused. + it('is null with no membership in the active organization, not a role', () => { + const auth = storeFor(null) + expect(auth.userRole).toBeNull() + expect(Object.values(auth.can).some(Boolean)).toBe(false) + }) + + it('is null when the membership belongs to a different organization', () => { + setActivePinia(createPinia()) + const auth = useAuthStore() + auth.user = { id: 'u1' } as never + auth.memberships = [{ id: 'm1', organization: 'other_org', user: 'u1', role: 'owner' }] as never + auth.currentOrgId = ORG + + expect(auth.userRole).toBeNull() + expect(auth.can.manageMembers).toBe(false) + }) + + it('treats a superuser as an owner', () => { + setActivePinia(createPinia()) + const auth = useAuthStore() + auth.user = { id: 'su' } as never + auth.isSuperAdmin = true + + expect(auth.userRole).toBe('owner') + expect(auth.can.manageMembers).toBe(true) + }) +}) diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts index 51c97bc..d7b17cd 100644 --- a/ui/src/stores/auth.ts +++ b/ui/src/stores/auth.ts @@ -76,7 +76,7 @@ export const useAuthStore = defineStore('auth', () => { // NATS identities/roles/exports/imports, Nebula CA/networks/hosts. These // records mint signed credentials, so they are owner/admin only. manageInfrastructure: isAdmin, - // Thing types, thing operations, message schemas, location types. + // Thing types, thing operations, location types. manageDefinitions: isAdmin, // Edge nodes. manageLeafNodes: isAdmin, diff --git a/ui/src/stores/dashboard.spec.ts b/ui/src/stores/dashboard.spec.ts new file mode 100644 index 0000000..d3458d9 --- /dev/null +++ b/ui/src/stores/dashboard.spec.ts @@ -0,0 +1,204 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { createDefaultWidget } from '@/types/dashboard' +import { useDashboardStore } from './dashboard' + +// Import/export had no coverage, and a bad import loses a user's work silently: +// the `replace` strategy clears every local dashboard before writing, and the +// limit check decides whether anything is written at all. Nothing in the build +// or the type checker touches any of it. +// +// localStorage is stubbed rather than left absent. saveToStorage swallows its +// own errors, so without a stub these tests would pass through the failure +// branch and prove nothing about the path that actually runs in a browser. +function stubLocalStorage() { + const store = new Map() + ;(globalThis as any).localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: (i: number) => [...store.keys()][i] ?? null, + get length() { + return store.size + }, + } + return store +} + +function freshStore() { + setActivePinia(createPinia()) + stubLocalStorage() + const store = useDashboardStore() + store.localDashboards = [] + store.activeDashboard = null + return store +} + +// A dashboard with one real widget, so the round-trip has widget config to lose. +function seedDashboard(store: ReturnType, name: string) { + const dashboard = store.createDashboard(name) + if (!dashboard) throw new Error('createDashboard returned null') + dashboard.widgets = [createDefaultWidget('kvtable', { x: 0, y: 0 })] + return dashboard +} + +beforeEach(() => { + freshStore() +}) + +describe('dashboard export/import round trip', () => { + it('preserves the name and the widgets', () => { + const store = freshStore() + const original = seedDashboard(store, 'Cold Chain') + const json = store.exportDashboards([original.id]) + + const reimported = freshStore() + const result = reimported.importDashboards(json) + + expect(result.errors).toEqual([]) + expect(result.success).toBe(1) + expect(reimported.localDashboards).toHaveLength(1) + + const got = reimported.localDashboards[0] + expect(got.name).toBe('Cold Chain') + expect(got.widgets).toHaveLength(1) + expect(got.widgets[0].type).toBe('kvtable') + // Widget config has to survive, or an imported dashboard looks right and + // shows nothing. + expect(got.widgets[0].kvtableConfig?.kvBucket).toBe( + original.widgets[0].kvtableConfig?.kvBucket, + ) + }) + + it('assigns new ids, so importing the same file twice does not collide', () => { + const store = freshStore() + const original = seedDashboard(store, 'Twice') + const json = store.exportDashboards([original.id]) + + const target = freshStore() + target.importDashboards(json) + target.importDashboards(json) + + expect(target.localDashboards).toHaveLength(2) + const [a, b] = target.localDashboards + expect(a.id).not.toBe(b.id) + expect(a.id).not.toBe(original.id) + expect(a.widgets[0].id).not.toBe(b.widgets[0].id) + }) + + // storage / kvKey / kvRevision describe where a dashboard LIVED, not what it + // is. Exporting them would make an imported copy claim to be a shared + // dashboard backed by a KV key it does not own. + it('strips the storage location from the exported file', () => { + const store = freshStore() + const dashboard = seedDashboard(store, 'Local One') + const parsed = JSON.parse(store.exportDashboards([dashboard.id])) + + expect(parsed.dashboards[0]).not.toHaveProperty('storage') + expect(parsed.dashboards[0]).not.toHaveProperty('kvKey') + expect(parsed.dashboards[0]).not.toHaveProperty('kvRevision') + }) + + it('marks everything it imports as local', () => { + const store = freshStore() + const dashboard = seedDashboard(store, 'Local One') + const json = store.exportDashboards([dashboard.id]) + + const target = freshStore() + target.importDashboards(json) + expect(target.localDashboards[0].storage).toBe('local') + }) + + it('exports every local dashboard with exportAllDashboards', () => { + const store = freshStore() + seedDashboard(store, 'One') + seedDashboard(store, 'Two') + + const parsed = JSON.parse(store.exportAllDashboards()) + expect(parsed.dashboards.map((d: { name: string }) => d.name)).toEqual(['One', 'Two']) + }) +}) + +describe('dashboard import safety', () => { + it('reports a parse failure instead of throwing', () => { + const store = freshStore() + const result = store.importDashboards('{ not json') + + expect(result.success).toBe(0) + expect(result.errors.length).toBeGreaterThan(0) + expect(store.localDashboards).toHaveLength(0) + }) + + it('rejects a file that is not an export file', () => { + const store = freshStore() + const result = store.importDashboards(JSON.stringify({ dashboards: [] })) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it('skips a malformed dashboard without losing the good ones', () => { + const store = freshStore() + const json = JSON.stringify({ + version: '1.0', + dashboards: [ + { name: 'Good', widgets: [] }, + { name: 'No widgets array' }, + { widgets: [] }, + ], + }) + + const result = store.importDashboards(json) + expect(result.success).toBe(1) + expect(result.skipped).toBe(2) + expect(store.localDashboards.map((d) => d.name)).toEqual(['Good']) + }) + + // The limit is checked BEFORE anything is written, which is the part that + // matters: a partial import that then hit the limit would leave the user with + // some of their dashboards replaced and some not. + it('refuses an import that would exceed the limit, and writes nothing', () => { + const store = freshStore() + const many = Array.from({ length: 30 }, (_, i) => ({ name: `d${i}`, widgets: [] })) + const result = store.importDashboards(JSON.stringify({ version: '1.0', dashboards: many })) + + expect(result.success).toBe(0) + expect(result.errors.length).toBeGreaterThan(0) + expect(store.localDashboards).toHaveLength(0) + }) + + it('replace clears what was there; merge keeps it', () => { + const merge = freshStore() + seedDashboard(merge, 'Existing') + const incoming = JSON.stringify({ version: '1.0', dashboards: [{ name: 'Incoming', widgets: [] }] }) + + merge.importDashboards(incoming, 'merge') + expect(merge.localDashboards.map((d) => d.name)).toEqual(['Existing', 'Incoming']) + + const replace = freshStore() + seedDashboard(replace, 'Existing') + replace.importDashboards(incoming, 'replace') + expect(replace.localDashboards.map((d) => d.name)).toEqual(['Incoming']) + }) + + it('imports a single dashboard file too, and re-ids it', () => { + const store = freshStore() + const dashboard = seedDashboard(store, 'Single') + const json = JSON.stringify(dashboard) + + const target = freshStore() + const got = target.importDashboard(json) + + expect(got).not.toBeNull() + expect(got!.name).toBe('Single') + expect(got!.id).not.toBe(dashboard.id) + expect(got!.storage).toBe('local') + }) + + it('returns null rather than throwing on a bad single-dashboard file', () => { + const store = freshStore() + expect(store.importDashboard('{ not json')).toBeNull() + expect(store.importDashboard(JSON.stringify({ name: 'no widgets' }))).toBeNull() + expect(store.localDashboards).toHaveLength(0) + }) +}) diff --git a/ui/src/utils/twin.spec.ts b/ui/src/utils/twin.spec.ts new file mode 100644 index 0000000..6018d92 --- /dev/null +++ b/ui/src/utils/twin.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' + +import { + TWIN_BUCKET, + TWIN_BUCKET_CONFIG, + TWIN_DESIRED_BUCKET, + twinDrift, + valueAtPath, +} from './twin' + +// twinDrift is 20 lines and the platform's documentation spends several hundred +// words on its exact semantics: subset for objects, exact for arrays and +// scalars, and no operators, ever. None of that was pinned by anything, and the +// function was typed (any, any), so a "helpful" change to make a range work +// would have compiled, passed the build, and quietly redefined what every +// desired value on every deployment means. +describe('twinDrift', () => { + it('reports nothing when the reported value matches', () => { + expect(twinDrift({ mode: 'auto' }, { mode: 'auto' })).toEqual([]) + expect(twinDrift('auto', 'auto')).toEqual([]) + expect(twinDrift(20, 20)).toEqual([]) + expect(twinDrift(true, true)).toEqual([]) + }) + + // A desired value is a PARTIAL assertion: only the keys it names are checked. + // Full equality rots -- the day a device reports one new field, every + // assertion set months ago flips to "differs". + it('ignores reported keys the desired value does not mention', () => { + const desired = { setpoint: 20 } + const reported = { setpoint: 20, temp: 20.3, rssi: -61, firmware: '2.1.0' } + expect(twinDrift(desired, reported)).toEqual([]) + }) + + it('names the path of a key that differs', () => { + expect(twinDrift({ mode: 'manual' }, { mode: 'auto' })).toEqual(['mode']) + }) + + it('names a key the device has not reported at all', () => { + expect(twinDrift({ setpoint: 20 }, {})).toEqual(['setpoint']) + }) + + it('recurses into nested objects and reports the dotted path', () => { + const desired = { hvac: { setpoint: 20, mode: 'auto' } } + const reported = { hvac: { setpoint: 21, mode: 'auto' } } + expect(twinDrift(desired, reported)).toEqual(['hvac.setpoint']) + }) + + it('reports every differing key, not just the first', () => { + const desired = { a: 1, b: 2, c: 3 } + const reported = { a: 9, b: 2, c: 9 } + expect(twinDrift(desired, reported)).toEqual(['a', 'c']) + }) + + // Subset semantics are objects-only. An array is a value, so a desired array + // must match exactly -- otherwise "the first two elements are right" would + // silently count as agreement. + it('compares arrays exactly, not as a subset', () => { + expect(twinDrift({ zones: [1, 2] }, { zones: [1, 2] })).toEqual([]) + expect(twinDrift({ zones: [1, 2] }, { zones: [1, 2, 3] })).toEqual(['zones']) + expect(twinDrift({ zones: [1, 2] }, { zones: [2, 1] })).toEqual(['zones']) + }) + + // The degenerate case: a top-level scalar has no keys to recurse into, so its + // path is the empty string -- the whole value. + it('uses the empty path for a top-level scalar', () => { + expect(twinDrift('manual', 'auto')).toEqual(['']) + expect(twinDrift(20, 21)).toEqual(['']) + }) + + it('does not treat a type change as agreement', () => { + expect(twinDrift({ on: true }, { on: 'true' })).toEqual(['on']) + expect(twinDrift({ n: 1 }, { n: '1' })).toEqual(['n']) + }) + + // The rule the documentation is most emphatic about. An operator-looking + // object is just an object: {$gt: 30} is compared as a value, so it differs + // from a number rather than being interpreted. If this ever starts passing, + // someone has begun building a rules engine inside a KV browser. + it('treats an operator-shaped desired value as a plain value, not an operator', () => { + expect(twinDrift({ temp: { $gt: 30 } }, { temp: 35 })).toEqual(['temp']) + expect(twinDrift({ temp: { $between: [18, 22] } }, { temp: 20 })).toEqual(['temp']) + }) + + it('handles null without throwing or matching loosely', () => { + expect(twinDrift({ x: null }, { x: null })).toEqual([]) + expect(twinDrift({ x: null }, { x: 0 })).toEqual(['x']) + expect(twinDrift({ x: null }, {})).toEqual(['x']) + }) +}) + +// The drift indicator renders the VALUES, not a word for the difference, so +// turning a path back into a before/after is part of the contract. +describe('valueAtPath', () => { + it('returns the whole value for the empty path', () => { + expect(valueAtPath({ a: 1 }, '')).toEqual({ a: 1 }) + expect(valueAtPath('auto', '')).toBe('auto') + }) + + it('reads a dotted path', () => { + expect(valueAtPath({ hvac: { setpoint: 20 } }, 'hvac.setpoint')).toBe(20) + }) + + it('returns undefined rather than throwing on a missing branch', () => { + expect(valueAtPath({}, 'hvac.setpoint')).toBeUndefined() + expect(valueAtPath({ hvac: null }, 'hvac.setpoint')).toBeUndefined() + }) + + it('round-trips every path twinDrift reports', () => { + const desired = { hvac: { setpoint: 20 }, mode: 'manual' } + const reported = { hvac: { setpoint: 21 }, mode: 'auto' } + const paths = twinDrift(desired, reported) + expect(paths).toEqual(['hvac.setpoint', 'mode']) + for (const p of paths) { + expect(valueAtPath(desired, p)).not.toEqual(valueAtPath(reported, p)) + } + }) +}) + +// The two bucket names and their retention are duplicated in +// internal/leafsync/twin.go, and whichever side creates a bucket first defines +// it. Nothing enforces that they agree, so pin this side's values here; the Go +// side has its own test. +describe('twin bucket definitions', () => { + it('names the two buckets by owner, not by what they describe', () => { + expect(TWIN_BUCKET).toBe('twin') + expect(TWIN_DESIRED_BUCKET).toBe('twin_desired') + }) + + it('keeps history, so a revision list has something to show', () => { + expect(TWIN_BUCKET_CONFIG.history).toBe(10) + }) +}) From 7a4470067fa5d4e54585ace661b7e4e3f8a297b6 Mon Sep 17 00:00:00 2001 From: Brian Miller Date: Wed, 9 Sep 2026 00:25:25 -0500 Subject: [PATCH 12/14] fix(ui): make ConfirmDialog 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. Enter on a dialog nobody had read deleted the thing. Now: announces itself as a modal; labels and describes itself from the title and message it already renders; hides the decorative emoji from assistive technology; cancels on Escape; traps Tab; and returns focus to whatever opened it. Two decisions worth keeping. Focus lands on the dialog CONTAINER, not a button. Nothing is pre-selected, so Enter cannot confirm a destructive action, and the first Tab reaches Cancel because it comes first in the DOM. The alternative -- focusing Cancel -- reads the same but makes Enter meaningful, and the point here is that it should not be. Restoring focus tolerates the target having disappeared. The action just confirmed has usually removed the row whose button opened the dialog, so the element to hand focus back to may no longer be in the document. Fourteen tests cover it, and they are the one place in this suite that mounts a component: what is under test IS the DOM contract, and dialog semantics, focus movement and key handling cannot be asserted against a pure function. The config now documents that boundary rather than leaving the next person to guess it. Specs opt into jsdom per file. Writing them caught a bug in my own implementation. The focus trap filtered candidate elements on `offsetParent !== null`, a common visibility test that is null for EVERY element under jsdom (no layout) and for anything inside a position:fixed subtree in some engines -- so the candidate list came back empty and the trap was silently a no-op. Everything in this dialog is rendered by v-if and on screen whenever the dialog exists, so there was nothing to filter in the first place. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 24 + ui/package-lock.json | 775 ++++++++++++++++++ ui/package.json | 2 + .../components/common/ConfirmDialog.spec.ts | 221 +++++ ui/src/components/common/ConfirmDialog.vue | 155 +++- ui/vitest.config.ts | 16 +- 6 files changed, 1179 insertions(+), 14 deletions(-) create mode 100644 ui/src/components/common/ConfirmDialog.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 83eb1e7..be8ebfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,30 @@ and this file starts where the versioned releases do. ### 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 diff --git a/ui/package-lock.json b/ui/package-lock.json index 9060807..47e787b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -35,8 +35,10 @@ "@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", @@ -58,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", @@ -167,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", @@ -485,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", @@ -1190,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", @@ -1228,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", @@ -1379,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", @@ -1392,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", @@ -1414,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", @@ -1594,6 +1889,17 @@ "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", @@ -1612,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", @@ -1661,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", @@ -1680,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", @@ -1735,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", @@ -1935,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", @@ -1981,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", @@ -2067,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", @@ -2077,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", @@ -2454,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", @@ -2536,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", @@ -2560,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", @@ -2569,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", @@ -2650,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", @@ -2736,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", @@ -2759,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", @@ -3041,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", @@ -3139,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", @@ -3244,12 +3858,38 @@ "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", @@ -3348,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", @@ -3487,6 +4134,26 @@ "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", @@ -3500,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", @@ -3539,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", @@ -3899,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", @@ -4016,12 +4726,60 @@ "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", @@ -4059,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 2439682..a7819cf 100644 --- a/ui/package.json +++ b/ui/package.json @@ -38,8 +38,10 @@ "@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", 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 @@