screentinker/docs/multi-tenancy-design.md

37 KiB

ScreenTinker Multi-Tenancy / Reseller Design (V1)

Status: design approved 2026-05-11. Implementation begins Phase 1 on approval of this doc.

1. Mental model

Today every user is the root of their own data. Teams give shared scope inside one user. There is no layer above that.

V1 adds two layers:

platform                 (the hosted screentinker.com instance, or one self-hosted install)
  organization           (a reseller or a customer paying us; owns a Stripe sub)
    workspace            (a client of the reseller; what was previously a Team)
      device | content | playlist | layout | widget | schedule | video_wall | ...
  • An organization is a billing/admin entity. Resellers run an org with many workspaces. Direct customers run an org with one workspace.
  • A workspace is a tenant. Data inside is isolated from siblings. Equivalent to today's teams row, just parented by an org.
  • Workspaces are the unit of UI tenancy: when you log in, you are "in" exactly one workspace at a time. The workspace picker switches context.

teams collapses into workspaces. team_members collapses into workspace_members. No nested teams inside workspaces in V1.

2. Roles

Role Scope Powers
platform_admin platform (one or two rows) sees everything across all orgs. Replaces today's superadmin. Hosted operator only.
org_owner one org full control of the org and every workspace inside, owns the Stripe subscription, can delete the org.
org_admin one org same as org_owner minus billing and delete-org. Suitable for reseller staff.
workspace_admin one workspace full control of one workspace: users, devices, content, playlists, branding.
workspace_editor one workspace create/edit content, devices, playlists, layouts, schedules. No user invites, no branding.
workspace_viewer one workspace read-only.

Notes:

  • Today's users.role = 'admin' (intermediate hosted role) is dropped. Existing rows get migrated to org_admin of their migrated org. See section 7.
  • workspace_owner and workspace_admin collapse into a single workspace_admin role.
  • A single user can hold roles in multiple orgs and multiple workspaces (multi-org membership). Memberships are stored in two join tables (see section 3).

Permission check layering

Resolution order on every request, top wins:

  1. platform_admin on the user row -> allow.
  2. org_owner or org_admin on the user-in-this-org membership -> allow within that org's workspaces.
  3. workspace_admin / editor / viewer on the user-in-this-workspace membership -> allow within that one workspace at the role level.
  4. Otherwise -> 403.

Code shape (pseudocode, not code):

function can(user, action, target) {
  if (user.role === 'platform_admin') return true;
  const orgRole = orgRoleOf(user.id, target.organization_id);
  if (orgRole === 'org_owner') return true;
  if (orgRole === 'org_admin' && !ORG_OWNER_ONLY.has(action)) return true;
  const wsRole = workspaceRoleOf(user.id, target.workspace_id);
  return roleAllows(wsRole, action);
}

ORG_OWNER_ONLY = { 'billing.write', 'org.delete', 'workspace.delete' }.

3. Schema

3.1 New tables

