mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
15 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
59489b3b20 |
#240: stop the morning wave buying itself a blocking checkpoint
Bold reported loop lag that grew with uptime and reset on restart. The signature they saw — mean = p50 = p99 = max, identical to two decimals — is not a fixed cost paid on every cycle. It is what an IntervalHistogram window reports when it recorded exactly ONE delay: the mean is the raw value, and every percentile returns the bucket ceiling above it. Reproduced against their exact numbers (1329.07 / 1329.59). So the loop took one long turn that swallowed the sampling second, episodically — which is what they later confirmed independently. The turn is ours, and it is now measured rather than theorised. Probing the real worker against a real WAL with one reader mid-transaction: a single main-thread write blocked for 4,936ms behind the worker's wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE is the blocking form and its locks are held ACROSS connections, so moving it to a worker kept the fsync off the loop but not the lock; and it does not throw when it cannot get those locks, it returns busy=1 having sat on SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled loop for zero benefit, and silent. It was reached far too easily. The rule was "escalate if the WAL grew across three consecutive 15s runs" — which any sustained 45-second write burst satisfies. A customer's fleet powering on in the morning does it daily. Two gates, because either alone leaves the hole open. A size FLOOR, so a WAL in the lower half of its budget can't buy a blocking checkpoint it has nothing to reclaim from. And a COOLDOWN, because the floor alone fixes nothing for Bold — their WAL already sits at 6.2MB against a 16MB high-water, above any sane floor, so every burst would still escalate. However long the pressure lasts, our own maintenance may now stall the loop at most once per window. The high-water rule bypasses both and is untouched: a runaway WAL is the one case worth blocking for, so the "WAL cannot grow forever" invariant is exactly as strong as before. A busy TRUNCATE now says so in the log instead of reading like a success. Also softened the adjacent path: when the worker is declared unrecoverable, engageFallback() re-arms inline autocheckpoint on the main connection — a state that is STICKY for the life of the process, i.e. exactly the shape of "degrades with uptime, a restart fixes it". It used to also run an unconditional main-thread TRUNCATE on the way in; that now happens only when the WAL is genuinely over high-water, and the fallback state is served on /api/status rather than being inferable only from a log line that may have rolled. Telemetry, so the next report is self-explanatory: loop_lag carries `samples` (~50 when healthy, 1 when a single turn swallowed the second), `tick_gap_ms` measured on the WALL CLOCK independently of the histogram, and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling can no longer miss an episode. Band semantics are deliberately unchanged. A one-sample window during a real stall is the correct trigger for the shed valve; suppressing it would blind the protection at exactly the moment it is needed. Separately, device_telemetry gets the age sweep it never had. The per-heartbeat row cap only ever trims the device whose heartbeat is being handled, so a device that STOPS reporting leaves its rows behind forever. The new sweep is per-device (rides idx_telemetry_device rather than scanning), chunked and yielding like the device_status_log one, and defaults to 30 days to match the uptime report's own default window — so it cannot remove rows that report would have shown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS |
||
|
|
9c70fcc790
|
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
8ad2258e7c |
feat: app-ending signal (exit-signal contract v1) — server + APK + .wgt + /player
Best-effort "last gasp" so Offline is annotated with WHY it went away — completing the liveness story.
Categories: crashed (client uncaught-exception), clean_exit (client confident lifecycle-end, best-effort),
silent (SERVER-inferred by absence — the honest catch-all for violent/external death incl. force-stop/MDM).
SERVER:
- device:exit socket handler + token-authed beacon POST /api/device/exit (reliable-on-unload). Both gated
by liveness.sanitizeExitReason (honesty: only crashed/clean_exit accepted; 'silent'/unknown rejected).
- offline_reason/offline_reason_at/offline_detail columns (additive migration). Clear-on-online (a reason
is always THIS session's); offline transition COALESCEs to 'silent'. Pure annotation — offline detection
and #148/liveness are untouched. Offline dashboard emits carry offline_reason + client_type.
CLIENTS (canonical {reason,detail} shape):
- /player: window error/unhandledrejection + pagehide(persisted=false) -> sendBeacon.
- .wgt: same + BACK-key exit -> socket.emit + sendBeacon.
- APK: global UncaughtExceptionHandler -> crashed (blocking beacon, chains to default); Service.onDestroy
-> clean_exit (socket + bounded beacon). New ExitSignal.kt. onStop/onPause NOT wired (background != exit).
Proven (Phase 3): per-category classification, nothing misclassified, external kill -> silent (never
clean_exit), backgrounding emits no false exit, #148/reconnect-vs-exit intact. 382/382 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4cf156d4a0 |
feat(server): v4 liveness CORE pass — uniform heartbeat-ack + ack-gap + dashboard liveness + identity
Server-side keystone: the server now honors the v4 liveness contract uniformly across the MIXED fleet (v4 + old pre-v4 + disconnected), all three clients depending on it. - UNIFORM heartbeat-ack: emitted from the single shared device:heartbeat handler (uniform by construction; no per-client/per-path branch), BEFORE the auth guard so a known device's watchdog stays armed. Harmless to old clients (they ignore it). - RECONNECT-WINDOW ack-gap fix (ackableHeartbeat): ack a KNOWN device (authed socket OR a device_id that resolves) even mid-reconnect; NOT anonymous/never-authenticated sockets (degrade-safe); identity-agnostic. No state mutation before requireDeviceAuth (auth surface unchanged; device_ids are uuidv4). - DASHBOARD LIVENESS (deriveLiveness): server-derived, VERSION-AGNOSTIC Healthy/Degraded/Offline from signals every client sends (socket presence, heartbeat age, reconnect frequency); no client status-push. - IDENTITY CAPTURE (capture-don't-act): client_type/client_version/platform/contract_version columns; degrades to legacy/unknown for old clients; NEVER breaks register. - A-BUCKET FIX (QA): recordReconnect + persistIdentity gated on !isPlaylistRefresh (a ~45-60s refresh is not a reconnect/new identity — matches #134), and the identity write is change-detected — closing the WAL write-amplification (A1) and the benign-refresh -> false-"Degraded" (A2) regressions. New lib/liveness.js (pure helpers, unit-tested). 30 new tests (uniform ack, ack-gap, mixed fleet, identity capture, cross-client conformance, refresh-gate reproduce-then-prove); 366/366 total. OTA artifact-availability is a separate concern (out of scope). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bcfe3eaf8b |
fix(#148) Items 2-4: mark-offline closes the socket + tighten ping + TCP keepalive
Item 2: when the heartbeat checker marks a device offline it now also disconnects any socket it still holds for it, so DB-offline can't diverge from socket-state into a silent half-open (defensive — the live-socket guard already defers genuinely-live sockets). Item 3: tighten half-open detection WITHOUT reintroducing the TV-WebKit decode-load risk the 30s pong-timeout was chosen for — lower only pingInterval 30s->15s (probe more often), KEEP pingTimeout at 30s. Detection = interval+timeout = 45s (was 60s), and the client inherits these via the handshake so BOTH ends detect a dead peer ~25% sooner. (Deliberately did NOT drop pingTimeout to ~20s: MAXHUB is a video-playing TV-class device and the code comment warns tighter timeouts cause spurious drops under decode load.) Item 4: SO_KEEPALIVE on every accepted connection (lib/tcp-keepalive.js) so a half-open TCP can't persist indefinitely at the OS layer, independent of the app ping. Tests: server closes a non-ponging peer within ~pingInterval+pingTimeout while a ponging peer survives; a device whose transport dies ends offline with its connection torn down; keepalive applied to each accepted connection (and never breaks setup on error). Suite 328/328. |
||
|
|
977407ce99 |
feat(#146): usage metering + admin-gated Billable Screens report (contract system-of-record)
Implements the ByteTinker-Bold distribution-agreement billing math and surfaces it on a standalone admin-only route. No UI (the API figure is the deliverable). Server-side only. Contract math (lib/billing.js, config-driven; defaults ARE the agreement): - ASD (per device/day) = min(1.0, online_seconds / (hours*3600)) # 28800 default - BillableScreens (per month) = round-half-up( Sum ASD / days_in_month ) - Flat tier (not marginal): 1-499 $1.50 / 500-999 $1.25 / 1000+ $1.00; cost = screens*rate. Single global rate card for now (per-tenant is a future concern; noted in code). Data foundation: - New durable rollup device_usage_daily(device_id, day 'YYYY-MM-DD', online_seconds), index on day. status_log (3d) / telemetry (24h) can't back a billing month. - Accumulated INCREMENTALLY off the heartbeat tick from the live connection map (same source as devices_connected) - never reconstructed from logs. Each tick credits every connected device's today-row (min(86400, +elapsed)), chunked + transactional (non-blocking); per-tick credit capped (accrualCapSeconds) as a stall/restart guard. - Retention ~400d, pruned via chunked-prune (pruneUsageDaily in runMaintenance). API: GET /api/billing/usage?month=YYYY-MM (default current), requirePlatformAdmin, mounted SEPARATELY from /api/status (billing is revenue data + a heavier aggregate; must not touch the hot status path). Reads the rollup only. MTD figure averages over COMPLETED days only (today shown in `daily` but excluded until it completes); is_final + billable_screens_final appear once the month completes. Tests (12): ASD math; billable round-half-up; flat tier/cost boundaries; accumulator (accrues by interval, caps at 86400/day, disconnected doesn't accrue); report MTD-excludes- today + final-month is_final; retention prune; endpoint authz (admin 200 / non-admin 403 / anon 401) + billing absent from /api/status. Suite 301/301. First-full-month caveat + formula in docs/billing.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9418582de5 |
feat(#146): always-on devices_connected + admin-toggleable /api/status debug block
1. devices_connected (always on, never gated): a top-level /api/status field next to loop_lag = LIVE WS socket count from the heartbeat connection map (getConnectedCount), NOT devices.status='online' (which lags by the offline-timeout). The single most-glanced operational number, so it can't disappear when debug is off. Also dropped 4 dead per-poll COUNT(*) queries the route computed but never returned. 2. debug block behind an admin flag: new minimal app_settings KV table (none existed; ai_settings is per-workspace, white_labels is branding) + lib/app-settings.js (cached, refresh-on-write so status polls read a cached boolean, not a DB row). routes/status.js includes `debug` ONLY when status_debug_enabled is on (persisted value overrides the STATUS_DEBUG_ENABLED env default); when off the key is omitted entirely. 3. Admin toggle: GET/PUT /api/admin/status-debug (requirePlatformAdmin, mirrors the branding endpoints) + a checkbox in the Admin tab "Status endpoint" section (mirrors the branding checkbox). Takes effect on the next poll, no restart. Tests: devices_connected always present+numeric and rises with a live socket (booted + socket.io-client); debug present by default, admin flips OFF -> key omitted (loop_lag + devices_connected remain) -> ON again, no restart; non-admin 403, anon 401; unit coverage for getConnectedCount + app-settings default/override. Suite 289/289. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8dd6491288 |
fix(#146) P1.3: per-feature env kill switches + fallout doc section
Every new subsystem is disable-able via env (flip + restart, no redeploy/bisect): - FLAP_LIMITER_ENABLED=false -> flap limiter always allows. - OTA_DOWNLOAD_GUARD_ENABLED=false -> download guard always admits. - MAINTENANCE_BAND_GATE_ENABLED=false -> interval maintenance ignores band. - CONNECT_RATE_QUARANTINE_TRIPS=0 -> quarantine off (already; confirmed). Startup prune is never band-gated regardless. Kill switches table added to the fallout doc. Tests assert each OFF behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2f7133f8c8 |
fix(#146) A: non-blocking maintenance — chunked+yield+band-gate all sweeps
The death-spiral amplifier: pruneStatusLog ran a whole-table ROW_NUMBER() sort, 40-48s synchronous on the 1.1M-row incident table, freezing boot -> healthcheck fail -> restart loop. - lib/chunked-prune.js: shared chunkedDelete (rowid IN (SELECT ... LIMIT ?) since better-sqlite3 has no DELETE...LIMIT) — bounded batch + setImmediate yield between batches, optional band-gate. Core invariant: no sync op blocks >~50ms ever. - pruneStatusLog: rewritten per-device via a loose index-scan seek (WHERE device_id > ? ORDER BY device_id LIMIT 1 — O(log n) each), retention + newest-cap trimmed in bounded batches, async, re-entrancy-guarded, band-gated on the interval / un-gated + fire-and-forget at startup so a bloated table self-heals on deploy WITHOUT freezing boot. - heartbeat.js: maintenance moved off the interval body into async band-gated re-entrant runMaintenance(); play_logs + provisioning prunes chunked; offline-marking stays synchronous. - pruneTelemetry: bounded single statement (OFFSET 6000 LIMIT batch), stays sync. - idx_devices_provisioning so the provisioning prune batch subquery is an index range. Tests: correctness (per-device cap + retention, independent devices), 300k-row backlog trims in many batches with max event-loop gap <250ms, band-gate no-op while critical + startup runs regardless, re-entrancy (concurrent -> once). Existing prune tests updated to await. Suite 247/247. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
81e7d58099 |
fix(#146): reconnect/heartbeat storm containment (beta5)
Second head of the OTA-loop root cause (#144), on the connection/heartbeat layer: unbounded device-driven work with no circuit-breaker. Symptoms in Bold prod — devices shown OFFLINE in CMS while online+playing, loop-lag simmer (p99 300-1145ms), device_status_log grown to 1.1M rows. False-offline (two causes, both fixed): - evicted-socket re-arm race: evictPriorSocket runs before registerConnection, so the evicted old socket's disconnect armed a fresh offline timer for a just-reconnected device. Tag evicted socket ids and bail in the disconnect handler (ws/deviceSocket.js). - heartbeat checker false-positive: a device with a live socket in /device is UP even if its in-memory lastHeartbeat is stale under lag; skip it instead of marking offline (services/heartbeat.js). Storm containment: - batched/coalescing device_status_log writer (lib/status-log-writer.js): net state per device per flush, breaking the storm->bloat->slow-write->lag loop. - newest-N-per-device row-count cap in the global sweep (db/database.js): hard bound regardless of churn; trims the existing 1.1M backlog on the first sweep. Per-device prune unified to statusLogRetentionDays (was hardcoded 7d). - reconnect-throttle idle-bucket sweep (lib/reconnect-throttle.js): the #142 throttle already existed; added the memory-bound sweep it lacked (wired in server.js). No second breaker. - cosmetic: cap the OTA breaker level counter (lib/ota-breaker.js). - best-effort status-log flush on the crash path (server.js). Tests: load harness (test/reconnect-storm-load.test.js) proves breaker engage, clean offline-clear, no-throttle-on-normal-reconnect, batched writes, bounded loop-lag; cause-1 re-arm race proven with teeth (test/evicted-socket-rearm.test.js). Both mutation-checked (fail without their fix). Full suite 240/240. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
139d7d09fa |
fix(#142): provisioning-row cleanup window 365d -> 24h (matches its own comment)
services/heartbeat.js deleted unclaimed provisioning devices with created_at < now - (365 * 86400) — a YEAR — while its own comment said "older than 24 hours". So socket-register pairing junk lingered ~365x longer than intended. Change the window to 24 * 3600 to match the comment. Correctness fix only — does NOT touch the pre-auth register path or add a rate limiter (that pre-auth hardening is a separate security issue, out of this cut). Extracted the sweep into pruneProvisioningDevices() (still in heartbeat.js, called from the same interval) so it is unit-testable. Test asserts a >24h unclaimed provisioning row is swept while a <24h row, an imported row (user_id set), and a non-provisioning row are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
29a8896aa8 |
fix(#142): global device_status_log retention sweep + STATUS_LOG_RETENTION_DAYS
The per-device insert-time prune (deviceSocket.js) only ever touches a device that is actively inserting, so it misses two paths: removed/idle devices whose rows linger forever, and heartbeat.js's offline_timeout insert that bypasses logDeviceStatus entirely. The reporter's 1.2M-row bloat accumulated UNDER a 7-day per-device prune for exactly this reason. - pruneStatusLog() (db/database.js): a GLOBAL time-range sweep across ALL devices, modeled on the play_logs prune. Run once on startup (recovers a bloated table right after deploy) and on the heartbeat interval (services/heartbeat.js). - STATUS_LOG_RETENTION_DAYS env, default 3 (lower than the old hardcoded 7d; the dashboard only shows a 24h uptime window, so 2-3d is ample for diagnostics). - Deliberately NO per-device row cap: Step 3's throttle already bounds how fast a storming device can generate status rows, so a cap would add sweep complexity for little gain (noted for later if needed). - NO VACUUM / auto_vacuum here (kept off the hot path); space reclaim is left as a separate decision (see report). test: deterministic in-process unit test proves the sweep deletes over-retention rows across all devices — including a device absent from the devices table and an offline_timeout row — while keeping recent rows; idempotent on an empty table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c4fbd2ba5c |
feat(workspaces): invite/accept-invite backend (slice 1+3)
Slice 1 + 3 of the user-management feature from the May 12 plan.
Backend-only - no UI yet (slice 2 ships separately). Backend +
accept-handler together so the email accept link is functional
from day one without a half-state.
Endpoints added:
- GET /api/workspaces/:id/members (any member; via_org=true
for org-level entries,
read-only from ws context)
- GET /api/workspaces/:id/invites (workspace_admin)
- POST /api/workspaces/:id/invites (workspace_admin)
- DELETE /api/workspaces/:id/invites/:inviteId (workspace_admin)
- PUT /api/workspaces/:id/members/:userId (workspace_admin)
- DELETE /api/workspaces/:id/members/:userId (workspace_admin)
- POST /api/auth/accept-invite/:inviteId (requireAuth +
case-insensitive
email match)
Permission gating:
- canAdminWorkspace (existing) for admin-gated endpoints
- canAccessWorkspace (new helper in lib/permissions.js) for the
members read endpoint - mirrors canAdminWorkspace shape but
admits any workspace_members role plus org/platform paths
Security additions vs the original plan:
- Transaction-bounded collision check on POST /invites closes the
TOCTOU race between simultaneous duplicate POSTs (no UNIQUE
constraint on workspace_invites(workspace_id, email))
- Per-(inviter, workspace), hour-window rate limit on POST /invites
to prevent abuse / cost runaway. Env-configurable via
INVITE_RATE_LIMIT_PER_HOUR with conservative 50/hour default.
429 response is generic - does not echo the configured value.
- Invite expiry env-configurable via INVITE_EXPIRY_DAYS (default 7)
- PUBLIC_URL env var (optional) pins the accept-URL origin in prod;
falls back to request-derived for local dev
Rollback rule on email send: only graph_error (real send attempt
failed at Graph) deletes the row and returns 502. not_configured
and dev_restricted are intentional non-sends - keep the row, count
against rate limit, allow local accept-invite testing to proceed.
Other safety blocks:
- Cannot demote/remove the last workspace_admin (409)
- Cannot remove the parent-org's org_owner via workspace path (403)
- Accept-invite is idempotent if user already a member
- Expired invites delete-on-read and return 410
- Wrong-account accept returns 403 without touching the invite
Expired-invite cleanup added to services/heartbeat.js mirroring
the team_invites sweep pattern.
Verification: 9-case curl-driven E2E against the dev DB fixture
(switcher-test + invitee-existing + invitee-new mid-flow register).
All 9 pass: create / collision-409 / second-create / rate-limit-429 /
existing-user-accept / register-then-accept / wrong-account-403 /
expired-410 / viewer-cannot-invite-403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
fc29843035 |
feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.
Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket). Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped. Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed. Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login. |
||
|
|
1594a9d4a4 |
Initial open source release
ScreenTinker - open source digital signage management software. MIT License, all features included, no license gates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |