Skip to content

Live updates and filtering on every list; stop broadcasting model events to everyone - #218

Merged
wmantly merged 2 commits into
masterfrom
feat/live-updates-and-filtering
Aug 12, 2026
Merged

Live updates and filtering on every list; stop broadcasting model events to everyone#218
wmantly merged 2 commits into
masterfrom
feat/live-updates-and-filtering

Conversation

@wmantly

@wmantly wmantly commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Only the Proxy List updated itself, and only via code hand-written in that one view. Two root causes, plus a security problem found on the way.

Why only one page ever updated

Only Host published events. Every model registers a ModelPs proxy, but only host.js exports it — the rest export the raw class, and the routes import those files directly. LocalGroup and Permission published nothing, so the Groups and Permissions pages were subscribed to events that were never sent.

ModelPs published the class name as the primary key. getIndex() read model[Model._key], and on a static call model is the class — which always has a built-in .name. So for any model keyed on 'name', the pk of every event was the literal string "LocalGroup". Creates appended a row matching nothing; updates would have duplicated the record. Host behaved only because its key is 'host' and Host.host is undefined.

Security: the bridge broadcast everything to everyone

app.io.emit('P2PSub', …) sent every model event, with its full record, to every authenticated socket. No per-model or per-row read check — a viewer scoped to one domain received live payloads for every other domain in the install.

utils/socket_pubsub.js now gates per socket, mirroring the REST read guards (Host → viewer on that domain, DnsProvider → admin, …), caching resolved rights briefly and busting them when a grant or group membership changes. Models without a gate are not broadcast at all, so a new model must opt in deliberately rather than start leaking the moment someone wraps it in ModelPs.

Clients could also inject topics: socket.on('P2PSub') republished anything a client emitted to every other client. No app code has ever called app.publish(), so this carried no legitimate traffic; events now flow server → client only. That also fixes a crash where a delete event's null body was tagged unconditionally.

Lists

Hosts, Permissions and Groups now use app.filter.live() from @simpleworkjs/frontend. The one changed row is patched rather than reloading the whole list, so scroll position, checkbox selection and open dropdowns survive another user's edit. Each list gains a search box and a count; hosts also gets a type facet, and its search now covers IP, port and DNS provider instead of hostname only.

Verification

216 unit tests pass, including 10 new ones for the read gate. Driven in a real browser:

  • create / update / delete propagating live between two tabs with no refresh
  • a live insert appearing only if it matches the active filter, and showing up when that filter is cleared
  • a domain-scoped viewer receiving only model:Host:update:alpha.example.com while delta.test.local was withheld entirely — the admin tab received both

Note on the dependency

Stays at ^0.3.0, which 0.3.1 satisfies, so npm ci keeps working. Two framework fixes this UI wants (Bootstrap d-flex rows not hiding, and a stale filter count) are in @simpleworkjs/frontend@0.3.1, which is merged and tagged but not yet published to npm. Once it is, a routine npm install here picks it up with no code change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BxAmDhp1KABTwWUFca7sQR

wmantly and others added 2 commits August 12, 2026 03:17
…ts to everyone

Only the Proxy List updated itself, and it did so through code written
by hand in that one view. Two root causes, both fixed here.

Only Host ever published. Every model registers a ModelPs proxy, but
only host.js *exports* it -- the rest export the raw class, and the
routes import those files directly. So LocalGroup and Permission
published nothing at all, and the Groups and Permissions pages
subscribed to events that were never sent.

ModelPs published the class name as the primary key. getIndex() read
model[Model._key] with `model` being the class on a static call, and
every class has a built-in `.name` -- so for any model keyed on 'name'
the pk of every event was the literal string "LocalGroup". Creates
appended a row that matched nothing; updates would have duplicated the
record. Host was the sole model that behaved, purely because its key is
'host' and Host.host is undefined.

The socket bridge broadcast everything to everyone. app.io.emit sent
every model event, with its full record, to every authenticated socket
-- no per-model or per-row read check, so a viewer scoped to one domain
received live payloads for every other domain in the install. It is now
gated per socket by utils/socket_pubsub.js, mirroring the REST read
guards, with rights cached briefly and busted when a grant or group
changes. Models without a gate are not broadcast at all, so a new model
must opt in rather than start leaking the moment it is wrapped.

Clients could also inject topics: socket.on('P2PSub') republished
anything a client sent to every other client. No app code ever published
locally, so this carried no legitimate traffic; events now flow
server -> client only. That also fixes a crash where a delete event's
null body was tagged unconditionally.

Hosts, Permissions and Groups now use app.filter.live() from
@simpleworkjs/frontend: one changed row is patched instead of reloading
the whole list, and each list gains a search box, a count, and (for
hosts) a type facet. Hosts search now covers IP, port and DNS provider,
not just the hostname.

Verified in a browser: cross-tab live create/update/delete, filtered
live inserts, and a domain-scoped viewer receiving only the events for
its own domain while the admin receives both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxAmDhp1KABTwWUFca7sQR
… had

The DNS page drove $.scope[Model] straight off the event topic inside
empty catch blocks. Two things made it a no-op: the scopes declared
`jq-repeat-index` -- an attribute jq-repeat WRITES onto rendered rows and
never reads as configuration -- instead of `jq-index-key`, so they had no
key and every remove/update lookup returned -1; and ModelPs published the
class name as the pk. The catch{} swallowed both, so the page looked
wired up while doing nothing.

Providers and dynamic records now use app.filter.live() with an explicit
key, and both gain a search box and a count. The same wrong attribute is
corrected on the Permissions list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxAmDhp1KABTwWUFca7sQR
@wmantly
wmantly merged commit c81df3e into master Aug 12, 2026
13 checks passed
@wmantly
wmantly deleted the feat/live-updates-and-filtering branch August 12, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant