Commit graph

110 commits

Author SHA1 Message Date
ScreenTinker 5d24c30ea1 feat(displays): drag-to-reorder display tiles within a section (#106)
Option A: tile-on-tile (same section) reorders; tile-on-section / cross-
section stays group-assign (existing behavior untouched). Ordering is
cosmetic (dashboard only — nothing the device/player reads).

Backend:
- Migration: devices.sort_order column (idempotent ALTER; default 0).
- GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at).
- POST /api/devices/reorder — ordered id array -> transactional
  UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace
  (forged cross-workspace ids are no-ops). Write-gated (viewer read-only).
  Mirrors the playlist items reorder.

Frontend (the collision):
- Card-level dragover/drop: reorder ONLY when target is another card in the
  SAME section; otherwise no-op so the event bubbles to the section's
  group-assign handler. stopPropagation on the same-section drop prevents
  the section handler also firing. Drop indicator (inset box-shadow).
  Native HTML5 DnD; no library.

Validated (headless Chrome, synthetic DnD + a section-level drop spy):
- SAME-section reorder: section drop suppressed (sectionDrops=0), POST
  /devices/reorder fires, NO group call, sort_order persists in DB.
- CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/
  devices fires and membership actually changes — group-assign unbroken.
- The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture.
- 149 server tests green; migration applies clean on the prod-copy DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:15:21 -05:00
ScreenTinker 57d78dd1fa feat: full-screen-only guardrail for agency designations (#73)
Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen
agency upload can't safely target a zone, so the ambiguous case is excluded rather than
solved. Checked at THREE points:
- Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target.
- Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation.
  MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an
  auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload
  into a zoned playlist. The upload check is the only thing that catches it.
- Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now
  returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5.

isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no
zone structure, no api_token_target_zones.

Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an
auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not
auto-published; neutralizing the upload check makes it go red. 149 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:36:30 -05:00
ScreenTinker 400a438fff revert: drop zone-binding, keep whole-playlist grants + size-guidance card (#73)
Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist
property: a normal playlist has no derivable layout (zone_id is NULL unless set in the
device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The
right model: placement belongs to the device (same playlist can be full-screen on one screen,
a zone on another); the agency just gets whole-playlist grants + size-guidance.

Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant
convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the
short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the
create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings
zone-picker + its i18n, and the zone-grant bite-test.

KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET
/api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the
playlist actually feeds (where/what-size content lands), or full-screen when it has no layout.
Whole-playlist grants = today's working model. 147 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:52:11 -05:00
ScreenTinker c5550f5bc9 feat: agency zone-grant issuance UI + reactive placement card (#73)
Issuance (on the proven seam):
- tokens.js create + PUT /:id/targets accept per-playlist zone grants (target_zones), inserted
  into api_token_target_zones inside the same transaction as the playlist grants (FK requires
  the parent, so order matters and is correct).
- Issuance validation (the mirror of runtime confinement): grantableZoneIds() - can grant ONLY
  a zone the playlist's layout actually feeds; can't grant one it doesn't have or one from
  another playlist's layout. Bite-tested. PUT re-designate stays atomic: delete parent rows ->
  zone grants cascade out (no manual child delete).
- settings.js: checking a designated playlist reveals its grantable zones (GET
  /api/playlists/:id/zones, JWT); leave unchecked = whole-playlist. i18n across all 5 locales.

Card:
- GET /api/agency/playlists/:playlistId/layout (rides router.param - confined; a non-
  designated playlist -> 403, asserted). "Your zone" = the GRANTED zones. Retired the
  token-wide /layouts (the per-playlist card replaces the disconnected lump).
- Portal card reacts to the playlist selector: pick a playlist -> its layout renders, the
  granted zone highlighted with px size, siblings as context.

Full suite + agency bite-suite green (154).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:12:55 -05:00
ScreenTinker 289d54f4fa feat(api): zone-grant confinement for agency tokens - FK-anchored (#73)
Placement-as-grant, replacing the inferred auto-place idea. api_token_target_zones is an
ADDITIVE second table (does NOT touch the proven api_token_targets), structurally anchored:
a composite FK to api_token_targets(token_id, playlist_id) makes a zone grant orphan-
impossible and cascade away when the playlist grant is revoked - "narrow" is structural, not
conventional. zone_id FK -> layout_zones cascades on zone/layout delete.

Confinement (lib/agency-targets.resolveGrantedZone, called in the item-add): grants exist ->
the item MUST land in a granted zone (a body zone_id picks among grants, never escapes them);
none -> whole-playlist/full-screen as before. The item-add stamps the granted zone_id.

Bite-tested (6, all proven incl. neutralize->red on the confinement): granted YES; non-
granted/cross-playlist/ambiguous blocked; orphan-grant rejected by the FK; cascade on
playlist-grant revoke, on playlist delete, on zone/layout delete; and foreign_keys=ON
asserted (a cascade that no-ops because FKs are off is the trap). 153 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:57:27 -05:00
ScreenTinker c55ca60b56 feat(api): batched email digest for agency uploads (#73)
Reuses the existing scheduler + sendEmail infra (no new scheduler). The agency endpoint
enqueues one agency_notifications row per item added; a 15-min flush groups unsent rows per
token+playlist+action and sends ONE digest per group to the workspace owner/admins + the
playlist owner (deduped via UNION). Draft -> "added N items, awaiting approval"; published ->
"updated <playlist>".

Two robustness rules, both tested:
- Queue never balloons when SMTP is off: the endpoint skips enqueue when !isConfigured(),
  and the flush drains-and-discards unsent rows as a backstop.
- sent_at is stamped ONLY after a successful send, so a failed send retries next cycle
  instead of silently dropping.

Wired into boot via startAgencyDigest(). 147 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:59:37 -05:00
ScreenTinker 986d94a778 feat(api): GET /api/agency/layouts - device-free layout geometry (#73)
So the agency can size/place content: returns the canvas size + zone positions/sizes for the
layouts its designated playlists feed, marking which zones are theirs. DEVICE-FREE BY
CONSTRUCTION - the query path is playlist_items.zone_id -> layout_zones -> layouts and never
touches devices/groups/schedules, so device names/locations/IPs/topology are structurally
absent, not filtered. Geometry only - no sibling-zone content. layout.name included (admin's
canvas name); thumbnail_data omitted (could render other zones' content).

Confinement query in lib/agency-layouts.js, bite-tested: own layout YES, a non-designated
playlist's layout NO, response has NO device fields (asserted on a db where a location-named
device exists), and neutralizing the t.token_id filter goes red. 142 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:53:30 -05:00
ScreenTinker 1f207c4278 feat(api): per-agency-token auto-publish (#73)
api_tokens.auto_publish (DEFAULT 0 = draft, the fail-safe). Admin sets it at token creation
in the designate UI (checkbox, agency scope only). The agency endpoint reads it from the
TOKEN ROW via req.apiToken (apiTokenAuth attaches it) - NEVER from req.body, so an agency
can't opt itself out of approval. 0 -> markDraft; 1 -> the shared publishPlaylist path.

Tests (integration): draft is the default; a draft token with auto_publish:true IN THE BODY
still lands draft (body ignored); an auto-publish token goes live; manual publish still works
(extraction regression). i18n across all 5 locales. 141 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:17 -05:00
ScreenTinker 79c453cd43 refactor(playlists): extract publishPlaylist() shared fn (#73)
POST /:id/publish snapshots items into published_snapshot (what devices consume) + pushes
to devices. Extracted that into publishPlaylist(id, req) so the agency auto-publish path can
call the IDENTICAL logic - a "published" playlist that wasn't snapshotted would be live on no
screen. The manual endpoint now calls it; behavior-preserving (suite green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:17 -05:00
ScreenTinker d59adfd10c feat(ui): agency token designation in Settings (#73)
Admin-facing. Extends the existing API-token UI: an 'agency' scope option reveals a
playlist picker (the workspace's playlists); creating the token binds the checked ones as
its allowlist (target_playlist_ids). The token list shows each agency token's designated
playlists (tokens GET now returns targets for agency-scoped tokens). i18n keys added across
all five locales (parity test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker 6d152a5ccf feat(api): GET /api/agency/playlists - a token's designated targets (#73)
The portal needs to show an agency which playlists it may post to. New read surface on the
security primitive, built with write-path rigor: the confinement query lives in
lib/agency-targets.js (own token + bound workspace only) and is bite-tested four ways -
own targets yes; another token's, outside the allowlist, and cross-workspace all NO;
neutralizing the t.token_id filter makes it go red. Real-path wiring + the portal's
graceful 401 trigger asserted in the integration suite. No :playlistId, so router.param
doesn't apply - the query is the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker 40102b2b41 feat(api): agency portal endpoints + router.param target seam (#73)
The agency capability behind the proven off-ladder/agencyGate primitive:
- agencyGate is now SCOPE-only at the mount; the per-target check is router.param
  ('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no
  :playlistId route can skip it (drift-proof). A mount-level target check was silently
  bypassed (Express populates req.params only at route match); the integration bite-suite
  caught it - this is the fix.
- routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded
  #74/#75 item; lands as draft so the admin's re-publish is the approval gate).
- tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist
  allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen).
- server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate.

Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path:
neutralizing the router.param check makes non-designated->403 go red. Four assertions at
three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens
JWT-only), issuance cross-workspace (create validation). 139 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00
ScreenTinker a59b53cc25 refactor(content): extract the upload ingest into a shared lib (#73)
routes/content.js POST / processing (thumbnail/dimensions/duration) + insert moved to
lib/content-ingest.js so the agency router produces byte-identical first-class content.
content.js POST / is now a thin caller; behavior-preserving - the 52 content regression
tests (api/operator-permissions/config-paths) pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00
ScreenTinker f4c5865013 fix(server): strip totp_secret_enc/totp_last_step from login responses (#100)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Review caught the encrypted TOTP secret riding in the login/verify response body:
issueSession receives a SELECT * user row and only destructured out password_hash, so
totp_secret_enc (and the internal replay counter totp_last_step) leaked. Encrypted, so
not catastrophic, but it regresses the API work's "secrets never in responses" rule.
Strip both in issueSession (covers /login and /totp/verify); add an assertion that a
verify response carries no totp_secret_enc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 1d3e9acea4 feat(server): TOTP MFA login flow + enrollment/verify endpoints (#100)
Two-token login: /login returns an mfa_pending token when TOTP is on; requireAuth/optionalAuth
REJECT mfa_pending (tightening #1 - else password-alone is a session). /totp/verify exchanges
it + a TOTP or recovery code for a full session (per-user lockout; recovery checked
independently of the decryptable secret). Enrollment: setup -> enable (confirm-then-enable) ->
recovery codes shown once; disable/regenerate require re-auth; regenerate replaces atomically;
status surfaces codes-remaining (tightening #3). API tokens + SSO bypass TOTP by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 3305e79e61 fix(api): consolidate device pairing to /pair, remove vestigial bare endpoint (#90)
POST /api/provision was a second pairing endpoint that paired a device by code but,
unlike POST /api/provision/pair, did NOT assign a workspace, enforce checkDeviceLimit, or
emit device:paired / dashboard:device-added - a silently-diverging duplicate that no
client ever called. It now returns 410 Gone and points callers at /pair, so
/api/provision/pair is the single, fully-protected pairing endpoint. The mount stays in
the JWT-only partition, so a Bearer st_ token still gets 401 (requireAuth) before the 410.

Closes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:13:16 -05:00
ScreenTinker dce0d22763 fix(api): expose zone_id + layout_id on the public write paths
- playlists: accept zone_id on item create + update, validated against a template or a
  layout in the playlist's workspace (no cross-tenant zone reference).
- devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route),
  validated the same way; null clears it. Both are already returned in the GET SELECTs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker fab4ae909a feat(api): token management endpoints + Settings UI
- routes/tokens.js: create (returns the full secret once), list (never the secret),
  revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
  revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
  secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 73ca3cf258 feat(api): scoped API token foundation + secure-by-exclusion mounts
Introduce the public API's token layer and make the router partition data-driven.

- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
  unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
  to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
  tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
  JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
  mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.

A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 2ccf3264a9 feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.

- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
  DST US+AU, overnight-wrap anchoring, timezone correctness, date
  boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
  Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
  test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
  own clock, idle with a "Nothing scheduled" message + 30s re-check when
  everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
  client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
  in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
  to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
  guarantee, and the scheduled-single-video re-render tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00
ScreenTinker 52b10408be chore(version): single-source VERSION, env-configurable data paths, bump tooling
- server/version.js: shared version helper that reads the root VERSION file once
  (fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
  in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
  db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
  legacy in-repo paths, so existing installs (including production) are
  byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
  android (versionName and versionCode + 1), and the tizen widget version; makes
  one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
  (including .db.devbak backups, at any path) can be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:56:03 -05:00
ScreenTinker dc6424a3cc feat(ai): separate optional image API key (#41)
Image generation reused the single (text-endpoint) API key, which breaks the
common 'local LLM with no key + OpenAI for images' setup. Add an optional
image_api_key (encrypted, write-only, never returned); generate-design uses it
for image calls and falls back to the main key when blank (all-OpenAI setups).
Local sd.cpp / ComfyUI still need no key. Schema column + migration.
2026-06-09 13:47:47 -05:00
ScreenTinker 303c83e86a feat(ai): generate background + foreground images for signs (#41 Phase 2)
A prompt now produces a full sign: the LLM writes the design AND image prompts,
the server generates the images and composites them with the crisp text layer.

- lib/image-gen.js: text-to-image with 3 BYO/self-hostable backends, all behind
  the SSRF guard: 'sdcpp' (local stable-diffusion.cpp OpenAI-compatible server,
  exact small sizes that fit VRAM), 'openai' (cloud / OpenAI-compatible, snapped
  sizes), 'comfyui' (prompt/history/view API).
- ai.js: prompt asks for a background_prompt (preferred — full-bleed atmosphere)
  and an optional foreground image element; after the design is normalized, the
  bg + fg images are generated best-effort (a failed image never fails the sign)
  and returned as data URLs. New image_* settings (provider/base_url/model),
  image_provider whitelist, schema column + migration.
- designer.js: AI-images section in settings; generate applies the background
  image; publish bakes the background image into the HTML so it survives.
- server.js: raise JSON body limit to 12mb for embedded image data URLs.

Verified end-to-end on local Vulkan SDXL (RTX 5090): prompt -> bg+fg images on
the canvas -> publish creates a widget with the images embedded. 63/63.

Note: prod (not self-hosted) requires a PUBLIC image endpoint (e.g. OpenAI); the
SSRF guard blocks localhost there. Follow-up: upload generated images to the
content store and reference by URL to avoid multi-MB widget configs.
2026-06-09 13:40:14 -05:00
ScreenTinker 734795f20b fix(ai): de-overlap generated text + layer shapes behind text (#41)
Models sometimes stacked text lines at the same y (unreadable) and emitted accent
shapes after text, so a band could hide the words.

- deoverlapTexts: push a line down only when it also overlaps horizontally
  (leaves side-by-side text alone), with conservative line-height clearance so
  real rendering doesn't re-overlap; shift the stack up if it ran past the bottom.
- Order shapes before text in the output so accent bands always render behind the
  words.

Verified: 0 text-on-text overlaps across multiple prompts (Playwright DOM check);
unit test asserts overlapping lines get separated + shapes precede text. 63/63.
2026-06-09 12:57:41 -05:00
ScreenTinker 4cc8ccb67e fix(ai): keep generated designs inside the canvas (#41)
Text could run off the edge (long/large headlines, nowrap) and shapes placed at
the far edge (e.g. a bottom band at y=100) spilled over.

- Server-side fit pass on every generated element: shrink text fontSize so it
  fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase
  headlines) and height (incl. line-height), then nudge x/y within 4% margins;
  clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on
  the model getting layout right.
- Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to
  the canvas, not the browser window. The preview was overstating size vs what
  actually publishes; now it matches. Published widget keeps vw (scales on the
  player).

Verified: Playwright DOM check shows zero elements overflowing the canvas after
generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62.
2026-06-09 12:51:23 -05:00
ScreenTinker 1420a0d2b7 feat(ai): model dropdown + longer generate timeout (#41)
- POST /api/ai/models lists the configured endpoint's models (OpenAI-compatible
  /models) so the settings modal can populate a 'Load models' dropdown instead of
  requiring users to type the model name. Combobox (datalist) so they can still
  type a custom one. Admin only; same SSRF guard; uses the posted or saved key.
- Bump generate-design timeout 120s -> 180s for slow local endpoints.
2026-06-09 12:36:29 -05:00
ScreenTinker 0ba36949cf feat(ai): AI content design in the Designer, BYO endpoint (#41 Phase 1)
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it
in a way that's actually BETTER for signage and costs the operator nothing.

Key idea: don't generate raw images (AI garbles text - fatal for menus/promos).
The LLM returns a STRUCTURED design spec (headline, supporting text, accent
shapes, palette) that the existing Designer renders with real fonts - crisp and
fully editable. Reuses the whole Designer.

BYOK, fully under the customer's control: each workspace configures its own
OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio
/ llama.cpp). Operator bears zero AI cost/liability.
- server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned).
- routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST
  /generate-design (editor+). Output is strictly validated/normalized (cap count,
  clamp ranges, px->%, strip HTML, validate colors) - never trust the model.
  SSRF guard: hosted instances block private/internal targets; self-hosted (the
  whole point of local AI) may point at localhost/LAN.
- Designer: an 'AI generate' panel (prompt + Generate) + a settings modal.

Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design
on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61.

Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
2026-06-09 12:23:55 -05:00
ScreenTinker cb21b8e34a fix(layouts): atomic zone save (stop template zone duplication)
Saving a layout grew its zone count on every server restart. Root cause: the
editor saved zones with a per-zone delete-then-POST loop, and POST /zones minted
a NEW uuid for every zone - so each save replaced the seeded ids (z-sh-1, ...)
with fresh uuids. schema.sql re-seeds template zones via INSERT OR IGNORE on every
boot, so the next restart re-added the now-missing canonical zone alongside the
renamed copy -> a 2-zone template became 4, 6, ... (worse for self-hosters who
rebuild often).

Fix:
- PUT /api/layouts/:id now accepts a zones[] and replaces them atomically in one
  transaction, REUSING each zone's id when supplied. The editor sends the full
  set in a single call, so the layout ends up with exactly those zones and ids
  stay stable (also fixes fit_mode not persisting, and stops device->zone
  assignments being orphaned by id churn).
- One-time dedupe migration removes positional-duplicate template zones, keeping
  the canonical 'z-...' seeded id so the re-seed stays an idempotent no-op.

Verified: 2 atomic saves keep count + ids stable with fit updated; dedupe restores
a polluted 4-zone split template to its 2 canonical zones. Suite 56/56.
2026-06-09 10:16:01 -05:00
ScreenTinker 0d14db97a6 feat(admin): Delete Organization + Workspace with cascade (#36)
Platform admins can now cleanly remove a customer org (account ends) or a stray
workspace from the UI, instead of raw SQL that risks orphaning resources.

The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices,
content, playlists, ...) are NO ACTION and must be purged before the workspace.
Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade /
deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses
the purgeWorkspaces extraction).

Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts +
workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id.
UI: an Organizations section in Admin listing every org/workspace with a
type-the-name confirmation before the irreversible delete.
Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53.
2026-06-09 09:22:21 -05:00
ScreenTinker ae595a208d feat(admin): Create Organization for platform admins (#35)
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.

POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.

UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
2026-06-09 09:10:15 -05:00
ScreenTinker 8fd971405e feat(layouts): per-zone fit mode + default to 'contain'
Multi-zone videos/images were cropped: every template zone inherited fit_mode
'cover' (fill+crop) and the layout editor had no control to change it, so a
landscape video in a tall split zone showed only a center strip. The player
already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI
and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout
editor, and make 'contain' (show the whole frame) the default for new zones, the
schema column, and the save fallbacks. Existing built-in templates are migrated
separately.
2026-06-09 08:55:15 -05:00
ScreenTinker 8e7d599170 fix(widgets): no-store on widget/kiosk render
The render had no Cache-Control. A copy cached before the X-Frame-Options fix keeps
showing blank, and widget data (clock/weather/rss/directory) is dynamic anyway, so
mark the render no-store. Pairs with the X-Frame-Options removal.
2026-06-08 23:46:42 -05:00
ScreenTinker 827b1c4c87 fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
The web player embeds widget/kiosk renders in a sandboxed (allow-scripts, no
allow-same-origin) iframe = a null origin. The global helmet X-Frame-Options:
SAMEORIGIN refuses that (null != same-origin), so every widget rendered blank in
the web player (video worked since it isn't an iframe). Drop X-Frame-Options on
just the /render endpoints - the sandbox, not X-Frame-Options, is what isolates
the widget from the dashboard (it still can't read the JWT). Dashboard keeps its
clickjacking protection. Verified: directory board now renders in a sandboxed
iframe with no refusal.
2026-06-08 23:36:53 -05:00
ScreenTinker 401c4b00b5 fix(security): sanitize public widget render to close stored XSS
The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined
config values straight into <style>/CSS and (for the text widget) raw into the
same-origin document. A workspace editor could store `}</style><script>...` in a
color/background/size field (bypassing the UI pickers via the API) → stored XSS
executing in the app origin for anyone who opens the render URL (JWT theft).

- safeCss(): allow colors/gradients but reject CSS breakout / url() / @import /
  expression / javascript:. Applied to background/color across clock, weather,
  rss, social renders.
- safeNumber(): coerce font_size / scroll_speed / max_items to a finite number
  so they can't smuggle markup.
- Text widget keeps its intentional raw HTML/CSS feature, but it now renders
  inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run
  in a null origin that can't reach the dashboard's localStorage/JWT.

Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text
isolated, legit colors/gradients preserved). Full suite green.
2026-06-08 19:11:14 -05:00
ScreenTinker ba3e2cc785 fix(security): patch quick-win findings from the codebase review
Five low-risk, high-value fixes surfaced by the security review:

#3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC,
   pre-auth branding resolver and the login-page <style>) are now settable only
   by platform admins; a workspace_admin can no longer hijack the platform login
   page by claiming its domain. The public /api/branding (+ /domain) now return
   only presentational fields via publicBranding() (no id/user_id/workspace_id/
   custom_domain/timestamps leak).

#6 Strip device_token — the device WS auth secret (validated with
   timingSafeEqual) was returned in device list/get/update + pairing responses
   (SELECT d.* / *). New lib/device-sanitize.js strips it everywhere; prevents
   device impersonation by any workspace user.

#7 must_change_password enforced server-side — was a frontend-only redirect, so
   a provisioned temp password worked indefinitely via the API. requireAuth now
   403s every route except GET/PUT /api/auth/me (the password change, which
   clears the flag) and logout while the flag is set.

#8 XSS — escape user data interpolated into innerHTML in teams.js, kiosk.js,
   layout-editor.js (team/page/layout/zone names, member name/email, kiosk
   config fields). scriptSrcAttr 'unsafe-inline' made these exploitable via
   injected event handlers, not just markup.

#9 Thumbnail IDOR — /api/content/:id/thumbnail had no auth/scope gate (any UUID
   served any tenant's thumbnail). Now mirrors the /file route's playlist/widget
   workspace-scoped reference check.

Tests: new test/security-fixes.test.js (device strip, publicBranding field
allowlist, must_change_password gate). Full suite 41/41. Verified live against a
prod-data copy: device_token absent from /api/devices, /api/branding trimmed.

Not addressed here (tracked for follow-up): Android OTA signature verification
(Critical), public widget-render XSS, token revocation/logout, pairing-code
strength, validateRemoteUrl hardening, import quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:02:19 -05:00
ScreenTinker eb13f716d0 feat(branding): instance-level default white-label branding (#15)
White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.

Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).

The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.

- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
  upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
  hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
  (was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
  custom CSS) pre-auth; Admin page gets a "Default branding" form.

Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.

Closes #15.
2026-06-08 16:55:22 -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 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 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 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 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 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 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 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