mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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>
94 lines
2.9 KiB
JavaScript
94 lines
2.9 KiB
JavaScript
const { db } = require('../db/database');
|
|
const config = require('../config');
|
|
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
|
|
|
// Track connected device sockets: deviceId -> { socketId, lastHeartbeat }
|
|
const deviceConnections = new Map();
|
|
|
|
function startHeartbeatChecker(io) {
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
const dashboardNs = io.of('/dashboard');
|
|
|
|
// Check database for devices that should be offline
|
|
const onlineDevices = db.prepare("SELECT id, last_heartbeat FROM devices WHERE status = 'online'").all();
|
|
|
|
for (const device of onlineDevices) {
|
|
const conn = deviceConnections.get(device.id);
|
|
const lastBeat = conn ? conn.lastHeartbeat : (device.last_heartbeat ? device.last_heartbeat * 1000 : 0);
|
|
|
|
if (now - lastBeat > config.heartbeatTimeout) {
|
|
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
|
|
.run(device.id);
|
|
deviceConnections.delete(device.id);
|
|
|
|
// Notify dashboard (workspace-scoped via the device's room).
|
|
emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', {
|
|
device_id: device.id,
|
|
status: 'offline',
|
|
telemetry: null
|
|
});
|
|
|
|
console.log(`Device ${device.id} marked offline (heartbeat timeout)`);
|
|
try {
|
|
db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(device.id, 'offline_timeout');
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// Cleanup: delete unclaimed provisioning devices older than 24 hours
|
|
// Keep imported devices (they have user_id set) so users can re-pair them
|
|
db.prepare(`
|
|
DELETE FROM devices WHERE status = 'provisioning'
|
|
AND user_id IS NULL
|
|
AND created_at < strftime('%s','now') - (365 * 86400)
|
|
`).run();
|
|
|
|
// Cleanup: prune play logs older than 90 days
|
|
db.prepare(`
|
|
DELETE FROM play_logs WHERE started_at < strftime('%s','now') - (90 * 86400)
|
|
`).run();
|
|
|
|
// Cleanup: expired team invites
|
|
db.prepare(`
|
|
DELETE FROM team_invites WHERE expires_at < strftime('%s','now')
|
|
`).run();
|
|
|
|
// Cleanup: expired workspace invites
|
|
db.prepare(`
|
|
DELETE FROM workspace_invites WHERE expires_at < strftime('%s','now')
|
|
`).run();
|
|
|
|
}, config.heartbeatInterval);
|
|
}
|
|
|
|
function registerConnection(deviceId, socketId) {
|
|
deviceConnections.set(deviceId, { socketId, lastHeartbeat: Date.now() });
|
|
}
|
|
|
|
function updateHeartbeat(deviceId) {
|
|
const conn = deviceConnections.get(deviceId);
|
|
if (conn) conn.lastHeartbeat = Date.now();
|
|
}
|
|
|
|
function removeConnection(deviceId) {
|
|
deviceConnections.delete(deviceId);
|
|
}
|
|
|
|
function getConnection(deviceId) {
|
|
return deviceConnections.get(deviceId);
|
|
}
|
|
|
|
function getAllConnections() {
|
|
return deviceConnections;
|
|
}
|
|
|
|
module.exports = {
|
|
startHeartbeatChecker,
|
|
registerConnection,
|
|
updateHeartbeat,
|
|
removeConnection,
|
|
getConnection,
|
|
getAllConnections
|
|
};
|