screentinker/server/lib
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
..
command-queue.js feat(socket): delivery queue for offline-device emits 2026-05-14 13:06:43 -05:00
permissions.js feat(workspaces): invite/accept-invite backend (slice 1+3) 2026-05-16 12:19:59 -05:00
socket-rooms.js feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename. 2026-05-12 11:34:24 -05:00
tenancy.js fix(auth/me): broaden non-admin accessible_workspaces to include org_owner/org_admin paths 2026-05-16 11:50:37 -05:00