platform_operator is cross-org STAFF: it can see and act-as into every
org and read/write workspace-scoped resources (content, playlists,
layouts, schedules, devices, widgets, kiosk) anywhere - but holds NO
owner-level power.
Design is deny-by-default: operator is NEVER added to PLATFORM_ROLES /
isPlatformRole, so every owner capability (billing, org/workspace
deletion, user/role management, shared & template asset curation,
branding, workspace member mgmt/rename) stays denied, and any NEW owner
endpoint added later inherits that denial automatically.
Operator gets power from exactly two levers:
- middleware/auth.js: new PLATFORM_STAFF set + isPlatformStaff(); owner
guards (PLATFORM_ROLES, requireAdmin, requireSuperAdmin) unchanged.
- tenancy.js: accessContext + resolveTenancy treat staff as act-as
capable; new req.isPlatformStaff / req.isPlatformOperator (req.isPlatformAdmin
stays owner-only); accessibleWorkspaceIds + switch-workspace guard use staff.
- permissions.js: canRead/canWrite + canAccessWorkspace (read) grant staff;
canAdmin / canAdminWorkspace / isOrgAdmin / isOrgOwner stay owner-gated.
Read-only edges (per review): operator may VIEW workspace member lists
(canAccessWorkspace) and the unassigned device pool (devices.js), but
cannot mutate either.
Frontend: platform role dropdown adds "Platform operator"; the user-mgmt
view stays isPlatformAdmin-gated so operators can't open it. EN i18n only.
Behaviour identical under HOSTED_INSTANCE set or unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The legacy /api/auth/users dropdown could write 'superadmin' and 'admin'
role strings that not every code path recognized. Some checks matched only
'platform_admin' (tenancy accessContext/resolveTenancy), so a 'superadmin'
user could list orgs but not act-as into them.
Normalize to the current two-tier platform model (users.role holds the
PLATFORM role only; org/workspace roles live in the membership tables):
- Migration (idempotent, exact-string): superadmin -> platform_admin,
admin -> user. No-ops on rows already in the current model.
- Add isPlatformRole() helper in middleware/auth.js; route the two
superadmin-excluding checks in tenancy.js through it so a stray
'superadmin' is never treated as lower-privileged (fixes act-as).
- Remove the dead/stricter requirePlatformAdmin in permissions.js (bare
=== 'platform_admin'); the single guard is the one in middleware/auth.js.
- Recovery-token default role admin -> platform_admin so emergency
recovery keeps full access once 'admin' no longer implies elevation.
- PUT /api/auth/users/:id/role whitelist -> ['user','platform_admin'];
self-demote guard retargeted via isPlatformRole.
- Frontend: platform user-management dropdown now offers User / Platform
admin only; owner-delete guard and settings highlight use isPlatformAdmin.
EN i18n: add admin.role.platform_admin.
Behaviour is identical under HOSTED_INSTANCE set or unset; the migration
only touches exact legacy strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>