Skip to content

Workspaces: a team level above sites, so a teammate sees every site #10

Description

@JonathanCarthagos

The problem

Access is granted per site only (site_members: owner / admin / viewer). We run a self-hosted instance for an agency and create a site per client. Every teammate therefore has to be invited to each site, one by one, and invited again whenever a site is created. A teammate who signs in before being invited lands on onboarding ("create your first site") instead of the agency's sites. Separately, clients should keep seeing only their own site, which the per-site model already handles well.

Nothing is wrong with sign-in itself (Better Auth, magic link / OAuth). What is missing is a level above the site.

Proposed solution

A workspace: a named group of sites with its own members. We would like to build it and send it as PRs, and want to agree on the shape first.

  • Schema (one expand-first migration):
    • workspaces;
    • workspace_members (workspace_id, user_id, role), with the same owner | admin | viewer roles;
    • workspace_invites, which mirrors site_invites: token hash only, 7-day TTL, one pending invite per address;
    • a nullable sites.workspace_id, so existing sites are untouched until someone moves them;
    • a deferred trigger so that every workspace keeps at least one owner, in the style of 0006_ownership_invariants.
  • Effective role on a site = the higher of the direct site_members role and the role in the site's workspace. The capability matrix in packages/domain/src/authz.ts is unchanged. The change sits in the read functions of repositories/sites.ts (getMembership, listSitesForUser, getSiteForUser, resolveSlugForUser, getSiteReportingTimezone). The session API, the read API / MCP, realtime token minting and the OAuth consent pages all inherit it without changes of their own.
  • Billing owner / owner of record stays a direct site owner. The OA001/OA002 invariants are untouched, and a workspace role never makes someone sites.owner_user_id.
  • Leaving a workspace revokes, on every site of that workspace where the member has no direct membership, what removeMember revokes today: revenue credentials, API-key departure and the realtime epoch. This would be one shared helper.
  • API (OpenAPI first):
    • /v1/workspaces, plus /members and /invites under it;
    • POST /v1/workspace-invites/accept, with the same email-match rule as site invites;
    • workspace_id on POST /v1/sites and PATCH /v1/sites/{id};
    • two optional fields on SiteSummary: workspace_id and access: direct | workspace;
    • a new workspace_invite email kind.
  • Dashboard:
    • a Workspace tab under Account;
    • a workspace picker when creating a site;
    • "move to workspace" in site settings;
    • inherited members shown read-only in a site's Team section.
  • Scope: invite-only in v1, with no email-domain auto-join and nothing billing-related. This is a product-layer feature and adds none of the commercial tables schema:product forbids. By RELEASING.md it would ship in a minor release.

Suggested PR order: schema + repository (migration tests first) → API + contract → dashboard → docs.

Alternatives considered

  • Keep inviting per site. It works, but it has to be repeated for every new site and every new teammate.
  • Better Auth's organization plugin. It brings its own tables and endpoints outside the OpenAPI contract, plus a second role model beside authz.ts. Own tables keep one contract and one capability matrix.
  • Replacing auth with an external backend. It is unnecessary, because sign-in is not the problem, and it would add a hosted dependency to the self-host.

Notes

Questions before we start:

  1. Would you take this as a contribution, and does the shape fit?
  2. Is migration 0047 free, or is something already in flight on your side?
  3. Should the effective-role union live in SQL (join / GREATEST over a role rank) or in packages/auth?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions