screentinker/server/middleware/subscription.js
screentinker 51b0b006b1
fix(pairing): reinstalled panel reclaims its device row instead of being blocked [Bold] (#180)
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>
2026-07-13 23:07:50 -05:00

153 lines
4.8 KiB
JavaScript

const { db } = require('../db/database');
const config = require('../config');
const TRIAL_DAYS = 14;
function getUserPlan(userId) {
const user = db.prepare(`
SELECT u.*, p.name as plan_name, p.display_name as plan_display_name,
p.max_devices, p.max_storage_mb, p.remote_control, p.remote_url,
p.priority_support, p.price_monthly, p.price_yearly
FROM users u
JOIN plans p ON u.plan_id = p.id
WHERE u.id = ?
`).get(userId);
// No user row (or no joinable plan) — return null so callers treat it as unrestricted
// (checkDeviceAccess: `if (!plan) return { allowed: true }`). Previously the else branch
// below dereferenced an undefined `user` ("Cannot set properties of undefined"), which — once
// a claimed device's reclaim runs checkDeviceAccess — was swallowed by the caller's try/catch
// and silently dropped the device to the provision-fresh path instead of reclaiming it.
if (!user) return null;
// Check if trial has expired
if (user.trial_started) {
const trialEnd = user.trial_started + (TRIAL_DAYS * 86400);
const now = Math.floor(Date.now() / 1000);
user.trial_active = now < trialEnd;
user.trial_days_left = Math.max(0, Math.ceil((trialEnd - now) / 86400));
user.trial_end = trialEnd;
// Auto-downgrade if trial expired and no paid subscription
if (!user.trial_active && user.subscription_status !== 'active' && user.plan_name !== 'free') {
db.prepare("UPDATE users SET plan_id = 'free', trial_started = NULL WHERE id = ?").run(userId);
// Re-fetch with free plan
return getUserPlan(userId);
}
} else {
user.trial_active = false;
user.trial_days_left = 0;
}
return user;
}
function getUserDeviceCount(userId) {
return db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(userId).count;
}
function getUserStorageMB(userId) {
const result = db.prepare('SELECT COALESCE(SUM(file_size), 0) as total FROM content WHERE user_id = ?').get(userId);
return Math.ceil(result.total / (1024 * 1024));
}
// Check if user can add more devices
function checkDeviceLimit(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan) return res.status(403).json({ error: 'No plan found' });
// -1 means unlimited
if (plan.max_devices === -1) return next();
const deviceCount = getUserDeviceCount(req.user.id);
if (deviceCount >= plan.max_devices) {
return res.status(403).json({
error: `Device limit reached (${plan.max_devices} on ${plan.plan_display_name} plan). Upgrade to add more.`,
code: 'DEVICE_LIMIT',
current: deviceCount,
limit: plan.max_devices,
plan: plan.plan_name
});
}
next();
}
// Check if user can upload more content
function checkStorageLimit(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan) return res.status(403).json({ error: 'No plan found' });
// -1 means unlimited
if (plan.max_storage_mb === -1) return next();
const usedMB = getUserStorageMB(req.user.id);
if (usedMB >= plan.max_storage_mb) {
return res.status(403).json({
error: `Storage limit reached (${plan.max_storage_mb}MB on ${plan.plan_display_name} plan). Upgrade for more.`,
code: 'STORAGE_LIMIT',
current_mb: usedMB,
limit_mb: plan.max_storage_mb,
plan: plan.plan_name
});
}
next();
}
// Check if user has remote control access
function checkRemoteControl(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan || !plan.remote_control) {
return res.status(403).json({
error: 'Remote control requires Starter plan or above.',
code: 'FEATURE_LOCKED',
plan: plan?.plan_name
});
}
next();
}
// Check remote URL feature access
function checkRemoteUrl(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan || !plan.remote_url) {
return res.status(403).json({
error: 'Remote URL content requires Pro plan or above.',
code: 'FEATURE_LOCKED',
plan: plan?.plan_name
});
}
next();
}
// Check subscription is active (not expired)
function checkActiveSubscription(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan) return res.status(403).json({ error: 'No plan found' });
// Free plan is always active
if (plan.plan_name === 'free') return next();
// Self-hosted mode doesn't check expiry
if (config.selfHosted) return next();
// Check if subscription has expired
if (plan.subscription_status !== 'active' && plan.subscription_ends && plan.subscription_ends < Math.floor(Date.now() / 1000)) {
return res.status(403).json({
error: 'Subscription expired. Please renew to continue.',
code: 'SUBSCRIPTION_EXPIRED'
});
}
next();
}
module.exports = {
getUserPlan,
getUserDeviceCount,
getUserStorageMB,
checkDeviceLimit,
checkStorageLimit,
checkRemoteControl,
checkRemoteUrl,
checkActiveSubscription
};