CREATE TABLE IF NOT EXISTS organizations (
    id                      TEXT PRIMARY KEY,
    name                    TEXT NOT NULL,
    slug                    TEXT UNIQUE,                       -- v2 subdomain hook
    owner_user_id           TEXT NOT NULL REFERENCES users(id),
    plan_id                 TEXT DEFAULT 'free' REFERENCES plans(id),
    stripe_customer_id      TEXT,
    stripe_subscription_id  TEXT,
    subscription_status     TEXT DEFAULT 'active',
    subscription_ends       INTEGER,
    -- subscription lifecycle (section 8)
    grace_period_ends       INTEGER,                           -- nullable; set when sub fails or cancels at period end
    locked_at               INTEGER,                           -- nullable; set when grace expires
    -- branding defaults applied to new workspaces in this org
    default_brand_name      TEXT,
    default_logo_url        TEXT,
    default_primary_color   TEXT,
    created_at              INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    updated_at              INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE TABLE IF NOT EXISTS organization_members (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role            TEXT NOT NULL DEFAULT 'org_admin',        -- 'org_owner' | 'org_admin'
    invited_by      TEXT REFERENCES users(id),
    joined_at       INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    UNIQUE(organization_id, user_id)
);

CREATE TABLE IF NOT EXISTS workspaces (
    id              TEXT PRIMARY KEY,
    organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    name            TEXT NOT NULL,
    slug            TEXT,                                       -- v2 subdomain hook; unique within org
    created_by      TEXT REFERENCES users(id),
    created_at      INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    updated_at      INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    UNIQUE(organization_id, slug)
);

CREATE TABLE IF NOT EXISTS workspace_members (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    workspace_id    TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role            TEXT NOT NULL DEFAULT 'workspace_viewer',  -- 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
    invited_by      TEXT REFERENCES users(id),
    joined_at       INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    UNIQUE(workspace_id, user_id)
);

CREATE TABLE IF NOT EXISTS workspace_invites (
    id              TEXT PRIMARY KEY,
    workspace_id    TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
    email           TEXT NOT NULL,
    role            TEXT NOT NULL DEFAULT 'workspace_viewer',
    invited_by      TEXT NOT NULL REFERENCES users(id),
    expires_at      INTEGER NOT NULL,
    created_at      INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

3.2 Existing-table changes

Every per-tenant resource gets a workspace_id. The legacy user_id column stays (nullable) and represents "created by"; the legacy team_id column stays for one release as a compatibility shim, then drops in V2.

Table Adds Notes
devices workspace_id TEXT REFERENCES workspaces(id) required for new rows; legacy user_id becomes nullable created_by.
content workspace_id same.
playlists workspace_id same.
layouts workspace_id same.
widgets workspace_id same. user_id IS NULL ("public") rows stay platform-level templates owned by platform_admin.
schedules workspace_id same.
video_walls workspace_id same.
device_groups workspace_id same.
white_labels workspace_id TEXT REFERENCES workspaces(id) (keyed by workspace, not user). Org-level defaults live on organizations.default_*.
activity_log organization_id, workspace_id, acting_user_id, was_acting_as both org and workspace since some actions are org-scoped (billing). acting_user_id records the reseller when an action was performed via acting-as; was_acting_as INTEGER DEFAULT 0 is the boolean flag. When not acting-as, acting_user_id is NULL and was_acting_as = 0.
kiosk_pages workspace_id same.
alert_configs workspace_id same.
device_fingerprints (none) platform-wide reinstall guard, stays user-keyed by intent.

3.3 Stripe columns

users.plan_id, users.stripe_customer_id, users.stripe_subscription_id, users.subscription_status, users.subscription_ends -> move to organizations. Columns stay on users as nullable for one release (see Q9 default), then drop in V2.

3.3.1 Workspace billing metadata (add D)

The workspaces table also carries reseller-side annotation columns. These are visible and editable only to org_owner and org_admin. workspace_admin and below cannot see them. They never affect Stripe, never affect device caps, and ScreenTinker never emails the addresses stored in them.

ALTER TABLE workspaces ADD COLUMN billing_type          TEXT DEFAULT 'client_billable';
ALTER TABLE workspaces ADD COLUMN billing_notes         TEXT;
ALTER TABLE workspaces ADD COLUMN billing_contact_email TEXT;
ALTER TABLE workspaces ADD COLUMN billing_contract_ref  TEXT;
Column Purpose
billing_type One of client_billable (default - workspace is a paying client of the reseller), client_complimentary (client the reseller is comping - demo, charity, freebie), internal (the reseller's own usage - test bed, sales demo, their own signage).
billing_notes Free-text reseller memory of the deal: "Acme - $50/mo, net-30, started 2025-09-01".
billing_contact_email Whom at the client the reseller invoices. Stored only; never receives platform email.
billing_contract_ref Reseller's internal cross-reference (contract id, CRM ticket, whatever).

How a reseller actually charges these clients (full retail, discounted, comped, not at all) is the reseller's business and never modeled or enforced by the platform. See §8.1.

3.4 What stays user-scoped

  • users table itself: identity, password, auth_provider, name, avatar.
  • device_fingerprints: reinstall guard, no tenancy concept.
  • team_invites / workspace_invites: scoped to the inviting workspace.

3.5 What gets both org and workspace IDs

Only activity_log. Some entries (billing, workspace create/delete) need to live at the org level even if no workspace context applies; others (device pair, content upload) carry both for filtering.

4. Migration

4.1 Strategy

Every existing user with any owned data becomes an organizations row plus a default workspaces row plus optional additional workspaces (their existing teams).

For each user U with owned data:
  org_id = new uuid
  insert organizations(id=org_id, name="<U.email>'s organization",
                       owner_user_id=U.id,
                       plan_id=U.plan_id,
                       stripe_*=U.stripe_*,
                       subscription_*=U.subscription_*)
  insert organization_members(org_id, U.id, role='org_owner')

  if U owns any teams T1..Tn:
    for each Ti:
      insert workspaces(id=Ti.id, organization_id=org_id, name=Ti.name, created_by=Ti.owner_id)
      -- workspace.id reuses team.id so referencing rows continue to resolve
      for each team_members row M of Ti:
        ws_role = map(M.role)   -- owner -> workspace_admin, editor -> workspace_editor, viewer -> workspace_viewer
        insert workspace_members(workspace_id=Ti.id, user_id=M.user_id, role=ws_role)
    -- pick a default workspace for U: the team they own with the most data (or first by created_at)

  else:
    ws_id = new uuid
    insert workspaces(id=ws_id, organization_id=org_id, name='Default', created_by=U.id)
    insert workspace_members(workspace_id=ws_id, user_id=U.id, role='workspace_admin')

  for each user-scoped table (devices, content, etc):
    UPDATE table SET workspace_id = (
      -- if team_id is set on the row, use it as the workspace_id (team and workspace share id)
      -- otherwise use U's default workspace
      COALESCE(table.team_id, U_default_ws_id)
    )
    WHERE user_id = U.id

For each user U with users.role IN ('superadmin'):
  UPDATE users SET role='platform_admin' WHERE id=U.id

For each user U with users.role = 'admin':
  -- legacy intermediate role is dropped. Their migrated org gets them as org_admin.
  -- if they already became org_owner via the loop above, leave as org_owner.
  UPDATE users SET role='user' WHERE id=U.id
  -- (org_admin row is added by the per-org loop above for any team-membered admins)

Re-using team.id as the new workspace.id is intentional: every existing FK that points at a team continues to resolve without rewriting. Sockets, JWTs, and bookmarked URLs survive.

4.2 Migration SQL (high level)

Lives in server/db/database.js migrations array, idempotent, runs on next boot:

-- New tables (4x CREATE TABLE IF NOT EXISTS, shown in 3.1).

-- Additive columns. Each wrapped in try/catch in the migration runner so re-runs are safe.
ALTER TABLE devices         ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE content         ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE playlists       ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE layouts         ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE widgets         ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE schedules       ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE video_walls     ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE device_groups   ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE white_labels    ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE kiosk_pages     ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE alert_configs   ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE activity_log    ADD COLUMN workspace_id     TEXT REFERENCES workspaces(id);
ALTER TABLE activity_log    ADD COLUMN organization_id  TEXT REFERENCES organizations(id);
ALTER TABLE activity_log    ADD COLUMN acting_user_id   TEXT REFERENCES users(id);
ALTER TABLE activity_log    ADD COLUMN was_acting_as    INTEGER DEFAULT 0;

-- Reseller-side workspace annotations (add D).
ALTER TABLE workspaces      ADD COLUMN billing_type          TEXT DEFAULT 'client_billable';
ALTER TABLE workspaces      ADD COLUMN billing_notes         TEXT;
ALTER TABLE workspaces      ADD COLUMN billing_contact_email TEXT;
ALTER TABLE workspaces      ADD COLUMN billing_contract_ref  TEXT;

-- Indexes for the new lookup paths.
CREATE INDEX IF NOT EXISTS idx_devices_workspace        ON devices(workspace_id);
CREATE INDEX IF NOT EXISTS idx_content_workspace        ON content(workspace_id);
CREATE INDEX IF NOT EXISTS idx_playlists_workspace      ON playlists(workspace_id);
CREATE INDEX IF NOT EXISTS idx_video_walls_workspace    ON video_walls(workspace_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_organization  ON workspaces(organization_id);
CREATE INDEX IF NOT EXISTS idx_workspace_members_user   ON workspace_members(user_id);
CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);

Backfill runs as a one-shot in a transaction inside the migration runner, behind a schema_migrations row keyed 2026-05-11-multitenancy-backfill so it only runs once. Pseudocode in 4.1; concrete script ships in Phase 1.

4.3 Down-migration

We do NOT auto-rollback. On failure during Phase 1 testing:

  1. Take a pre-migration backup (the migration runner snapshots the SQLite file to data/screentinker.pre-multitenancy.sqlite before applying anything).
  2. Manual rollback: cp data/screentinker.pre-multitenancy.sqlite data/screentinker.sqlite && systemctl restart.
  3. No partial-migration state is allowed: the backfill runs inside BEGIN TRANSACTION ... COMMIT. Any error rolls the whole batch.

Phase 1 ships with a node scripts/rollback-multitenancy.js that drops the new tables and ALTER columns for completeness. It is NEVER auto-invoked.

4.4 Validation gate

Before Phase 2 begins, Phase 1 must produce a passing local test:

  • Clone the production SQLite backup to dev.
  • Run migrations.
  • For every user U, run a diff:
    • count(devices WHERE user_id=U) before == count(devices WHERE workspace_id IN ws_of_U) after.
    • same for content, playlists, layouts, widgets, schedules, video_walls.
  • Existing JWTs still resolve to a valid current_workspace_id.
  • Existing API calls still return the same shape (Phase 2 changes the shape; Phase 1 only adds columns).

5. API surface

5.1 New endpoints

POST   /api/orgs                                       create org (platform_admin or self-host bootstrap)
GET    /api/orgs                                       list orgs the caller can see
GET    /api/orgs/:id                                   org detail (incl. workspaces, members, billing summary)
PUT    /api/orgs/:id                                   update org (name, branding defaults)
DELETE /api/orgs/:id                                   delete org (org_owner only)
GET    /api/orgs/:id/usage                             rollup: per-workspace device counts (add B)
POST   /api/orgs/:id/members                           invite org member (org_owner)
DELETE /api/orgs/:id/members/:user_id                  remove org member

POST   /api/orgs/:id/workspaces                        create workspace
GET    /api/workspaces                                 list workspaces the caller can access
GET    /api/workspaces/:id                             workspace detail
PUT    /api/workspaces/:id                             update (name, branding override)
DELETE /api/workspaces/:id                             delete (org_owner)
POST   /api/workspaces/:id/members                     invite member to a workspace
DELETE /api/workspaces/:id/members/:user_id            remove member

POST   /api/auth/switch-workspace                      session swap: { workspace_id } -> new JWT
GET    /api/auth/me                                    now returns { user, current_workspace, accessible_workspaces[], current_org_role }

5.2 Existing endpoints

V1 keeps every existing path operational. Scoping happens implicitly:

  • JWT carries current_workspace_id. Set on login (last-used or first available). Updated on /api/auth/switch-workspace.
  • Every existing route resolves workspace_id from JWT and filters by it instead of user_id.
  • Optional ?workspace_id= query param overrides per-request (used by org_owner tooling).
  • No 308 redirects in V1. Path-versioned /api/workspaces/:wid/... form is deferred to V2.

The result is that frontend code in V1 continues to call /api/devices, /api/content, etc., unchanged. The middleware does the work.

5.3 Auth flow

POST /api/auth/login -> { token, user, accessible_workspaces[], current_workspace_id }

If accessible_workspaces.length === 1, frontend auto-enters it. If accessible_workspaces.length > 1, frontend shows the picker. If accessible_workspaces.length === 0, account is dormant (org but no workspace memberships) -> show "No workspace yet" landing.

6. Workspace switching UX

  • Picker at #/select-workspace shown after login when count > 1. Two columns:
    • "My workspaces" (workspaces where user is a member).
    • "Acting as" (for org_owner / org_admin: every workspace inside their org they aren't a direct member of). Visible only if user is org-level.
  • Persistent header indicator: workspace name + dropdown arrow at the top-left of the dashboard. Click opens the same picker as a popover.
  • Acting-as ribbon: when a reseller is inside a workspace they aren't a direct workspace_member of, a yellow bar pinned below the header reads Acting as workspace: <name>. <Return to my workspace>. Clicking the link switches back to the user's default workspace.
  • Audit log: every action recorded in an acting-as session has acting_user_id = reseller, target_workspace_id = client_workspace, was_acting_as = true. UI in the audit log filters surfaces these distinctly.

7. White-label

  • white_labels.workspace_id replaces white_labels.user_id. Branding belongs to the workspace.
  • organizations.default_* columns hold the org's default brand. On workspace create, the workspace's white_labels row is initialized from these defaults; the workspace_admin can override any field.
  • branding.js resolution order: per-workspace white_labels row -> org defaults -> platform defaults.
  • Custom domain per workspace: V2. The white_labels.custom_domain column stays unused in V1.

8. Billing model (rollup) and lifecycle (add A)

8.1 Model

The org_owner is the sole billable entity. A workspace under a paid org has:

  • NO Stripe customer.
  • NO Stripe subscription.
  • NO billing portal access.
  • NO platform-level billing relationship of any kind.

The platform sees one customer per org: the org_owner. Stripe knows nothing about workspaces.

How a reseller charges their own clients (full price, discounted, complimentary, comped, internal-only) is entirely the reseller's business. The platform does not model it, enforce it, or contact the client. The workspaces.billing_type / billing_notes / billing_contact_email / billing_contract_ref columns (see §3.3.1) exist purely as the reseller's own memory and are never read by any platform code path that touches money or email.

  • One Stripe subscription per organization, attached to org_owner.
  • plans.max_devices is the org-wide cap. Sum of devices across all workspaces of the org is checked.
  • Workspaces inside a paid org have no individual plan or Stripe relationship (see above).
  • Self-hosted: Stripe enforcement off regardless.

8.2 Device-count enforcement at pairing time

on POST /api/provision/pair:
  org = orgOf(caller)
  total_devices = sum(devices WHERE workspace_id IN workspaces_of(org.id))
  plan = plan_of(org)
  if total_devices >= plan.max_devices and plan.id != 'enterprise':
    return 402 { error: 'Org device limit reached', current: total_devices, limit: plan.max_devices }
  ...

device_status_log shows the user a clear error: which org, which limit, which plan.

8.3 Subscription lifecycle (add A)

States on the organizations row: active, past_due, grace, read_only, locked. Driven by the existing Stripe webhook plus a daily cron.

Transitions:

Event Action
invoice.payment_failed set subscription_status = 'past_due', set grace_period_ends = now + 7d. Send email to org_owner + org_admins.
invoice.payment_succeeded while past_due clear grace_period_ends, set subscription_status = 'active'.
daily cron, state == past_due AND grace_period_ends < now enter read_only. Reset grace_period_ends = now + 30d so the read_only -> locked transition has a fresh 30-day clock and does not fire on the very next cron run. Send email.
customer.subscription.deleted (explicit cancel) move to read_only immediately; set grace_period_ends = now + 30d.
daily cron, state == read_only AND grace_period_ends < now move to locked. Set locked_at = now.
checkout.session.completed while in any non-active state clear grace_period_ends and locked_at, set active.

Behavior per state:

State Devices play content Dashboard read Dashboard write New device pairing Stripe portal
active yes yes yes yes yes
past_due yes yes yes yes yes (banner: "payment failed, update card by ")
read_only yes (devices keep playing what they already have) yes no (locked banner, all write routes return 423) no yes
locked no (devices receive empty playlist, fall back to a "subscription expired" splash card with org-owner email) yes (so org_owner can see what they have) no no yes

Why this shape:

  • Resellers can't tolerate "we missed a payment and 80 displays went black at 2am." Devices keep playing in read_only.
  • 7-day grace covers most payment-method-update lag.
  • 30-day grace on explicit cancel matches stripe-customer-portal cancel-at-period-end semantics.
  • locked is the only state where devices visibly degrade. By then we've sent 4+ notifications across ~37 days.

Recovery from any state by paying invoice or re-subscribing is automatic via webhook.

Player and write-path mechanism in read_only

The read_only state is implemented by two surgical changes, neither of which touches what's already on the displays:

  1. Existing playlist delivery keeps working. The device sync path (buildPlaylistPayload, the device:playlist-update socket emission, and GET /api/provision/sync) ignore org subscription state entirely. They read whatever is already assigned to the device's workspace and return it as today. Devices keep receiving the same content, schedules, layouts, and playlists they had at the moment the org entered read_only. Reconnects, screenshot push, telemetry heartbeat: all unchanged.
  2. Write routes are blocked at the middleware level. A new requireWritableOrg middleware runs on every mutating route (POST/PUT/PATCH/DELETE that creates or edits workspace-scoped resources). It looks up the caller's org subscription state. If state is read_only or locked, it returns 423 Locked with a body explaining which org and how to recover (link to Stripe portal). GET routes are unaffected.

Blocked routes in read_only (non-exhaustive): /api/devices (POST/PUT/DELETE), /api/provision/pair, /api/content (upload, edit, delete, folder ops), /api/playlists (create/update/publish/items), /api/schedules (any write), /api/layouts (write), /api/widgets (write), /api/video-walls (any write), /api/device-groups (any write), /api/teams//api/workspaces member changes other than the org_owner removing themselves.

Routes that stay open in read_only: all GETs, Stripe billing portal/checkout (so the customer can pay and recover), /api/auth/* (login, switch-workspace, logout), /api/orgs/:id/usage (visibility), /api/activity (visibility), platform_admin endpoints.

In locked, the same write-routes stay blocked AND buildPlaylistPayload returns { assignments: [], suspended: true, message: 'Subscription expired', detail: '<org_owner email>' }. The existing "suspended" branch in the web player already renders this splash; we just wire it to org state.

Uniform application to every workspace (add D)

When an org enters read_only or locked, all of its workspaces are affected identically, regardless of billing_type. There is no special protection for internal or client_complimentary workspaces. The reseller's payment problem affects every workspace under them. This is intentional: the platform has exactly one billable customer (the org_owner), and managing client expectations during a payment lapse is the reseller's responsibility, not the platform's.

8.4 Free tier

Free tier = plans.id = 'free', max_devices = 1. Behaves identically to a paid plan that happens to have a low cap. Trial-expiry behavior in deviceSocket.js already exists and stays; it now keys off org state instead of user state.

9. Per-workspace usage rollup (add B)

Read-only visibility, no enforcement.

GET /api/orgs/:id/usage returns:

{
  "organization_id": "org_abc",
  "plan_id": "pro",
  "max_devices": 100,
  "total_devices": 95,
  "subscription_status": "active",
  "workspaces": [
    { "workspace_id": "ws_acme", "name": "AcmeClient",   "device_count": 80, "online": 78, "offline": 2, "billing_type": "client_billable" },
    { "workspace_id": "ws_foo",  "name": "FooClient",    "device_count": 15, "online": 15, "offline": 0, "billing_type": "client_complimentary" },
    { "workspace_id": "ws_demo", "name": "Sales Demo",   "device_count": 2,  "online": 2,  "offline": 0, "billing_type": "internal" }
  ]
}

billing_type is included so the reseller can see their mix at a glance (paying clients vs comped vs internal use) without opening each workspace. The org_owner UI may use it for a stacked summary (e.g. "92 client_billable, 15 client_complimentary, 2 internal of 100 cap").

UI: in the org_owner / org_admin org-settings view, a stacked horizontal bar shows each workspace's slice of the org's cap, plus a row table with raw counts. Click a workspace name to switch into it (acting-as). No allocation UI - resellers eyeball the bar and add devices wherever they want.

workspace_admin and below cannot call this endpoint (their org_id doesn't resolve, returns 403).

10. Device pairing while acting-as (add C)

Pairing flow is workspace-scoped: a paired device's workspace_id is whatever workspace the user is currently in at the moment of confirmation.

10.1 Reseller acting inside a client workspace

  1. The acting-as ribbon is showing (Acting as workspace: Acme).
  2. Reseller clicks "Add display" on the dashboard.
  3. The "Pair Display" modal opens. Top of modal:
    New display will be added to: Acme  (you are acting as this workspace)
    
    with a button Change target workspace that opens a workspace dropdown limited to workspaces of the current org (resellers cannot pair a device into a workspace outside their org).
  4. Reseller enters pairing code, clicks "Pair".
  5. Device row is inserted with:
    • workspace_id = ws_acme (the acting-as workspace, or the target from step 3 if changed)
    • user_id = reseller.id (created_by record)
    • team_id = ws_acme (legacy column for compatibility shim)
  6. Org-wide device count enforcement runs (section 8.2). If over cap, return 402 BEFORE inserting the row.
  7. Activity log: acting_user_id = reseller, workspace_id = ws_acme, action = 'device.paired', was_acting_as = true.

10.2 Reseller NOT acting-as (in their own context)

Two sub-cases. We pick one for V1.

V1 default: force a workspace pick at pairing time.

When org_owner / org_admin is in their org-level context (no specific workspace selected, e.g. on the org settings page), the "Add display" CTA is disabled with a tooltip Enter a workspace first to pair a device. They cannot pair from the org settings page.

When they are in their personal default workspace (which is just one of the org's workspaces), pairing works as in 10.1 with that workspace as the target.

Why force the pick rather than land in personal default:

  • Resellers consistently report: "I paired five devices into the wrong workspace because I forgot to switch first." Forcing the explicit choice prevents this footgun.
  • Personal-default workspace concept is fragile for resellers who have no personal use case (they only manage clients).

Alternative (rejected for V1): Allow pairing from org-level context and require a workspace selector inside the pairing modal. Adds an extra step for every single-workspace customer (the majority of self-hosted users). Reconsidered if real-world feedback contradicts.

10.3 Workspace_admin / editor / viewer

Pairing target is always the workspace they're in. No selector shown. Their session has exactly one workspace; the modal just says New display will be added to: <workspace name>.

11. Self-hosted bootstrap

On a fresh self-hosted install (SELF_HOSTED=true, empty database):

  1. First registrant becomes users.role = 'platform_admin'.
  2. Same registrant becomes the org_owner of an auto-created organization named <name>'s organization.
  3. Same registrant becomes workspace_admin of an auto-created workspace named Default.
  4. plans.id = 'enterprise' is force-assigned to the org with max_devices = 999999. No Stripe lookup.

Subsequent registrants when DISABLE_REGISTRATION=false:

  • Lands as users.role = 'user', no org or workspace memberships.
  • The platform_admin must invite them to a workspace (or grant org_admin).
  • Frontend shows "No workspace yet. Ask your administrator for access."

When DISABLE_REGISTRATION=true: registration is closed at the route level. Bootstrap user is the only auto-created identity; others must arrive via invite.

Self-hosted instances may create multiple organizations. The platform_admin UI exposes a "create new organization" button. No Stripe involvement.

12. Socket.IO scoping

  • Device sockets (/device): unchanged. They join the device_id room as today.
  • Dashboard sockets (/dashboard): join ws:<current_workspace_id> instead of an implicit per-user room.
    • When the user switches workspace, the socket leaves the old room and joins the new one. Frontend emits dashboard:switch-workspace with the new id; server validates membership/acting-as and updates rooms.
  • Server emits dashboard:device-status, dashboard:screenshot-ready, dashboard:playback-progress, dashboard:wall-changed to ws:<workspace_id> of the affected resource, not globally.
  • The existing audience filter (every dashboard reloads after dashboard:wall-changed and re-fetches via the access-controlled GET) means even if a stray broadcast reaches a wrong workspace, the GET would 403; for V1 we tighten the broadcast at emit time anyway.

13. Phase-by-phase rollout

Phase 0 - design (THIS DOC). Done on approval.

Phase 1 - database and migration

  • Add the four new tables.
  • Add workspace_id / organization_id columns on existing tables.
  • Backfill: every existing user becomes an org + workspace(s) per section 4.
  • Snapshot pre-migration DB before any ALTER.
  • Validation script: row-count parity per user before vs after.
  • No route changes yet. Frontend unchanged. Existing logins still work because middleware reads team_id as before in V0 paths.
  • Gate: visual test - log in as three different existing users, see exactly the same dashboard as before migration.

Phase 2 - backend permissions and scoping

  • Org and workspace models in server/models/ (or wherever the repo wants them).
  • Auth middleware resolves current_workspace_id. JWT gets current_workspace_id. /api/auth/me returns memberships.
  • /api/auth/switch-workspace endpoint.
  • Permission helpers (can() per section 2.5).
  • Every existing route: replace user_id filter with workspace_id filter. Keep user_id writes as created_by.
  • Socket.IO room scoping (section 12).
  • Gate: regression test of every route under the new scoping. Existing client unchanged, all functionality works.

Phase 3 - frontend

  • Workspace picker view at #/select-workspace.
  • Header workspace indicator + dropdown.
  • Acting-as ribbon.
  • Org settings page with: members, workspaces list, branding defaults, usage rollup (add B). Rollup table includes a billing_type column.
  • Workspace settings page: members, branding override, delete-workspace (org_owner only).
  • Workspace settings "Billing (reseller use)" section (add D), visible only to org_owner and org_admin:
    • billing_type dropdown (client_billable / client_complimentary / internal)
    • billing_notes textarea
    • billing_contact_email field
    • billing_contract_ref field
    • Help text: "This information is for your own records. ScreenTinker does not bill or contact clients - that is between you and them."
    • The whole section is gated server-side and hidden client-side from workspace_admin and below.
  • Updated pairing modal per section 10 (target workspace banner / selector).

Phase 4 - billing

  • Move Stripe customer/subscription writes to the org row.
  • Device-count enforcement at pair time queries the org rollup.
  • Webhook handlers update the org's lifecycle state machine (section 8.3).
  • read_only and locked banners on dashboard chrome.
  • Daily cron job for grace-period expiry transitions.

Phase 5 - self-hosted validation

  • Fresh SELF_HOSTED=true install on a clean SQLite DB.
  • First registrant becomes platform_admin + org_owner + workspace_admin.
  • DISABLE_REGISTRATION=true still works.
  • Multi-org creation works (platform_admin can spin up multiple orgs for separate resellers).
  • Stripe routes return { enabled: false } and the billing UI hides.

14. Decisions deferred to V2

  • Subdomain-per-workspace (client.screentinker.com) and per-workspace custom domain via CNAME. Requires nginx automation + cert lifecycle (likely a sidecar like caddy or acme.sh integration).
  • Per-workspace device-count caps (allocation). V1 shows the rollup view (add B); allocation UI follows.
  • Per-client invoicing reports (add D): per-workspace soft caps combined with billing_type metadata enables a future "invoicing CSV" - V2 could render, for each client_billable workspace, a device-month consumption summary the reseller can import into their own invoicing system. Purely a reseller convenience; no money flows through ScreenTinker. Flagged here, deferred.
  • Path-versioned /api/workspaces/:wid/... form with 308 redirects from legacy paths.
  • Drop the now-unused users.plan_id, users.stripe_*, users.subscription_* columns. Stay nullable in V1, drop in V2.
  • Drop the team_id compatibility column on resource tables.
  • Nested teams inside a workspace. Not asked for. Don't add without a concrete request.
  • "Transfer workspace between organizations" - rare; defer until requested.

15. Open questions still on the table

None blocking Phase 1. The following are nice-to-have clarifications you can answer at any time before Phase 3:

  • Default workspace name format: current proposal is Default. Resellers might prefer <client name> only with no Default workspace at all. We can confirm during Phase 3 when the workspace-create UX lands.
  • Email notifications for invites: today's team invite email template gets reused for both org-member and workspace-member invites with subject lines that distinguish them. Confirm copy in Phase 3.
  • Activity log retention: currently unlimited. With orgs, do we want a per-org retention cap (90 days default, configurable on enterprise)? Defer to V2.

End of design doc.