Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Workspaces plugin

The tenancy boundary, and the guard the rest of the system borrows from it.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A workspace is the tenancy boundary. Every content row, media asset and membership belongs to exactly one. A request that names no workspace reaches none of them.

Install and register

npm install @orthacms/workspaces-server @orthacms/workspaces-admin
// apps/server/src/plugins.ts — AFTER IdentityPlugin
import { WorkspacesPlugin } from '@orthacms/workspaces-server';

WorkspacesPlugin();
// apps/admin/src/plugins.ts
import { WorkspacesPlugin } from '@orthacms/workspaces-admin';

WorkspacesPlugin();

No configuration, no environment variables.

The admin half draws the Workspaces page (a grid of cards), the workspace shell and its settings. Since 0.12.0 the settings page is a side nav — General, Content types, Members, any contributed section, then Danger zone — beside one column. A plugin adds a section through WORKSPACE_SETTINGS_TAB_SLOT; the page draws every section’s <h1>, so a contribution passes its one-line description as descriptionId / defaultDescription and renders only its body. See slots.

Since 0.13.0 a card says what is inside: “Edited {when}” (or “Archived {when}”), “N records · N content types”, and the members as faces. The toolbar searches, filters All / Active / Archived and sorts by Recently edited (the default), Name or Recently created. New workspace is three steps — Basics, Content, People — with a preview of the card beside them, and ends on a ready page at /workspaces/:id/ready with Next steps. People invited by email on the People step are sent their invite when you hold users:create; otherwise they wait as pending in Members. Archive… (on a card’s ⋯ menu, or in the Danger zone) first says what stays — records, members — and how many workspaces linking its shared records will see them as unavailable; an archived card offers Restore.

A WORKSPACE_SECTION_SLOT item may declare takeover(pathname): where it returns true, that section is the sidebar’s only content. The copilot’s Agents view uses it to show its chats in place of the content tree.

It must follow IdentityPlugin

memberships references identity’s users. Registered first, a fresh orthacms migrate fails with relation "users" does not exist — while an existing database migrates fine, so the mistake ships.

Naming the workspace on a request

Every workspace-scoped route reads X-Workspace-Id:

# admin API (session): the caller must be a member
curl -b cookies.txt https://cms.example.com/api/content/article \
  -H 'X-Workspace-Id: 6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40'

# public API (token): required only when the token covers several workspaces
curl https://cms.example.com/api/v1/content/article \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'X-Workspace-Id: 6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40'
Admin API answerWhy
400Header missing or not a uuid
403Not a member — same answer as a workspace that does not exist

The id is copyable from the workspace’s Settings → General.

WorkspaceGuard (session) and ApiTokenWorkspaceGuard (token) are the only implementations. Content, media, localization, transfer, alarms, segments, protection, activity and the copilot all import them. A plugin reads the resolved id with @CurrentWorkspace().

One route derives the workspace instead

GET /api/media/assets/:id/raw is loaded by <img src>, which cannot send a header. It takes the workspace from the asset and checks membership against it. Any new route a browser loads directly needs the same treatment. See the media plugin.

Managing workspaces

Admin API, session-authenticated. Writes are Origin-checked.

curl -b cookies.txt https://cms.example.com/api/workspaces \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "Marketing site",
        "slug": "marketing-site",
        "description": "",
        "color": "slate",
        "members": [],
        "content": {
          "mode": "specific",
          "collections": { "mode": "specific", "ids": ["article", "author"] },
          "sharedContent": [
            { "slug": "tag", "sourceWorkspaceId": "a41c7e02-5d9b-4f38-8e6a-0b2f9c1d7e45" }
          ]
        }
      }'
RouteNeedsBody / notes
GET /api/workspacesworkspaces:readEach item carries recordCount, recordCountByType, createdAt, lastEditedAt, archivedAt, and each member its directory status (since 0.13.0)
GET /api/workspaces/slug-available?slug=workspaces:create{ available }
POST /api/workspacesworkspaces:createname, slug (a-z0-9-, immutable), description, color, members[], content
PATCH /api/workspaces/:idworkspaces:update + member{ name?, description?, color?, isShared? }
POST /api/workspaces/:id/archive, …/unarchiveworkspaces:update + memberStamps or clears archived_at
GET /api/workspaces/:id/archive-impactworkspaces:update + member{ records, members, linkedWorkspaces } — what archiving touches. Since 0.13.0
POST /api/workspaces/:id/membersworkspaces:update + member{ userId }
DELETE /api/workspaces/:id/members/:userIdworkspaces:update + member
POST /api/workspaces/:id/contentworkspaces:update + member{ slug, sourceWorkspaceId? } — grant a type
DELETE /api/workspaces/:id/content/:slug[?source=<id>]workspaces:update + memberRevoke. 409 while the type still has entries (own grant only)
GET /api/workspaces/:id/shared-sourcesworkspaces:update + memberShared workspaces this one can take grants from
DELETE /api/workspaces/:idworkspaces:delete + member409 while it holds entries

A grant (workspace_content) is what lets a workspace use a content type. content.mode: "all" grants every own type; it never adds shared grants.

