A workspace is Apograph’s tenancy boundary. Every content row, every media asset and every membership belongs to exactly one, and a request that does not name one cannot reach any of them.
Install
npm install @apograph/workspaces-server @apograph/workspaces-adminRegister
// apps/server/src/plugins.ts
import { WorkspacesPlugin } from '@apograph/workspaces-server';
WorkspacesPlugin();// apps/admin/src/plugins.ts
import { WorkspacesPlugin } from '@apograph/workspaces-admin';
WorkspacesPlugin();It takes no configuration and reads no environment variables.
It must follow IdentityPlugin
Its memberships table references identity’s users. Registered first, a
fresh apograph migrate fails with relation "users" does not exist — while an
already-migrated database migrates happily, so the mistake ships and bites the
next clean install.
What it owns
Workspaces, their memberships, and their content-access grants. A grant is what lets a workspace see a subset of the content types the app defines, rather than all of them.
The guard other plugins carry
WorkspaceGuard reads the X-Workspace-Id header, resolves it to a workspace
the caller is a member of, and exposes it to handlers as @CurrentWorkspace().
Media, content, transfer and the copilot all carry it. It is the single
implementation of “is this person allowed in this tenant”, which is why it lives
here and is imported rather than reimplemented.
One route deliberately does not carry it
GET /media/assets/:id/raw is fetched by the browser itself — an <img src>
cannot send a custom header — so it derives the workspace from the asset and
checks membership against that. Any new route whose URL the browser loads
directly needs the same treatment. See the
media plugin.
Deleting a workspace
Three mechanisms clear a workspace’s rows, and which one applies is a property of what the rows are.
| Mechanism | Applies to |
|---|---|
| Cascade | memberships, workspace_content and the copilot’s tables — they carry a foreign key to workspaces, so Postgres removes them |
| Refuse | Content entries. They are workspace-scoped by a plain uuid with no foreign key, and they are authored records |
| Purge | Media assets and folders, and a token’s workspace bucket — pure scoping with no independent meaning |
A workspace holding entries cannot be deleted at all. The request is
refused with a 409 while any remain, rather than taking the content with it.
Somebody deletes their content deliberately; the CMS never does it for them. So
“delete this workspace” is a two-step job on a workspace that was ever used.
The purge runs in the same transaction as the delete. The blob half compares
each asset’s recorded storage_provider against the one this process has
configured, and refuses rather than silently skipping rows it cannot reach.
Four tables outlive the workspace
WorkspacePurger is implemented by the media plugin and by the API-token
grants, and by nothing else. Rows that are workspace-scoped by a plain uuid in
a plugin without a purger are left behind:
alarm_rulesandalarm_findings— alarmsentry_access— segmentssegments.workspace_ids— the deleted id stays in the array
None of them is reachable afterwards: every read is scoped to a workspace the caller is a member of, and the workspace is gone. They are orphaned rows rather than a leak. But they are rows, and nothing prunes them — clean them up in SQL if you delete workspaces routinely.
A plugin that owns workspace-scoped tables should implement WorkspacePurger
and register it. See writing a plugin.