Commit graph

308 commits

Author SHA1 Message Date
ScreenTinker 3bf108d0fb fix(switcher): expose workspace settings for single-workspace users (#19)
The switcher's "manage members" + "rename/slug" affordances lived only in the
multi-workspace (>1) dropdown. A user with exactly one accessible workspace got
a plain static name with no way to reach org settings - so a fresh user with a
fresh workspace couldn't invite users, set permissions, or rename their slug.

Fix: the single-workspace view now renders the workspace name plus inline
manage-members + rename icons when the user can administer it (can_admin). No
dropdown for a single item.

Refactored the icon markup into adminIconsHtml(w) and the click wiring into
wireAdminIcons(scope, list), shared by the single-workspace view and the
dropdown items so the two can't drift again.

Frontend only. Verified headless: a fresh single-workspace admin now sees both
icons; clicking members navigates to #/workspace/:id/members and the members
view renders. Server suite unaffected (33/33).

Closes #19.
2026-06-08 16:39:42 -05:00
screentinker c1f2f0a637
Merge pull request #24 from screentinker/feat/searchable-org-switcher-16
feat(switcher): searchable / filterable org switcher (#16)
2026-06-08 16:32:45 -05:00
ScreenTinker 1f62ffbc3b feat(switcher): searchable/filterable org switcher (#16)
At MSP scale (100+ orgs) the org/workspace switcher dropdown was an
un-scrollable wall. Add a type-to-filter search box.

- Sticky search input at the top of the switcher menu, shown once the list
  reaches a threshold (>= 8 workspaces); below that the plain list is fine.
- Live client-side filter: case-insensitive substring match on
  "organization name + workspace name" (data-search haystack per row). The
  full list is already loaded from /me, so no extra requests.
- Keyboard nav: search is auto-focused on open; type filters, ArrowUp/Down
  move a highlight among visible rows, Enter selects (switches), Esc closes.
- "No matches" state when nothing matches; opening resets the filter.
- Refactored the switch action into a shared switchTo() used by both click
  and Enter.

Frontend only. Verified headless: filter narrows live, no-match state,
clear restores, arrow-key highlight. EN i18n added.

Closes #16.
2026-06-08 16:31:46 -05:00
screentinker 0f84cac440
Merge pull request #23 from screentinker/feat/admin-user-workspace-mgmt
feat(admin): manage a user's workspace memberships (multi + per-workspace role)
2026-06-08 16:26:07 -05:00
ScreenTinker 2872b883c7 feat(admin): manage a user's workspace memberships (multi + per-workspace role)
The Workspace column on the platform Users page could only move a 0/1-workspace
user and showed a dead "N workspaces" label for multi-membership users. Replace
it with a "Manage workspaces" modal that handles the full picture.

Backend (routes/admin.js, requirePlatformAdmin):
- GET    /api/admin/users/:id/workspaces            list memberships (+org/ws names, role)
- POST   /api/admin/users/:id/workspaces            add to a workspace (upsert role)
- PUT    /api/admin/users/:id/workspaces/:wsId      change role in a workspace
- DELETE /api/admin/users/:id/workspaces/:wsId      remove (last one allowed -> unassigned)
Roles validated against WORKSPACE_ROLES; each mutation writes an audit row.

Frontend:
- Workspace cell is now a summary (Unassigned / <name> / N workspaces /
  "Platform (all)" for staff) + a Manage button.
- New admin-user-workspaces-modal: lists every membership with an inline role
  dropdown + Remove, plus a type-to-filter "Add to workspace" picker (org-grouped,
  excludes current memberships) with a role select. Staff get a note that they
  already have platform-wide access. Refreshes the table on close if changed.
- Removed the old single-select inline move control (superseded by the modal).

Tests: 6 added (add to multiple workspaces, per-workspace role change, upsert,
remove incl. last->unassigned, validation 400/404, non-platform-admin 403).
Full suite 33/33. Verified headless: Manage opens, lists memberships, filtered
picker, add/role-change/remove round-trips persist (throwaway user, cleaned up).
2026-06-08 16:24:52 -05:00
ScreenTinker 66c95bb331 fix(db): cascade tenant resources on workspace/org delete (#18 follow-up)
The #18 user-delete bug was the first symptom of a broader gap: 13 tables
reference workspaces(id) (and activity_log also organizations(id)) with NO
ACTION, so deleting a workspace or organization fails the same FK wall once it
holds any content. SQLite can't ALTER an FK action, so this migration rebuilds
each table (the create-copy-rename pattern the assignments/schedules migrations
already use), changing only the tenant FK clause:
  workspace_id -> ON DELETE CASCADE   (resources belong to the workspace)
  activity_log.workspace_id / organization_id -> ON DELETE SET NULL (keep audit)

user_id FKs are intentionally left as-is - user deletion stays handled app-side
by lib/user-deletion.js (the #18 fix).

- lib/tenant-cascade-migration.js: pure, idempotent core (table-existence
  guarded; transforms the stored CREATE text, copies rows verbatim, recreates
  indexes; fixes activity_log's AUTOINCREMENT sequence; baseline-vs-after
  foreign_key_check so pre-existing orphan rows don't abort it but a botched
  rebuild does).
- db/database.js: boot wrapper owns the pre-migration snapshot + process.exit
  on failure, matching the other heavy migrations.

Tests (node:test): reproduces the workspace-delete FK failure, applies the
migration, verifies FK actions (CASCADE / SET NULL), index recreation, data
preserved, and that workspace/org delete now cascades (activity_log preserved).
Full suite 27/27. Verified on a copy of a real DB: 13 tables rebuilt,
integrity_check ok, workspace delete cascades, no new FK violations.
2026-06-08 16:01:52 -05:00
screentinker ec44cb785a
Merge pull request #21 from screentinker/fix/delete-user-fk-cascade-18
fix: user deletion fails with FOREIGN KEY constraint (#18)
2026-06-08 10:52:50 -05:00
ScreenTinker 05f9c20ecf fix(admin): user deletion failed with FOREIGN KEY constraint (#18)
DELETE /api/auth/users/:id ran a bare `DELETE FROM users`, but 23 columns
reference users(id) and only 4 cascade, so with foreign_keys=ON the delete
fails the moment the user is referenced anywhere - and a real user always is
(owns an org, created a workspace, has login activity). Reproduces on a fresh
DB, exactly as reported.

The schema also lacks cascades from workspaces -> tenant resources, so the DB
can't clean up on its own. New lib/user-deletion.js resolves every reference in
one transaction (defer_foreign_keys=ON for forgiving order; table-existence
guard for resilience):
  - Refuse (409) if the user OWNS an organization that has other members -
    don't nuke a shared tenant; transfer ownership first.
  - Hard-delete the organizations they SOLELY own (workspaces + all contents).
  - In orgs they don't own, PRESERVE resources: SET NULL the nullable
    creator/inviter columns, and reassign the NOT NULL legacy creator user_id to
    the resource's org owner (fallback: the acting admin).
  - Memberships (organization_members/workspace_members/team_members/
    content_folders) cascade on the user delete; pending invites they sent and
    legacy teams they own are removed.

The handler now 404s an unknown id and 409s the shared-org case.

Tests (node:test): reproduces the FK failure, then verifies provisioned-member
delete (resources preserved + unlinked/reassigned), solo-org-owner cascade,
shared-org refusal (409), self-delete 400, non-superadmin 403, unknown 404.
Full suite 22/22. Verified end-to-end on a copy of a real DB: deleted a user
owning 2 solo orgs, foreign_key_check clean.

Closes #18.
2026-06-08 10:51:32 -05:00
ScreenTinker 7615eabdd5 feat(admin): Workspace column + inline move/assign on the Users page
Adds a "Workspace" column (after Plan) to the platform Users admin table so a
platform_admin can see and reassign a user's workspace inline, alongside the
Role/Plan dropdowns. Single-workspace move/assign model.

Backend:
- GET /api/auth/users (platform branch): one aggregate query adds
  workspace_count and, for exactly-one membership, the workspace id/name + org
  name (no N+1).
- PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded):
  move (1 membership) or assign (0) into the chosen workspace, default role
  workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a
  user with >1 membership (manage in the members view). logActivity
  admin_set_user_workspace.

Frontend (admin.js):
- Editable <select> only for a 'user' with 0/1 membership; multi-membership ->
  read-only "N workspaces", platform staff -> read-only "Platform (all)".
- Options grouped by org via <optgroup>, built ONCE from /me's
  accessible_workspaces (same source as the Add User picker) and reused per row.
- Picking "Unassigned" or the same workspace is a no-op so a stray pick can't
  strip a membership. Success -> toast + refresh. EN i18n only.

Tests: 4 added (single-membership move 200 + changed, zero-membership assign
200, multi-membership 400 refused, non-platform-admin/operator 403). npm test
16/16. Verified headless: column renders, selected value correct, "Platform
(all)" for staff, and a dropdown move persisted (throwaway user, cleaned up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
ScreenTinker 65691e26da chore(admin-sw): bump cache to v3 to evict stale clients
Force returning browsers to drop the old service-worker cache bucket so the
new platform Users "Add user" button lands. The SW is already network-first;
bumping CACHE (rd-admin-v2 -> v3) changes the SW bytes, which makes the browser
detect a new worker and run activate(), deleting every cache key != CACHE.
Also rescues any client still stuck on the pre-v2 cache-first worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
ScreenTinker 400872f8ea feat(admin): Add User from the platform Users page (workspace picker)
Extends the shared add-user modal (workspace-members-add-user-modal.js) with
an optional picker mode instead of forking a second form:
- opened with a fixed workspace (members view) -> unchanged, no picker;
- opened with null (platform Users admin page) -> shows an Org/Workspace
  picker (type-to-filter over /me's accessible_workspaces, labelled
  "org / workspace") plus the role select; email/name/password+generate/
  must-change/error-mapping stay shared.

Role options are rendered from a single WORKSPACE_ROLES constant that mirrors
the set POST /api/admin/users accepts (routes/admin.js) - so we never offer a
value the endpoint 400s (the platform_operator mismatch we already hit).
org_admin is intentionally NOT offered: the endpoint accepts only the three
workspace roles.

admin.js: "Add user" button in the page header (page is already
platform_admin-gated; the endpoint additionally enforces canAdminWorkspace,
which platform_admin passes everywhere). On success -> toast + refresh the
user list. Reuses workspace-members.js's mapMutationError. EN i18n only.

Frontend only - no backend change. Verified headless (Playwright): button
opens the modal, picker lists all 45 workspaces with working filter, role
options = [viewer, editor, admin], and submit created + assigned a user into
the chosen workspace (test row cleaned up afterward). npm test still 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
screentinker 9aae64c47a
Merge pull request #20 from screentinker/docs/surface-disable-registration
docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
2026-06-08 10:30:13 -05:00
ScreenTinker 406f481a57 docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
DISABLE_REGISTRATION already closes public self-service signup (first-user
setup on an empty DB still allowed) and the login page already hides its
"Create account" button when it's set - but the flag was easy to miss: it was
in the README env-var table yet absent from .env.example (the file
self-hosters actually copy) and from the README systemd unit example.

- .env.example: document DISABLE_REGISTRATION + DISABLE_HOMEPAGE under the
  Self-hosting section.
- README: add commented Environment= lines for both to the systemd example,
  noting the login UI hides the signup button to match.

Docs only - no code change. Backend gate (routes/auth.js canRegister +
/auth/config registration_enabled) and the login.js hiding already behave
correctly; verified registration_enabled flips to false under the flag.

Closes #11.
2026-06-08 10:29:08 -05:00
screentinker 212170eb88
Merge pull request #17 from screentinker/feat/role-model-and-admin-users
Role model + MSP user provisioning (#14, #13, #10, #12)
2026-06-05 13:44:46 -05:00
ScreenTinker 5502a3eaa8 fix(roles): make platform_operator assignable + add deny/assign regression tests
The bug: #13 added 'platform_operator' to the frontend role dropdown
(PLATFORM_ROLE_OPTIONS) but #14's PUT /api/auth/users/:id/role whitelist
(ASSIGNABLE_PLATFORM_ROLES) only listed ['user','platform_admin'], so
selecting "Platform operator" returned 400 "Invalid role" - the role was
unassignable via the UI.

Fix: add 'platform_operator' to ASSIGNABLE_PLATFORM_ROLES. One line; the
self-demote guard is intentionally left untouched (a platform_admin still
cannot self-assign the non-owner operator role and lock themselves out).

Tests (node:test, isolated in-memory DB injection - no DB_PATH change):
- admin-users.test.js: platform_admin can PUT role=platform_operator on a
  target user -> 200 and the row persists as platform_operator (regression
  guard for the whitelist gap).
- operator-permissions.test.js (new): verify-then-test of the highest-blast
  -radius deny. Operator CAN update/delete a workspace-scoped content row
  (cross-org write works) but is denied (403) updating or deleting a shared
  (workspace_id IS NULL) row - proving the separate PLATFORM_ROLES gate in
  content.js's checkContentWrite still holds after canWrite was broadened to
  isPlatformStaff.

Verified read-only (no leak): the other shared-asset write sites keep their
PLATFORM_ROLES gate that excludes operator - kiosk.js:57, widgets.js:110,
folders.js:31, layouts.js:59/117/133.

cd server && npm test -> 12 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:44:39 -05:00
ScreenTinker 7674f6dc9f test(admin): node:test coverage for Add User + role gating
Adds server/test/admin-users.test.js and a `npm test` (node --test) script.
No DB_PATH override: the suite mounts the real routers against an isolated
in-memory better-sqlite3 instance injected into the require cache, seeded by
the test itself. Node v20 built-ins only (node:test, node:assert, fetch).

Covers: Add User success (response omits password/hash, hash stored not
plaintext, membership written, hosted lifecycle sentinels stamped, audit row
without the password), duplicate-email 409 (no overwrite), non-admin 403,
platform_operator denied (403), org_admin scoped to their own org only,
input validation, and the must_change_password lifecycle (set on create,
surfaced on login, cleared on PUT /api/auth/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:23:06 -05:00
ScreenTinker 54549420e7 feat(signup): optional org-on-create for self-service signups (#12)
MSP-style deployments want self-service signups created WITHOUT a personal
org, so an admin/operator can assign them into an existing customer org
afterward.

- config.autoCreateOrgOnSignup (AUTO_CREATE_ORG_ON_SIGNUP env), default
  true - single-tenant and the hosted self-service flow are unchanged.
- ensureDefaultOrgForUser gains { allowCreate }: an existing membership is
  always returned (idempotent); the MINT path is gated. allowCreate=false +
  no membership -> returns null (user created org-less).
- register accepts a per-request createOrg flag overriding the deployment
  default; the first-ever user is always given an org (never headless).
  login / Google / Microsoft pass allowCreate from the global config, so an
  org-less user is not silently given an org on next sign-in.

Edge case: a non-platform user with zero workspaces now lands on a "no
workspaces yet" empty state (new no-workspace view) instead of being bounced
into onboarding (whose pairing step needs a workspace). route() redirects
them there, and refreshCurrentUser() redirects once /me reveals zero
accessible_workspaces (covers the first-load race). The workspace switcher
already rendered an empty placeholder and resource routes already return []
for a null workspace, so nothing crashes in between.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:16:27 -05:00
ScreenTinker 6e31770cee feat(admin): admin-provisioned user creation + first-login gate (#10)
Adds POST /api/admin/users so an admin can create a user directly with a
known password and assign them to a workspace + role - for self-hosted
instances with no outbound email, where invites never deliver.

Server (routes/admin.js, mounted /api/admin with requireAuth + activityLogger):
- Gated by canAdminWorkspace(db, req.user, targetWorkspace): 404 if the
  workspace is missing, 403 if not an admin of it. This scopes org_admins
  to their own org and excludes platform_operator (no user/role mgmt, #13).
- Validates email (invite-create regex), role in WORKSPACE_ROLES, password
  min-8 (the /me rule). 409 on duplicate email - never overwrites.
- One transaction: global users row (auth_provider 'local',
  bcrypt.hashSync(pw,10), must_change_password from the flag) + a
  workspace_members row written inline (same footprint as an accepted
  invite; accept-invite left untouched).
- Explicit audit row admin_create_user; never logs the password; response
  excludes password/hash.
- HOSTED_INSTANCE: never calls sendSignupEmails and stamps both
  welcome_email_sent_at / activation_nudge_sent_at, so an admin-created
  user gets no welcome email and never enters the activation-nudge sweep.

must_change_password (frontend-first enforcement, per spec):
- Migration adds users.must_change_password INTEGER NOT NULL DEFAULT 0;
  surfaced via requireAuth + /me + login responses.
- route() in app.js forces users with the flag to a #/change-password
  screen (new force-password-change view, reuses PUT /api/auth/me) and
  blocks every other view until set. The /me update clears the flag.

Frontend: "Add User" button beside "Invite member" in the members view
(admin-only) opening a modal (email, name, password + generate, role,
must-change checkbox); invite and Add User coexist. api.adminCreateUser;
EN i18n only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:03:56 -05:00
ScreenTinker 48902f6807 feat(roles): add cross-org platform_operator staff role (#13)
platform_operator is cross-org STAFF: it can see and act-as into every
org and read/write workspace-scoped resources (content, playlists,
layouts, schedules, devices, widgets, kiosk) anywhere - but holds NO
owner-level power.

Design is deny-by-default: operator is NEVER added to PLATFORM_ROLES /
isPlatformRole, so every owner capability (billing, org/workspace
deletion, user/role management, shared & template asset curation,
branding, workspace member mgmt/rename) stays denied, and any NEW owner
endpoint added later inherits that denial automatically.

Operator gets power from exactly two levers:
- middleware/auth.js: new PLATFORM_STAFF set + isPlatformStaff(); owner
  guards (PLATFORM_ROLES, requireAdmin, requireSuperAdmin) unchanged.
- tenancy.js: accessContext + resolveTenancy treat staff as act-as
  capable; new req.isPlatformStaff / req.isPlatformOperator (req.isPlatformAdmin
  stays owner-only); accessibleWorkspaceIds + switch-workspace guard use staff.
- permissions.js: canRead/canWrite + canAccessWorkspace (read) grant staff;
  canAdmin / canAdminWorkspace / isOrgAdmin / isOrgOwner stay owner-gated.

Read-only edges (per review): operator may VIEW workspace member lists
(canAccessWorkspace) and the unassigned device pool (devices.js), but
cannot mutate either.

Frontend: platform role dropdown adds "Platform operator"; the user-mgmt
view stays isPlatformAdmin-gated so operators can't open it. EN i18n only.

Behaviour identical under HOSTED_INSTANCE set or unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:30:21 -05:00
ScreenTinker 797eab7c8d refactor(roles): normalize the platform-role model (#14)
The legacy /api/auth/users dropdown could write 'superadmin' and 'admin'
role strings that not every code path recognized. Some checks matched only
'platform_admin' (tenancy accessContext/resolveTenancy), so a 'superadmin'
user could list orgs but not act-as into them.

Normalize to the current two-tier platform model (users.role holds the
PLATFORM role only; org/workspace roles live in the membership tables):

- Migration (idempotent, exact-string): superadmin -> platform_admin,
  admin -> user. No-ops on rows already in the current model.
- Add isPlatformRole() helper in middleware/auth.js; route the two
  superadmin-excluding checks in tenancy.js through it so a stray
  'superadmin' is never treated as lower-privileged (fixes act-as).
- Remove the dead/stricter requirePlatformAdmin in permissions.js (bare
  === 'platform_admin'); the single guard is the one in middleware/auth.js.
- Recovery-token default role admin -> platform_admin so emergency
  recovery keeps full access once 'admin' no longer implies elevation.
- PUT /api/auth/users/:id/role whitelist -> ['user','platform_admin'];
  self-demote guard retargeted via isPlatformRole.
- Frontend: platform user-management dropdown now offers User / Platform
  admin only; owner-delete guard and settings highlight use isPlatformAdmin.
  EN i18n: add admin.role.platform_admin.

Behaviour is identical under HOSTED_INSTANCE set or unset; the migration
only touches exact legacy strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:58:46 -05:00
ScreenTinker 0fec335e75 docs: add Android player troubleshooting & recovery guide
Covers the "Connecting to server" / xhr-poll-error hang (stale server URL,
fixed via Clear data + re-provision), and adb-over-Wi-Fi setup including the
gotchas: must be on the same subnet, and never `adb root` over a wireless
connection (it wedges adbd until reboot). Linked from the README Device Setup
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:58:09 -05:00
ScreenTinker 890ec5790f fix(proof-of-play): throttle play_logs writes to prevent runaway bloat
A player stuck in a tight loop (playlist with 0-second item durations)
fires device:play-event 'play_start' ~3x/sec, inserting a play_logs row
each time. Three web players doing this generated ~909k rows (99.9% with
duration_sec=0) and grew the prod DB to 265 MB.

Throttle proof-of-play inserts to at most one per device per 2s (in-memory
lastPlayLogAt map). Skipped cycles create no row; the live dashboard
progress event still fires every time, so the UI is unaffected. The
play_end UPDATE only closes open rows, so throttling play_start is safe.

(Junk rows already pruned in prod: 909k deleted, DB 265 MB -> 9.8 MB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:52:22 -05:00
ScreenTinker cbe00d6c85 feat(signup): T+3 activation nudge for users with zero paired screens
Daily sweep (15:00 UTC) emails a warm, personal "checking in" message
to users who signed up 3-14 days ago and still have no paired screen,
nudging them toward activation. Once per user, reuses the Graph
transport (services/email.js) via the existing fromName/rawSubject
options.

- New service services/activationNudge.js, started from server.js.
  Self-correcting daily scheduler (recompute next 15:00 UTC each run;
  no node-cron dependency).
- Eligibility (Option B, workspace-aware): created 3-14 days ago,
  activation_nudge_sent_at IS NULL, COALESCE(email_alerts,1)=1 (only
  an explicit opt-out of 0 is excluded; NULL/unset still qualify), and
  ZERO devices owned by the user OR present in any workspace they
  belong to. The workspace check avoids nudging engaged team members.
- Idempotency: activation_nudge_sent_at, stamped after send; paired
  sentinel-1 backfill so the first sweep can't blast the dormant
  legacy base. Only genuinely-new signups become eligible.
- GATE: HOSTED_INSTANCE=true (positive hosted signal, NOT !selfHosted).
  A daily bulk sweep would be far worse to leak than a single email, so
  a self-hoster who configured Graph but missed SELF_HOSTED won't blast
  their user base. Unset -> neither scheduled nor sent. Documented in
  .env.example.
2026-05-30 20:28:24 -05:00
ScreenTinker 2f78fa1106 chore: track .env.example (un-ignore from .env.* rule)
The prior commit's .env.example was silently dropped by the .env.*
gitignore rule. Add a "!.env.example" negation so the documented
template (placeholders only, no secrets) is tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:16:52 -05:00
ScreenTinker c0b220836a fix(signup): make admin-notify recipient env-driven, not hardcoded
The admin signup-notify recipient was hardcoded to
support@screentinker.com and shipped in the open-source code. Combined
with the opt-out SELF_HOSTED gate, any self-hoster who configured their
own Graph credentials but forgot SELF_HOSTED=true would fire their
users' signup PII (email, IP, country) into our support inbox.

Source the recipient from ADMIN_NOTIFY_EMAIL instead, defaulting to
null. When unset, the admin notification is skipped entirely and logged
("[SIGNUP-EMAIL] admin notify skipped (ADMIN_NOTIFY_EMAIL unset)"); the
user's welcome email is unaffected. Hosted prod sets the env var so its
notifications continue; self-hosters send nothing to us by default, and
the .com address no longer ships in code.

Document ADMIN_NOTIFY_EMAIL (and the related mail/self-host vars) in a
new .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:16:33 -05:00
ScreenTinker a0abdc01ed fix(signup): route admin signup notification to support@ not personal inbox
Admin signup notifications were going to dw5304@gmail.com. Route them
to the monitored support@screentinker.com queue instead, so signups
land in the shared inbox rather than a personal account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:52:59 -05:00
ScreenTinker b67fbaa1b6 feat(signup): welcome email + admin signup notification (slice 1)
Every new user now gets a personal welcome email from
"Dan at ScreenTinker" <support@screentinker.com>, and Dan gets an
admin notification, immediately after signup. Fired from all three
signup paths (local /register, Google, Microsoft) via a shared
helper (services/signupEmails.js) at the new-user branch only, so
OAuth logins of existing users don't re-trigger.

- Reuses the single Microsoft Graph transport (services/email.js).
  Adds two optional, backward-compatible params: fromName (custom
  From display name; address stays support@ so replies route there)
  and rawSubject (skip the "[ScreenTinker] " prefix for clean
  subjects "Welcome to ScreenTinker" / "New signup: <email>").
- Idempotency: users.welcome_email_sent_at, stamped after the send
  block; non-null short-circuits so a user is only emailed once.
  Paired backfill stamps all pre-existing users with sentinel 1 so
  a future "IS NULL" sweep can't mistake the legacy base for
  un-welcomed and blast them.
- Production-only: gated on !config.selfHosted so self-host
  operators never emit mail from our domain or CC Dan.
- No retry logic by design (no re-trigger path on existing users);
  per-email {sent, reason} is logged so a Graph hiccup is visible.

Admin notification includes workspace org name, email, UTC + Central
timestamp, client IP (CF-aware), CF-IPCountry, and user agent.
2026-05-30 14:50:27 -05:00
ScreenTinker d7e3ae6076 security(widgets): tighten webpage widget inner sandbox
The webpage widget's inner iframe previously declared
sandbox="allow-scripts allow-same-origin", which was
functionally stripped to "allow-scripts" by the outer
iframe sandbox added in fe36c8c. This commit makes the
declared sandbox match the actual effective behavior.

Closes the remaining piece of issue #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:14:31 -05:00
ScreenTinker fe36c8c4b9 security(widgets): add sandbox="allow-scripts" to widget iframes
Addresses the primary finding from the May 27 security report (issue #8):
the admin widget preview modal (frontend/js/views/widgets.js) and the web
player widget renderer (server/player/index.html, 2 sites) loaded
user-authored widget HTML into unsandboxed iframes. Same-origin scripts
in the widget content could access window.parent.localStorage and
exfiltrate the JWT.

sandbox="allow-scripts" without allow-same-origin sandboxes the widget
into a unique origin: inline scripts (clock, RSS, weather widgets)
continue to work, but parent-origin access and same-origin requests are
blocked. Verified via Playwright probe against all 6 widget types in the
dev DB (clock, rss, social, text, weather, webpage): each renders
correctly under the new sandbox and contentDocument access from the
parent is blocked (opaque-origin enforcement working). Admin preview
unchanged in appearance; player display unchanged.

Webpage widget (server/routes/widgets.js) sandbox tightening (drop
allow-same-origin) is a separate forthcoming commit - needs test against
real embed URLs since some sites rely on same-origin behavior. The
sandbox-attribute intersection rule means today's outer-iframe sandbox
will cascade and strip allow-same-origin from the webpage widget's inner
iframe too; accepted as a narrow cosmetic regression (cookies/localStorage
stripped for embedded sites) until the deliberate inner-iframe handling
ships.

SECURITY.md added with reporting process (GitHub Security Advisories
primary, support@bytetinker.net fallback) and scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:34 -05:00
ScreenTinker 159a36ed99 fix(workspaces): use APP_URL env var for invite-accept URL generation
Slice 1+3 (c4fbd2b) introduced PUBLIC_URL as the env var name for the
public-facing origin used to construct invite-accept URLs. The README
has long documented APP_URL as the canonical name for this concept
(used for Stripe callbacks in the existing codebase). The new code
should have read APP_URL from the start; PUBLIC_URL was unintentional
naming drift.

Caught during prod-deploy survey on 2026-05-17: APP_URL was set on the
production systemd unit and documented in the README, but read by no
code path on origin/main. PUBLIC_URL was read by slice-1 code but set
nowhere. The bug was masked in 99% of cases by the request-derived
fallback (${req.protocol}://${req.get('host')}) which produces the
correct URL when invites are triggered from browsers behind Cloudflare.
It would have manifested for any future non-browser-triggered invite
path.

README updated to note APP_URL covers both Stripe callbacks and
invite-accept URL generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:26:07 -05:00
ScreenTinker caa9fd0f40 feat(workspaces): mutation UI for members (slice 2B)
Completes P2 user-management. Adds the full admin surface for managing
workspace membership: invite modal, role change, member remove, cancel
pending invite. All admin-gated client-side via can_admin from /me,
server-gated via canAdminWorkspace.

Component additions:
- NEW workspace-members-invite-modal.js (~115 LOC). Mirrors
  workspace-rename-modal.js pattern (imperative open + listeners + close
  + esc/click-outside/enter). Two key differences: onSuccess callback
  instead of window.location.reload (allows targeted re-render of
  pending-invites section), and mapError callback so the parent's
  mapMutationError is the single regex-to-i18n source of truth (instead
  of duplicating in the modal).
- workspace-members.js: header invite button (can_admin gated), per-row
  affordances (role select + remove on direct members, cancel on invited
  rows, none on via_org rows), exported mapMutationError mapper,
  re-render on both success AND error for role-select to resync state
  when the server rejects.
- 4 api.js helpers (inviteWorkspaceMember, cancelWorkspaceInvite,
  updateWorkspaceMemberRole, removeWorkspaceMember).
- 24 i18n keys under members.modal.*, members.button.*,
  members.confirm.*, members.error.*, members.success.*
- CSS for .member-actions family (action buttons + role select + hover
  states).

UX decisions:
- Direct-member rows: role <select> replaces role text in same column;
  remove button right of detail
- via_org rows: no actions cell (server would 403; UI respects boundary)
- Invited rows: cancel button only (handoff rule was over-broad -
  cancel-invite IS a valid mutation on invited rows, refined during 2B
  survey)
- Role select fires on change, no Save button (matches teams.js pattern;
  mitigations for accidental clicks noted in handoff if reports come in)
- Mutations re-fetch + re-render rather than optimistic updates -
  simpler, no state-drift bugs, endpoints respond fast
- /invites endpoint skipped entirely when !can_admin (saves a request;
  server still enforces)

Verification: 21/21 Playwright assertions PASS across 6 cases (invite
happy path, invite collision, role change, remove member, last-admin
block, cancel invite). Test infrastructure stashed at
~/Documents/screentinker-2b-playwright-2026-05.py.

Closes P2 (user-management feature). Slice 1+3 backend landed c4fbd2b,
2A read-only view landed 8db171d, 2C accept-invite handler landed
399af54, 2B mutation UI landed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:45:34 -05:00
ScreenTinker 399af54839 feat(workspaces): accept-invite URL handler (slice 2C) + email URL path fix
Slice 2C: hash route #/accept-invite/{id} with full flow support across
all six auth entry points (login/register/Google/Microsoft/support/setup)
via app-boot consumer pattern rather than per-handler hooks. Stash
mechanism uses localStorage with timestamp + staleness check
(INVITE_EXPIRY_DAYS_FRONTEND = 7, mirrors backend default). On success:
switch workspace, reload, show toast post-reload via scoped
pending_invite_toast key. On error: showToast directly, no reload.
Non-reentrant guard prevents double-consume across the synthetic
hashchange that fires before reload completes.

Two bugs surfaced during Playwright-driven verification (slice 1 left
two latent issues that only manifested when the full accept-invite
flow ran end-to-end):

1. Email URL path: workspaces.js constructed
   ${publicBase}/#/accept-invite/X which lands on the marketing landing
   page (the SPA is at /app). Fixed to use
   ${publicBase}/app#/accept-invite/X. Any invite email sent before
   this fix would have produced an unfollowable link.

2. Synchronous hashchange race: location.hash = '#/' followed by
   reload() fires hashchange BEFORE the reload unloads the page. The
   intermediate route() call would consume the toast key against a DOM
   about to be destroyed, so the post-reload page had no toast. Fixed
   with history.replaceState which mutates hash without firing
   hashchange.

Files:
- server/routes/workspaces.js (+4/-1, /app path fix + comment)
- frontend/js/api.js (+3 LOC, acceptInvite helper)
- frontend/js/app.js (+154 LOC, accept-invite plumbing)
- frontend/js/i18n/en.js (+9 LOC, accept.* keys)

Browser verification: 11/11 assertions PASS via Playwright suite
covering all 5 D-cases (unauthed flow, authed direct, wrong account,
stale stash, already-member). Script stashed at
~/Documents/screentinker-2c-playwright-2026-05.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:50:23 -05:00
ScreenTinker 8db171d979 feat(workspaces): members page read-only view (slice 2A)
Adds the workspace members page at #/workspace/:id/members.
Read-only listing only - mutations land in slice 2B,
accept-invite URL handler lands in slice 2C.

Three sections render based on access path:
- Members: direct workspace_members rows with role + join date
- Organization access: org_owner/org_admin who reach this
  workspace via org-level access (via_org=true). 75% opacity
  + italic "via organization" label to distinguish from direct
  membership. Section hidden if empty.
- Pending invites: workspace_invites rows (admin-only -
  section silently absent for non-admins via 403-suppress)

Switcher dropdown adds a "members" icon next to the rename
pencil, gated on can_admin (same predicate). Icon visible on
hover, mirrors the existing pencil pattern.

24 i18n keys added under members.* (read-only set; mutation
keys land in 2B).

Backend coverage from c4fbd2b unchanged; pre-flight curl
verification (13/13 cases) confirmed all 7 endpoints work as
documented before slice 2 first-exercised the four previously
untested ones (GET /invites, DELETE /invites/:id, PUT
/members/:userId, DELETE /members/:userId).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:00:51 -05:00
ScreenTinker 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>
2026-05-16 12:19:59 -05:00
ScreenTinker d2a3bdfd15 fix(auth/me): broaden non-admin accessible_workspaces to include org_owner/org_admin paths
The non-admin branch of /me's accessible_workspaces query drove
from workspace_members, so users with org_owner or org_admin on
an organization but no direct workspace_members row were missing
those workspaces from their /me response - and therefore from the
switcher dropdown. Mirrors the access logic in
accessibleWorkspaceIds() (lib/tenancy.js) while keeping the
full-row SELECT shape /me needs.

Verified end-to-end with switcher-test@local.test acting as
org_owner of Acme Studios with no workspace_members row on
Studio B - Studio B now appears in /me's accessible_workspaces
with workspace_role: null, can_admin: true.

Also updates the stale TODO comment in tenancy.js that flagged
this exact gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:50:37 -05:00
ScreenTinker 3294525f4c fix(socket): prefer WebSocket transport for dashboard socket
Mirrors the player-side fix in 1aee4f2 - skips the polling->WS
upgrade dance that was causing the dashboard socket to flicker
when Apply burst its fetch traffic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:15:18 -05:00
ScreenTinker 2725ea9152 docs(privacy): disclose error and diagnostic telemetry from players
Companion to 19f434d. The new player_debug_logs sink collects four
data categories not previously enumerated in the privacy policy:
browser user-agent, error/stack-trace data, recent player log entries
(which can include filenames of content being played), and screen/
viewport dimensions. New section 2.5 documents what's collected, why,
and the rolling-buffer retention model (10k entries, oldest pruned
on insert).

Section 5 (Self-Hosted Deployments) clarifies that the telemetry is
collected by the self-hoster's own server, not transmitted to us, and
points at the PLAYER_DEBUG_REPORTING=off kill switch for self-hosters
who prefer no collection at all.

Section 11 retention list gains a row for the rolling-buffer model.

"Last updated" bumped to May 15, 2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:31:21 -05:00
ScreenTinker 19f434d05a Add player debug overlay and server-side error telemetry sink
Smart TVs (Tizen, WebOS, Fire TV, Bravia) have no accessible browser
devtools, so when the player misbehaves on those platforms we previously
had zero visibility. This adds two paths to fix that:

- Visible debug overlay rendered on the TV screen for phone-photo capture
- Automatic server-side telemetry sink for hands-off error reporting

Client side (server/player/):
- Inline ES5 error trap as first script in index.html captures errors
  even from parse-time failures in later scripts. Captures into
  window.__debugLog with 200-entry cap.
- debug-overlay.js renders a fixed-position overlay covering the top 40%
  of the screen. Activates via ?debug=1, d-e-b-u-g key sequence, Samsung
  red button (keyCode 403), or smart-TV UA + ?autodebug=1. Freeze toggle
  (F key or Samsung green) with visible FROZEN badge for phone capture.
  pointer-events: none so touches pass through to the player underneath.
- Reporter machinery posts captured errors to /api/player-debug with
  5-second debounce batching, sendBeacon on unload (with payload size
  capping to stay under 64KB), 5-minute backoff after 429 responses.
  UA-gated: smart-TV allow-list first (handles Tizen-with-Chrome/108),
  modern-desktop deny-list second, default-report for unknown UAs.
- Two-pass djb2 fingerprint (16 hex chars) per error for future grouping.
- Absolute script src (/player/debug-overlay.js) so the script loads
  regardless of trailing-slash on the player URL.

Server side:
- New player_debug_logs table (10000-row FIFO cap, indexed on
  fingerprint + created_at). Schema in schema.sql, idempotent via
  CREATE TABLE IF NOT EXISTS.
- POST /api/player-debug unauthenticated (so unpaired players can also
  report), rate-limited 10/min/IP, per-field length caps to prevent abuse.
- Dynamic /player HTML route injects window.__playerConfig.debugReporting
  based on PLAYER_DEBUG_REPORTING env var (defaults on; =off suppresses
  all client telemetry traffic). Other player assets still served static.
- Admin routes (requireAuth + requireSuperAdmin):
  GET /api/player-debug/list with pagination and filters
  GET /api/player-debug/summary for UA family counts
  DELETE /api/player-debug/older-than for manual purge

Admin view (#/admin/player-debug):
- UA family summary at top (Tizen/WebOS/Fire TV/Bravia/Edge/Chrome/etc)
- Filter row: UA contains, date range, has-error checkbox
- Paginated table with expand-row JSON viewer for error_data and context
- device_id labeled (self-reported) since field is unauthenticated input
- Manual delete-older-than button with confirmation dialog

Verified end-to-end with Playwright + Chromium (17/17 checks pass) plus
manual real-browser verification including UA-spoofed Tizen flow landing
rows in the admin view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:20:42 -05:00
ScreenTinker 12fe0e43eb fix(zones): frontend assignment-flow picker + missed devices.js zone_id projection
Follow-up to 73f41c3 (server-side zone_id wiring). With this commit
the zone feature is verified working end-to-end: dashboard zone
picker renders correctly, zone_id saves and persists, the per-row
zone dropdown reflects the saved zone after reload, and a live
player run with computed-style inspection confirmed zone divs and
video elements size correctly within their geometry.

Frontend (device-detail.js, en.js):
- Add-content modal: zone picker slot now renders in all four states
  (has_zones / no_layout / fetch_failed / empty_layout) instead of
  silently vanishing when zones.length === 0. Informational rows
  match form-group styling and tell the user which control to use
  next. Closes the gate-4 symptom where 38-of-42 devices (no layout
  assigned) silently dropped zone_id on every assignment.
- Both /api/layouts/:id fetches (add modal, edit-path) now have
  !res.ok throw guards and surface failures via console.warn instead
  of swallowing them. The add modal additionally exposes the failure
  state to the user via the fetch_failed info row.
- Edit-path zone dropdown: replaced brittle DOM-scraping (reading
  the i18n label text and matching z.id.slice(0,8) against rendered
  meta HTML) with a data-current-zone-id attribute stashed at row
  render from a.zone_id. Removes the i18n-format coupling and gives
  exact UUID match.
- 3 new i18n keys in en.js (other locales fall back).

Server (devices.js):
- The GET /api/devices/:id assignments query had its own ad-hoc
  SELECT projection that was missed during the 73f41c3 site survey.
  Without pi.zone_id in this projection, loadDevice() got assignments
  without zone_id and the edit-path dropdown displayed "No zone"
  after every save+reload even though the DB had the correct value.
  One-line fix: add pi.zone_id, mirroring the ITEM_SELECT change in
  routes/assignments.js. Listed as the 8th site that 73f41c3's
  original survey missed; this commit closes it.

Verification:
- JS parse + en.js ESM load + server module load all clean.
- Live SQL probe: GET /api/devices/:id projection now returns zone_id
  for the test rows (id=31 zone_id=z-sh-1, id=54 zone_id=z-sh-2).
- Browser test by hand: zone picker renders per state, zone_id
  persists, reload shows saved zone, computed styles on rendered
  .zone divs match expected geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:26:58 -05:00
ScreenTinker 73f41c3288 fix(zone-id): restore zone-aware playlist_items wiring (issue #3 follow-up)
Phase 2 (assignments -> playlist_items) dropped zone_id during the
conversion: migrateAssignmentsToPlaylists INSERTed only (playlist_id,
content_id, widget_id, sort_order, duration_sec), and the new
playlist_items DDL omitted the zone_id column entirely. Every write
path on top of playlist_items inherited that omission - the
multi-zone layout assignment feature stopped working.

Frontend always sent zone_id correctly (device-detail.js:1015,1072
POST and PUT both include it; api.addAssignment and api.updateAssignment
forward the body verbatim). Server silently dropped it. The
assignments.js PUT route was the most direct evidence: it destructured
zone_id from req.body but never added it to the updates array.

Schema:
- schema.sql: add zone_id TEXT REFERENCES layout_zones(id) ON DELETE
  SET NULL to fresh-install DDL.
- database.js migrations[]: add idempotent ALTER TABLE for existing
  installs (the surrounding try/catch loop handles duplicate-column).

Backfill (new gated migration phase2_zone_id_backfill):
- Pre-migration snapshot copied to db/remote_display.pre-zone-id-
  backfill-<ts>.db (one-off for this migration; the general
  every-migration-snapshot framework is a separate concern, not built
  here).
- Best-effort UPDATE playlist_items.zone_id from surviving
  assignments rows via device.playlist_id + content_id/widget_id
  match, LIMIT 1 for the multi-match edge case.
- Regenerates published_snapshot for every published playlist so the
  JSON the player consumes carries zone_id going forward. Even with
  zero rows backfilled (the common case post-Phase-2 cleanup) this
  closes the snapshot-staleness gap.
- Stamps schema_migrations regardless so it won't re-run on next boot.
- On the live local DB: 0 playlist_items backfilled, 18
  published_snapshots regenerated. On the April 13 prod fixture
  (sandboxed copy): 0 backfilled, 7 regenerated. Expected and matches
  our pre-flight finding that assignments was effectively scrubbed of
  zone_id everywhere.

Route wiring (7 sites + 1 shared constant):
- assignments.js ITEM_SELECT: project pi.zone_id (read path so the
  frontend display at device-detail.js:500 surfaces the value).
- assignments.js POST INSERT: include zone_id column + value.
- assignments.js PUT: actually use the already-destructured zone_id
  in the updates allow-list. Treats undefined as "no change" so a PUT
  that omits zone_id leaves the existing value intact; any explicit
  value (including null) is written.
- assignments.js copy-to INSERT: preserve a.zone_id during
  device-to-device playlist copy.
- playlists.js buildSnapshotItems: project pi.zone_id so the snapshot
  JSON carries it. This is what the player's renderZones loop reads
  (player/index.html:1338 matches a.zone_id === zone.id).
- playlists.js discard-revert INSERT: restore zone_id from snapshot
  item on revert.

Out of scope (verified safe by SQL semantics + UI inspection):
- playlists.js POST item-add and PUT item-update in the playlist-detail
  surface: the UI there doesn't expose zone editing, and their SQL
  leaves zone_id NULL on insert / untouched on update. No regression.
- Other playlists.js SELECT projections (lines 141, 190, 240, 265, 334,
  379, 419) all use SELECT pi.* and auto-pick zone_id once the column
  exists.
- Kiosk-page assign at device-detail.js:1027 doesn't send zone_id;
  separate pre-existing gap, not part of this regression.

Tests (all local, no push, no prod deploy):
- Migration boot on live local DB: clean, idempotent (second boot
  skips the gated function).
- Migration boot on April 13 prod fixture (sandboxed copy at
  /tmp/zone-fix-fixtures/test-run.db): cleanly runs the full migration
  stack (multi-tenancy + 5 other phases the fixture predated) then
  the new zone_id backfill. Live local DB untouched.
- 8 SQL-level route behavior tests pass: INSERT stores zone_id, PUT
  changes/clears zone_id, ITEM_SELECT and buildSnapshotItems
  projections include zone_id, copy-to preserves, discard-revert
  restores from snapshot JSON, undefined zone_id in PUT leaves
  existing value intact.

Not verified: end-to-end multi-zone playback on a real device. The
SQL + snapshot JSON layer is correct (player consumes
playlist.find(a => a.zone_id === zone.id) and now gets the right
zone_id back from the snapshot); confirming render-to-correct-zone
on actual hardware is the next step before prod deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:20:44 -05:00
ScreenTinker cdd29d5e3b Merge pull request #6 from ChrisChrome: web player auto-connect
Adds a 5-second countdown on the player's Connect button when the
device is unpaired. Auto-clicks at 0 unless the user interacts with
the serverUrl field. Useful for headless kiosks where clicking is
painful. Includes a follow-up race-condition fix so the timer can't
double-fire if Connect is clicked manually during the countdown.
2026-05-14 16:12:58 -05:00
Christopher Cookman f6ef75549b Fix possible race condition in player auto-connect 2026-05-14 14:54:17 -06:00
Christopher Cookman 98e742c612
Merge branch 'screentinker:main' into main 2026-05-14 13:46:40 -06:00
Christopher Cookman d5e4e4d927 Feat: Web player auto connect
Add a simple 5 second countdown to the web player to get a code without interacting (for systems where interaction is a hassle, or impossible)
2026-05-14 13:46:19 -06:00
ScreenTinker 8439f2bf18 fix(landing): replace broken Custom pricing card with enterprise contact form
The "Custom" tier on the public pricing page was misrendering as a
better-than-Free tier: headline "Custom", price "Free", "Unlimited
devices/storage", "Get Started" button. Root cause is in DB data,
not markup - the 'enterprise' plan row has price_monthly=0 and
max_devices/storage=-1, and the dynamic render in landing.html maps
those to "Free" + "Unlimited" with the wrong CTA.

Fix: filter the 'enterprise' plan out of the public landing render
(client-side, in landing.html only) and replace it with a hardcoded
Enterprise / Custom marketing card whose Contact Us button opens a
new lead-capture modal.

The DB row itself stays - it is actively used elsewhere:
- auth.js: first user in SELF_HOSTED=true mode is assigned to it
- settings.js: white-label feature is gated on enterprise plan
- 1 user (the dev account) is currently assigned to it
- /api/subscription/plans is also consumed by billing.js, settings.js,
  admin.js (logged-in surfaces); they keep getting the full plan list.
The filter is scoped to landing.html's render only.

The in-app billing page renders the same plan with the same cosmetic
bug; that's a logged-in admin surface, out of scope for this commit.

Other 4 cards (Free, Starter, Pro, Business) unchanged.

Frontend (landing.html):
- Filter 'enterprise' from public render
- Hardcoded Enterprise / Custom card. Uses .price class with "Let's
  talk" + empty .yearly spacer to match Free card's vertical baseline
  so the feature list aligns with the paid cards' baselines.
- Modal markup, CSS (mirrored from frontend/css/main.css conventions
  since landing.html doesn't import main.css), and inline JS for
  open/close/submit/escape/background-click.
- Honeypot field: hidden 'fax_number' input (off-screen + aria-hidden
  + tabindex=-1). Picked over the obvious 'website' name to catch
  mid-tier bots that explicitly skip the well-known honeypot names.

Backend (new server/routes/contact.js):
- POST /api/contact/enterprise, public (unauthenticated)
- Rate limited 5/min/IP+path via the existing rateLimit middleware
- Honeypot check: populated fax_number returns 200 silently, no email
- Server-side validation: required fields, email format, screens
  1-100000, multi_tenant in {single,multi}, hosting in {hosted,self,
  unsure}. Length caps prevent textarea-bomb abuse.
- Sends via existing services/email.js (Microsoft Graph) to
  dan@bytetinker.net from the support@screentinker.com Graph sender.
- Log lines: "[contact] enterprise inquiry from EMAIL (COMPANY)
  delivered" or "[contact] honeypot triggered from IP; dropping".

Wired in server.js alongside other public routes (before requireAuth).

Build-time tests passed locally:
- Module loads, server boots clean
- Validation: missing fields, bad email, bad multi_tenant, bad
  hosting, screens out of range - all return 400 with the right
  error message
- Honeypot: populated fax_number returns 200 success, no email sent,
  log line confirms drop
- Rate limit: kicks in at 6th request within a minute as expected
- Real end-to-end send: one test submission delivered to
  dan@bytetinker.net via Graph (subject "[ScreenTinker] Enterprise
  inquiry: ScreenTinker Build Verification", body formatted with all
  fields). GRAPH_DEV_RESTRICT_TO was temporarily widened to include
  the recipient for the test and restored to dw5304@gmail.com
  immediately after.
- Card render order verified against live API: Free (outline,
  Get Started) | Starter | Pro (featured, Most Popular badge) |
  Business | Enterprise / Custom (Contact Us -> modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:52:24 -05:00
ScreenTinker f5ca26ae2d fix(socket): offline debounce + truthful single-device command feedback
Two dashboard-accuracy improvements for issue #3.

Disconnect debounce (5s):
- Brief transient flaps (Engine.IO ping miss, eviction-then-reconnect,
  Wi-Fi blip) no longer immediately flip the device to offline in the
  dashboard. Disconnect handler now defers the offline transition;
  register handlers cancel the pending timer if reconnect lands in
  window.
- Existing stale-disconnect guard kept as fast-path for the eviction
  case (no timer scheduled at all when the active heartbeat conn is
  already a different socket).
- Re-check at timer fire compares socketIds: aborts only if a
  GENUINELY DIFFERENT socket reclaimed the device. Just the closing
  socket's own (not-yet-cleaned-up) entry is treated as stale and
  proceeds with offline transition.
- Server-restart mid-grace is handled by the heartbeat checker safety
  net (existing component): any 'online' row with last_heartbeat
  older than heartbeatTimeout gets marked offline on next sweep.

Truthful single-device command feedback:
- dashboard:device-command handler now checks deviceNs.adapter.rooms
  for an active socket before emitting (matches the group-command
  route's pattern).
- If room is empty, falls through to commandQueue.queueCommand (lazy
  require - if commit C is reverted, MODULE_NOT_FOUND is cached and
  every subsequent call gets consistent queued=false behavior).
- Returns three-state ack to caller: { delivered, queued, reason }.
- Server log line was misleading - now logs 'Command delivered to
  device X' vs 'Command for offline device X (queued=true/false)'.

Frontend:
- sendCommand() takes optional callback. Without one, fires-and-forgets
  (no behavior change for non-wired callers). With one, uses Socket.IO
  .timeout(5000).emit so the callback always fires (ack or no_ack).
- Six device-detail command buttons wired to three-state toasts:
  reboot, shutdown, screen_off, screen_on, launch, update.
  - delivered: green/success toast (existing localized message)
  - queued: amber/warning toast (new generic message)
  - no_ack: red/error toast
  - fallback: red/error toast
- Two callers intentionally left fire-and-forget:
  - window._sendCmd (generic remote-overlay keypress/touch helper)
  - enable_system_capture (has its own visual state machine; out of
    scope for this commit)

Three new i18n keys (en.js only; other locales follow later):
- device.toast.command_queued
- device.toast.command_undeliverable
- device.toast.command_no_ack

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:40 -05:00
ScreenTinker 742d8c4b09 feat(socket): delivery queue for offline-device emits
Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.

server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
  flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
  dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries

Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.

Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js   (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js       (content-delete fan-out)
- server/routes/playlists.js     (pushToDevices + assign)
- server/services/scheduler.js   (scheduled rotations)
- server/ws/deviceSocket.js x2   (wall leader reclaim/reassign)

server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:06:43 -05:00
ScreenTinker 3da49ec79c chore(config): env-configurable heartbeat timing
Make HEARTBEAT_INTERVAL and HEARTBEAT_TIMEOUT env-tunable so
self-hosters with slow/jittery networks don't have to edit
config.js (issue #3 reporter did exactly this to confirm the
diagnosis). Defaults unchanged at 10000ms / 45000ms so existing
deployments keep current behavior.

Same parseInt(env) || default pattern as PORT/HTTPS_PORT/PING_*.
README env table extended.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:03:02 -05:00
ScreenTinker 1aee4f2d5b fix(socket): raise Engine.IO ping/pong + prefer WebSocket transport
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.

- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
  dead-socket detection 60s, up from ~45s). Both env-configurable
  via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
  Tries WebSocket first; falls back to polling on the same connect
  attempt if WebSocket fails. Polling fallback preserved for
  firewall-restricted networks.

App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.

Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:34 -05:00
ScreenTinker c4ac81c7a6 chore(discord): update Discord invite link
Old invite replaced with current permanent invite across README,
landing page, and anywhere else it appeared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:26:20 -05:00