The record counts and lastEditedAt come from the CONTENT_ENTRY_STATS port, which content binds. Unbound, it answers nothing and every card reads “0 records” — which is why no destructive check reads it: delete and revoke keep their own entry counter. linkedWorkspaces counts other workspaces holding a live shared grant from this one, and names none of them. Archiving still deletes nothing and blocks no route on the workspace itself; migration 0003_workspace_archived_at (0.13.0) adds the column, backfilled from updated_at.

Shared workspaces

Since 0.7.0 a workspace can be shared: its published records become readable and linkable — never writable — from other workspaces that choose to take them. Typical use: a library of authors, tags or legal copy.

Sharing is opt-in on both sides.

1. The source shares itself (admin: Settings → General, the Shared workspace switch under Sharing):

curl -b cookies.txt -X PATCH https://cms.example.com/api/workspaces/a41c7e02-5d9b-4f38-8e6a-0b2f9c1d7e45 \
  -H 'Content-Type: application/json' \
  -d '{"isShared":true}'

2. The consumer takes a shared grant, per type and per source:

# which sources and types are on offer
curl -b cookies.txt https://cms.example.com/api/workspaces/6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40/shared-sources
# → { "items": [ { "workspaceId": "a41c…", "workspaceName": "Travel Library",
#                  "content": [ { "slug": "tag", "kind": "collection" } ] } ] }

# take "Tags · Travel Library"
curl -b cookies.txt https://cms.example.com/api/workspaces/6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40/content \
  -H 'Content-Type: application/json' \
  -d '{"slug":"tag","sourceWorkspaceId":"a41c7e02-5d9b-4f38-8e6a-0b2f9c1d7e45"}'

# give it back (no entry count: a shared grant owns no records)
curl -b cookies.txt -X DELETE \
  'https://cms.example.com/api/workspaces/6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40/content/tag?source=a41c7e02-5d9b-4f38-8e6a-0b2f9c1d7e45'

The workspace view (trimmed) reports both kinds of grant:

{
    "id": "6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40",
    "isShared": false,
    "content": ["article", "author"],
    "sharedContent": [
        {
            "slug": "tag",
            "kind": "collection",
            "sourceWorkspaceId": "a41c7e02-5d9b-4f38-8e6a-0b2f9c1d7e45",
            "sourceWorkspaceName": "Travel Library",
            "available": true
        }
    ]
}

The rules:

  • Own grant (content) = may author records of the type. Shared grant (sharedContent) = may read and link one source’s published records. A workspace can hold either, both, or several shared grants of one type.
  • A shared grant needs a source that is shared, active, not itself, and holds its own grant for the type. Otherwise one uniform 422.
  • Source unshared, archived or stops granting the type → the grant stays, reads available: false, and exposes nothing.
  • Only published, non-deleted records are visible. Drafts never leave the source.
  • Not transitive: a source exposes its own records, never what it can see.
  • Membership is not shared: consumers get no access to the source’s settings.

What consumers see:

SurfaceBehaviour
Public REST / GraphQL list and readThe union of the type’s visible sources. No source on the wire
RelationsOwn records may link to visible shared records. A link to a record that stops being visible is hidden, not deleted
Admin list /api/content/:type?source=own|shared|all (default own), ?sourceWorkspaceId= to narrow; items carry source
Admin UIShared records open read-only, Save hidden
MCP content_list / content_getsource argument; foreign items carry source and readOnly: true
Any write to a shared record404 over REST (write paths stay own-workspace); a 403 tool error over MCP
Creating a type held only through a shared grant403 This workspace can only use "Tags" records from shared workspaces; it cannot create its own.
SegmentsA consumer’s reader tags still apply to shared records

Before unpublishing a library record, the source can check who links to it:

curl -b cookies.txt https://cms.example.com/api/content/tag/9e2b4d71-6c3a-4f85-b0d7-1a8e5c2f9b36/usages \
  -H 'X-Workspace-Id: a41c7e02-5d9b-4f38-8e6a-0b2f9c1d7e45'
# → { "items": [ { "workspaceId": "6b1f…", "workspaceName": "Marketing site", "count": 12 } ] }

Upgrading from 0.6: a migration writes a shared grant for every pairing a workspace could already see, so nothing changes until someone edits grants.

Deleting a workspace

A workspace holding entries cannot be deleted (409). Delete the content first; the CMS never does it for you. The admin’s Settings → Danger zone says how many entries still block it.

MechanismApplies to
Cascade (FK)memberships, workspace_content (shared grants naming it too), saved views, copilot tables
RefuseContent entries
Purge (same transaction)Media assets and folders, token workspace buckets, entry revisions, segment access rows, alarm rules and findings, protection rules, requests and approvals, webhook subscriptions
  • A failed purge rolls the delete back.
  • Media blobs are removed after commit, best-effort; failures are logged.
  • A segment’s workspaceIds keeps the deleted id — harmless, it only narrows where the segment is offered.

A plugin with workspace-scoped tables and no foreign key must implement WorkspacePurger and register it with WorkspacePurgeRegistry, or its rows outlive the workspace. See writing a plugin.