Commit graph

350 commits

Author SHA1 Message Date
ScreenTinker 4f56199bc7 chore(release): v1.8.0 2026-06-10 13:46:17 -05:00
ScreenTinker 4771f62623 ci: release pipeline (tarball, tizen wgt, multi-arch docker) + Docker packaging
- .github/workflows/release.yml: on a v* tag - verify the tag matches VERSION
  (fail-fast guard), run tests, build a source tarball + the unsigned Tizen .wgt
  and publish a GitHub Release with generated notes, and build+push a multi-arch
  (amd64 + arm64) image to ghcr.io/screentinker/screentinker:<version> + :latest.
  The Release (artifacts) and the docker push are independent jobs, so an
  arm64/QEMU docker failure does not block the GitHub Release and is re-runnable.
  Nothing deploys to prod. APK-build-in-CI left as a TODO (keystore secret).
- Dockerfile + .dockerignore: multi-stage node:20-slim image with server +
  frontend + VERSION + scripts; DATA_DIR=/data volume for db/uploads/jwt-secret.
  Verified to build, boot, serve the dashboard + web player, and persist state.
- docker-compose.example.yml: /data volume, SELF_HOSTED, a node-fetch healthcheck
  against /api/status, and an admin-lockout recovery note (reset-admin.js).
- server.js: resolve the OTA APK from DATA_DIR first (a container can mount one
  at /data/ScreenTinker.apk), fall back to the legacy in-repo path, 404 gracefully.
