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 answer | Why |
|---|---|
400 | Header missing or not a uuid |
403 | Not 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" }
]
}
}'| Route | Needs | Body / notes |
|---|---|---|
GET /api/workspaces | workspaces:read | Each 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/workspaces | workspaces:create | name, slug (a-z0-9-, immutable), description, color, members[], content |
PATCH /api/workspaces/:id | workspaces:update + member | { name?, description?, color?, isShared? } |
POST /api/workspaces/:id/archive, …/unarchive | workspaces:update + member | Stamps or clears archived_at |
GET /api/workspaces/:id/archive-impact | workspaces:update + member | { records, members, linkedWorkspaces } — what archiving touches. Since 0.13.0 |
POST /api/workspaces/:id/members | workspaces:update + member | { userId } |
DELETE /api/workspaces/:id/members/:userId | workspaces:update + member | |
POST /api/workspaces/:id/content | workspaces:update + member | { slug, sourceWorkspaceId? } — grant a type |
DELETE /api/workspaces/:id/content/:slug[?source=<id>] | workspaces:update + member | Revoke. 409 while the type still has entries (own grant only) |
GET /api/workspaces/:id/shared-sources | workspaces:update + member | Shared workspaces this one can take grants from |
DELETE /api/workspaces/:id | workspaces:delete + member | 409 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:
| Surface | Behaviour |
|---|---|
| Public REST / GraphQL list and read | The union of the type’s visible sources. No source on the wire |
| Relations | Own 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 UI | Shared records open read-only, Save hidden |
MCP content_list / content_get | source argument; foreign items carry source and readOnly: true |
| Any write to a shared record | 404 over REST (write paths stay own-workspace); a 403 tool error over MCP |
| Creating a type held only through a shared grant | 403 This workspace can only use "Tags" records from shared workspaces; it cannot create its own. |
| Segments | A 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.
| Mechanism | Applies to |
|---|---|
| Cascade (FK) | memberships, workspace_content (shared grants naming it too), saved views, copilot tables |
| Refuse | Content 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
workspaceIdskeeps 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.