getUserPlan()'s auto-downgrade was guarded on `subscription_status !== 'active'`,
but that column DEFAULTs to 'active' and is only ever changed by Stripe webhook
events. For trial users who never touch Stripe — the entire population it's meant
to catch — the condition was always false, so the downgrade never ran and every
signup kept Pro free forever.
Re-key the guard on the real signals:
- trial expired (!trial_active), AND
- stripe_subscription_id IS NULL (never paid), AND
- plan_id === trial_plan (still on the plan the trial granted), AND
- plan_name !== 'free'
The plan_id === trial_plan clause is load-bearing: it protects comped / hand-
granted plans (e.g. a manually-set enterprise plan, where plan_id !== trial_plan)
from being silently downgraded. Grandfathered accounts (trial_started IS NULL)
never enter the block at all, so the ~home cohort is untouched. Added a comment
documenting the subscription_status-default trap so it isn't reintroduced.
Enforcement stays forward-only/lazy — the downgrade happens in the resolver on a
user's next request; no mass update here.
Downstream (deviceSocket.checkDeviceAccess, traced, unchanged): a genuinely-
expired free-tier trial now resolves to free and its device-limit block correctly
caps it to 1 device; grandfathered home (2 devices) and paid users are not
blocked. NOTE: the separate "Trial Expired" screen branch there is a pre-existing
dead condition (it needs trial_started set AND plan_name='free' at once, but the
downgrade clears trial_started) — left as-is per scope; flagged for follow-up.
Tests (new trial-expiry.test.js — there was none, which is how this shipped):
lapsed trial downgrades; comped enterprise (plan_id!=trial_plan) not downgraded;
grandfathered home (trial_started NULL) not downgraded; paid user not downgraded;
in-window trial not downgraded; plus a regression pinning that subscription_status
='active' no longer shields a lapsed trial. Suite 563/563.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.
Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.
The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
row, rotate the token, emit device:registered{online} + device:paired. The panel returns
straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
claim / playlist / content. device:paired drives the app off the pairing screen, so the
fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).
Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.
Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>