- ci.yml: bump checkout/setup-node to v6 (clears the Node-20 action deprecation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:44:51 -05:00
ScreenTinker e2cd64054a ci: add CI workflow (unit tests + boot/version smoke)
- test job: node 20, npm ci + npm test in server/ (66 tests).
- smoke job: boot the server against a fresh SQLite db with SELF_HOSTED, then
  assert /api/status is ok and reports exactly the VERSION file (proves the
  single-source-of-truth wiring end to end).
- triggers: push and PR to main, plus manual workflow_dispatch. Concurrency
  cancels superseded in-flight runs per ref.
- upgrade-path job left as a TODO (needs a release tag earlier than HEAD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:24:46 -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 26cd29c530
Merge pull request #72 from screentinker/feat/player-orientation
feat(player): software orientation (portrait + flipped), Android + Tizen (1.7.12)
2026-06-09 21:43:12 -05:00
ScreenTinker dfc8a4e358 feat(player): software orientation (portrait + flipped) on both players (1.7.12)
The dashboard exposes landscape / portrait / landscape-flipped / portrait-flipped
and the README promises rotation, but neither player ever read the device's
orientation field - it was hardcoded landscape. Reported by a customer testing
Firestick + Samsung signage.

Rotate the CONTENT in software, not the panel: Fire TV / Android TV / Tizen are
fixed-landscape and ignore setRequestedOrientation (can't physically rotate).
- Android (MainActivity): applyOrientation() resizes rootView to the rotated
  dimensions, recenters, and rotates 0/90/180/270. rootView is the shared
  container for single-zone AND multi-zone, so both are covered. Driven from the
  playlist-update payload.
- Tizen (app.js): CSS transform on the stage (rotate + swapped 100vh/100vw),
  same four values, from the playlist payload.

Verified on an Android 16 emulator: device set to portrait -> 'Applied
orientation: portrait (rotation=90, swap=true)' and the video renders rotated.
2026-06-09 21:43:08 -05:00
screentinker f98bb57ab9
Merge pull request #71 from screentinker/feat/backup-script
feat(ops): nightly backup script with point-in-time content history
2026-06-09 19:53:12 -05:00
ScreenTinker 3ac81a4206 feat(ops): nightly backup script with point-in-time content history
Adds scripts/backup.sh — atomic SQLite .backup + hard-linked point-in-time
content snapshots, daily (7) + monthly (12) retention, and an error log.
Env-configurable (SCREENTINKER_DIR/BACKUP_DIR/DB/UPLOADS/*_KEEP*) so any
self-hoster can use it; defaults target a /opt/screentinker install.

Hardens two real failure modes found in production:
- Content snapshots EXCLUDE uploads/screenshots/ and use rsync --link-dest
  instead of cp -al. The per-device *_latest.jpg screenshots are rewritten
  24/7; cp -al aborts when a file mutates mid-copy and the prior script
  swallowed the error with 2>/dev/null, silently breaking content snapshots
  for ~8 weeks. rsync --link-dest hard-links unchanged files but tolerates
  in-flight changes; errors now go to backup.log.
- Retention sorts by NAME, not mtime: rsync -a / cp -al preserve the source
  dir's (frozen) mtime, so ls -dt treated fresh snapshots as oldest and pruned
  them. The timestamp is in the dir name, so name-sort is chronological.

README Backups section documents the cron setup + env knobs. Verified on prod.
2026-06-09 19:53:09 -05:00
screentinker 2cdf483f59
Merge pull request #70 from screentinker/chore/tizen-signing
chore(tizen): dev-signing setup + support email
2026-06-09 19:10:32 -05:00
ScreenTinker 5396cf9896 chore(tizen): dev-signing setup + support@screentinker.net author email
- config.xml author email -> support@screentinker.net
- build-wgt.sh: stage app files only before signing (keeps README/build script
  out of the .wgt), auto-add the Tizen CLI to PATH if installed.
- README: document the configured 'ScreenTinker' signing profile (self-signed
  author + default Tizen distributor) — installs on dev-mode TVs / emulator;
  production retail needs a Samsung distributor cert.

Signed .wgt + the author cert are not committed (build artifact / secret).
2026-06-09 19:10:28 -05:00
screentinker 6bcd193e45
Merge pull request #69 from screentinker/feat/tizen-player
feat(tizen): Samsung Tizen TV web player (.wgt)
2026-06-09 19:02:04 -05:00
ScreenTinker 0cfa09046c feat(tizen): Samsung Tizen TV web player (.wgt)
Ports the ScreenTinker player to a Tizen TV / signage web app, speaking the
SAME /device socket.io protocol as the Android player — no server changes; a
Tizen display pairs from the same dashboard.

- app.js: device protocol client — register (pairing_code | device_id+token),
  device:registered/paired/unpaired/playlist-update, 15s heartbeat, keep-awake.
  Always reaches the server prompt until the display is actually paired; a
  saved-but-unreachable server falls back to the prompt (no blank screen); BACK
  returns to it.
- player.js: fullscreen single-zone renderer — image (duration timer), video
  (play-to-end + loop), YouTube (iframe embed), widget (iframe render endpoint).
- config.xml: Tizen TV manifest; build-wgt.sh packages (signs if Tizen CLI
  present, else unsigned); README covers URL-Launcher and signed-.wgt deploy.

Validated: headless protocol test vs the live server passed end-to-end
(register -> pair -> reconnect-auth -> playlist(2) -> content 200); loads +
renders in Chromium with no JS errors.

Not yet ported (fullscreen single-zone covers most signage): multi-zone, video
walls, screenshots, remote control, self-OTA. .wgt is a build artifact (gitignored).
2026-06-09 19:01:58 -05:00
screentinker c20b5b9b6f
Merge pull request #68 from screentinker/feat/android-boot-launch-tv
feat(android): reliable boot-launch incl. Android TV (1.7.11)
2026-06-09 17:45:05 -05:00
ScreenTinker d9d7a8ae0f feat(android): reliable boot-launch incl. Android TV (1.7.11)
The player has a launcher (category.HOME) + a boot receiver, but auto-start was
unreliable where you can't set a home launcher (Android TV) and on Android 14+,
where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot
full-screen launcher silently no-ops.

Boot launch:
- BootReceiver now does a direct background startActivity when 'display over other
  apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch
  restriction, and the one path that works on Android TV. Full-screen-intent
  notification kept as a fallback (locked screen / no overlay).
- Boot notification moved to a dedicated HIGH-importance channel (full-screen
  intents are only honored from one), and it auto-dismisses once the UI is up.

Setup screen — new permission rows so operators can grant what boot-launch needs:
- Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+)
- Background Activity (battery-optimization exemption)
- Display Over Apps (SYSTEM_ALERT_WINDOW)
Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue
fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now
states updates are signature-verified, so it doesn't read as 'install anything'.

Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched
(Direct launch via overlay) and the boot notice cleared itself; all rows toggle.
2026-06-09 17:44:49 -05:00
screentinker acd93377e7
Merge pull request #67 from screentinker/fix/android-ota-install-completion
fix(android): OTA install completion + kiosk auto-confirm (1.7.10)
2026-06-09 16:14:12 -05:00
ScreenTinker 5e3408be9a fix(android): OTA install never completed; auto-confirm for kiosks (1.7.10)
The OTA downloaded + verified the new APK and committed a PackageInstaller
session, but never handled STATUS_PENDING_USER_ACTION (which Android 13+ returns
for non-device-owner installers) — so the session stalled and the update never
installed. Reproduced on an Android 13 emulator: device stayed on the old version.

- UpdateChecker: register a receiver for the session's INSTALL_COMPLETE broadcast;
  on PENDING_USER_ACTION launch the system confirm dialog (and log SUCCESS).
- PowerAccessibilityService: when the package-installer dialog appears, auto-click
  the confirm button (by id, then label) so unattended kiosk screens update
  without a human tap. Scoped strictly to the package installer.

Verified end-to-end on Android 13: device auto-updated 1.7.10 -> 1.7.11 with no
interaction (receiver launched the dialog, accessibility confirmed it). Ships as
1.7.10 (also carries the Android 14+ crash + YouTube 152 fixes).

NOTE: existing 1.7.7 devices still need a one-time manual reinstall to reach a
build that has this fix; from 1.7.10 onward OTA is fully automatic.
2026-06-09 16:14:08 -05:00
screentinker 91cf7ebee6
Merge pull request #66 from screentinker/release/android-1.7.9
release(android): 1.7.9 — Android 14+ crash + YouTube 152 fixes
2026-06-09 15:41:56 -05:00
ScreenTinker f392292b9e release(android): 1.7.9 — Android 14+ crash + YouTube 152 fixes
Rebuilds and redistributes the player APK so the fixes actually reach devices:
- #5 Android 14+ mediaProjection FGS crash (committed in source but the SERVED
  ScreenTinker.apk was a stale 1.7.7 build from before it — modern devices
  couldn't launch the app at all).
- YouTube error 152 (embed base domain).

versionCode 11->12, versionName 1.7.8->1.7.9, VERSION file 1.7.7->1.7.9 so the
update check offers it; signed with the same release key (OTA signature check
passes). Verified on a Pixel 10 / Android 16 emulator: launches without crashing,
YouTube plays.
2026-06-09 15:41:52 -05:00
screentinker 64975fec88
Merge pull request #65 from screentinker/fix/android-youtube-embed-152
fix(android): YouTube error 152 — embed under a third-party domain (#4)
2026-06-09 15:36:49 -05:00
ScreenTinker 4572963175 fix(android): YouTube error 152 - embed under a third-party domain, not youtube.com
The player loaded the YouTube embed via loadDataWithBaseURL with base
https://www.youtube.com, so the embedding page claimed to BE youtube.com hosting
a youtube.com iframe. YouTube rejects that as an invalid embed context -> 'This
video is unavailable / Error 152 - 4' for every video (reproduced on a Pixel 10
/ Android 16 emulator with multiple known-embeddable videos).

Load the embed under a real third-party domain (EMBED_BASE = the product domain)
so the referrer is a legitimate embedding site. The iframe still points at
youtube.com/embed. Verified: video now plays. (The earlier base=youtube.com was
the Error 153 fix; this supersedes it - a normal domain referrer fixes 153 too.)
2026-06-09 15:36:24 -05:00
screentinker b88150c115
Merge pull request #64 from screentinker/docs/help-ai-blurb
docs(help): AI Content Design quick-start in Help (#41)
2026-06-09 13:58:57 -05:00
ScreenTinker 09f543fb8b docs(help): add AI Content Design quick-start to the in-app Help page (#41) 2026-06-09 13:58:53 -05:00
screentinker 4a64053d66
Merge pull request #63 from screentinker/docs/local-ai-setup
docs: local AI setup guide for the Content Designer (#41)
2026-06-09 13:57:06 -05:00
ScreenTinker 1a4397ad24 docs: local AI setup guide for the Content Designer (#41)
How to run the AI design feature fully local + free: Ollama (OpenAI-compatible
LLM) for text/layout and stable-diffusion.cpp (Vulkan) for images, plus the
SELF_HOSTED requirement for localhost endpoints, an OpenAI fallback, and GPU
troubleshooting (incl. the Blackwell CUDA-fails/Vulkan-works note). Linked from
the README integrations section.
2026-06-09 13:57:02 -05:00
screentinker c1aee36326
Merge pull request #62 from screentinker/fix/ai-separate-image-key
feat(ai): separate optional image API key (#41)
2026-06-09 13:47:52 -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 c23e8ca289
Merge pull request #61 from screentinker/feat/ai-images-phase2
feat(ai): background + foreground images for signs (#41 Phase 2)
2026-06-09 13:40:19 -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 df4110d9ca
Merge pull request #60 from screentinker/fix/ai-deoverlap
fix(ai): de-overlap text + layer shapes behind text (#41)
2026-06-09 12:57:45 -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 958f5683e4
Merge pull request #59 from screentinker/fix/ai-canvas-fit
fix(ai): keep generated designs inside the canvas (#41)
2026-06-09 12:51:27 -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 f7f78a7486
Merge pull request #58 from screentinker/feat/ai-model-dropdown
feat(ai): model dropdown + longer timeout (#41)
2026-06-09 12:36:34 -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 d117016f2d
Merge pull request #57 from screentinker/feat/ai-content-design
feat(ai): AI content design, BYO endpoint (#41 Phase 1)
2026-06-09 12:24:00 -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 bcdffd4f56
Merge pull request #56 from screentinker/fix/branding-fouc
fix(branding): no default-brand flash on load/switch (#38)
2026-06-09 11:43:46 -05:00
ScreenTinker 2de99a12e9 fix(branding): no ScreenTinker default flash on load/switch (#38)
The logo/title/theme/favicon are static 'ScreenTinker' in index.html, and
applyBranding() only overrode them AFTER an async /api/white-label fetch - that
network delay was the flash, on every load and on switch (which reloads).

Now applyBranding caches the resolved white-label per workspace (keyed by the
JWT's current_workspace_id), and a tiny same-origin brand-prime.js loads
render-blocking right after the logo - so it applies the cached colors/name/
title/favicon/custom-css BEFORE first paint. CSP-safe (external 'self' script,
not inline). applyBranding still runs to refresh + re-cache. First-ever visit to
an uncached branded workspace still shows the default once; every load after is
flash-free.
2026-06-09 11:43:42 -05:00
screentinker 97c52408de
Merge pull request #55 from screentinker/fix/content-thumbnail-auth
fix(content): thumbnails for not-yet-assigned uploads (#39)
2026-06-09 11:19:01 -05:00
ScreenTinker 6760f61fb8 fix(content): show thumbnails for not-yet-assigned content (#39)
After uploading, content thumbnails were blank until the item was added to a
playlist/widget. The public /api/content/:id/thumbnail (and /file) endpoints are
reference-gated (an anonymous player with a UUID must not pull arbitrary tenants'
media), and a plain <img> can't send a Bearer token - so a just-uploaded item 403'd.

- Backend: add an authenticated bypass - a logged-in user who can access the
  content's workspace (verified from the Bearer token) may view its file/thumbnail
  even when unreferenced. Anonymous players still hit the reference gate.
- Frontend: the content library lazy-fetches thumbnails/previews WITH the token
  and swaps in an object URL (IntersectionObserver keeps it under the rate limit;
  the URL is revoked after load).

Verified: unreferenced thumbnail now 200 with a bearer token, still 403 anonymous.
2026-06-09 11:18:56 -05:00
screentinker 61279e9bea
Merge pull request #54 from screentinker/fix/upload-multiple-hint
ui(content): advertise multi-file upload in drop-zone text (#39)
2026-06-09 10:50:56 -05:00
ScreenTinker 020f0bfea7 ui(content): advertise multi-file upload in the drop-zone text (#39)
The upload input already has 'multiple' and the click handler shares handleFiles()
with drag-drop, so picking multiple files (shift/ctrl-click) already works - it just
wasn't discoverable ('click to upload' read as single-file). Reword to 'click to
select one or more'.
2026-06-09 10:50:51 -05:00
screentinker 3f429aec85
Merge pull request #53 from screentinker/fix/template-zone-duplication
fix(layouts): atomic zone save — stop template zone duplication
2026-06-09 10:16:06 -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 e2460855d9
Merge pull request #52 from screentinker/fix/migrate-count-addcolumn
fix(db): boot log counts only ADD COLUMN (#37 follow-up)
2026-06-09 10:02:43 -05:00
ScreenTinker bae70e9154 fix(db): count only ADD COLUMN as new migrations in boot log (#37 follow-up)
The boot summary counted any non-throwing statement, so UPDATE/index migrations
(which always succeed) made a healthy DB report 'applied N new column migration(s)'
every boot. Count only a successful ALTER ... ADD COLUMN (genuinely new), so the
line appears only when a column was actually added.
2026-06-09 10:02:38 -05:00
screentinker 7ef3e2eb93
Merge pull request #51 from screentinker/fix/migration-schema-verify
fix(db): observable migrations + fail-fast schema verification (#37)
2026-06-09 09:31:57 -05:00
ScreenTinker 7ab19adcea fix(db): observable migrations + fail-fast schema verification (#37)
Self-hosters rebuilding could end up schema-behind-code, failing only at runtime
(a missing users.must_change_password locked out all logins). Two root causes:

1. The migration loop swallowed EVERY error (catch {}), so a real ALTER failure
   was indistinguishable from the benign 'duplicate column' on an already-migrated
   DB. Now only 'duplicate column'/'already exists' is treated as a no-op; any
   other error is logged loudly, and a one-line summary reports how many new
   column migrations actually applied this boot.

2. Nothing verified the schema after migrations. Added lib/schema-check.js:
   verifyAndRepairSchema() checks the tables + columns the request path REQUIRES,
   idempotently repairs missing repairable columns (logging each), and if anything
   required is STILL missing, prints a loud FATAL block and exits - failing fast at
   boot instead of at the first authed request.

Note: the reported 'audit_log missing' was a misdiagnosis - the code uses
activity_log (0 refs to audit_log), created by schema.sql on every boot.

Tests: healthy (no-op), auto-repair of must_change_password, missing-table report.
2026-06-09 09:31:52 -05:00
screentinker 9deccf0a2f
Merge pull request #50 from screentinker/feat/admin-delete-org-workspace
feat(admin): Delete Organization + Workspace with cascade (#36)
2026-06-09 09:22:25 -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