Teams and Workspaces
User data in CrabStack belongs to a workspace, not to a user. Every user
has a personal workspace from the moment they sign up, and can create
shared workspaces and invite people into them. The sample resource,
items, is workspace-scoped, and so is every resource the new-module
skill scaffolds.
The model
Three tables, from backend/migrations/018_workspaces.ts:
workspaces:nameandis_personal.workspace_members: one row per user per workspace, with aroleofowner,adminormember. A partial unique index allows one owner per workspace, so "exactly one owner" holds in the database, not only in the code.workspace_invites: pending invites, one per workspace and address.
A resource table carries workspace_id (the workspace it belongs to,
ON DELETE CASCADE) and keeps a created_by for its author. items is
the worked example.
The personal workspace is created with the user, in the same
transaction (createPersonalWorkspace in workspaces.service.ts, called
by AuthAdapterService.createUser and the seed). It is listed first, and
it is solo: it cannot be deleted, cannot change owner and cannot take
invites. That keeps one guarantee simple everywhere: every user always has
a workspace to land in, so the frontend never has a "no workspace" state.
Users who need to work together create a shared workspace.
Roles
| Member | Admin | Owner | |
|---|---|---|---|
| Read and write the workspace's items | yes | yes | yes |
| See the member list | yes | yes | yes |
| Leave | yes | yes | no: transfer or delete first |
| Rename the workspace | yes | yes | |
| Invite, list and revoke invites | yes | yes | |
| Change an admin's or member's role, remove them | yes | yes | |
| Touch the owner's membership | no | ||
| Transfer ownership | yes (they become an admin) | ||
| Delete the workspace | yes, except the personal one | ||
| See the API keys | yes | yes | yes |
| Create and revoke API keys | yes | yes |
The roles are enforced by route guards, and every route states its
minimum role in POLICY in backend/src/common/route-auth.spec.ts.
How a request is checked
Workspace routes put the workspace in the path:
/workspaces/:workspaceId/items. After SessionGuard, one of
WorkspaceMemberGuard, WorkspaceAdminGuard or WorkspaceOwnerGuard
(backend/src/workspaces/workspace.guard.ts) looks up the caller's
membership:
- Not a member, or no such workspace: 404. The two answer the same, so workspace ids cannot be probed. The backoffice hides itself the same way.
- A member below the route's role: 403. They already know the workspace exists.
- Otherwise the guard puts the workspace and the caller's role on the
request, and
@CurrentMembership()hands them to the handler.
The guard proves the caller belongs to the workspace in the path. It does
not prove that the item id in the path is in that workspace, so the
service puts the workspace in every query's WHERE clause:
where({ id, workspace_id }). A member of workspace A who sends B's item
id under A's path matches nothing and gets 404.
src/workspaces/workspace-isolation.http.spec.ts proves this over real
HTTP, every way a route allows.
The write rule
The guard runs before the handler, so a transfer, a demotion or a removal
can commit in between. A write rechecks the caller's role only when an
invariant depends on it holding until the commit: deleting the
workspace, transferring it, leaving, changing a role, removing a member,
sending an invite, and creating an item (the free-plan cap). Those start
their transaction with lockWorkspaceRole(trx, workspaceId, userId, minRole, viaApiKey) from workspace.guard.ts, which locks the workspace row and
checks the role again with the guard's answers (404 for a non-member, 403
below the role). Every write that changes a role or a membership is in
that list, so the role read under the lock holds until the commit. Every
other write, such as renaming the workspace or editing and deleting an
item, is authorized when the request starts, like any other request, and
takes no lock. A new module follows the same rule.
API keys
A workspace's API keys let a script or a server call its item routes without a browser session. Personal workspaces have them too.
Creating one. An admin or the owner opens Settings, names the key
under API keys and creates it. The key, cs_<prefix>_<secret>, is shown
once with a copy button. The backend stores only its SHA-256, which a
request is looked up by, and the prefix for the list, so it cannot show
the key again. A workspace holds at most 25 keys
(API_KEYS_PER_WORKSPACE in api-keys.service.ts); the next create
answers 409. Every member sees the list: name, prefix, creator, created
and last used.
Calling the API. Send the key as a bearer token:
curl -H "Authorization: Bearer $CRABSTACK_KEY" \
"$BACKEND_URL/workspaces/$WORKSPACE_ID/items"
curl -X POST -H "Authorization: Bearer $CRABSTACK_KEY" \
-H 'Content-Type: application/json' -d '{"name":"From CI"}' \
"$BACKEND_URL/workspaces/$WORKSPACE_ID/items"
What a key can do. It acts as the user who created it, with the member role whatever role they hold, in its own workspace only:
- It reads and writes that workspace's items. An item it creates has the
key's creator as
created_byand counts against the workspace owner's plan like any other. - Another workspace's path answers 404, as to a non-member, even one its creator belongs to.
- Every other route refuses it with 403: the workspace itself, members, invites, keys, billing, the backoffice, account deletion and the user's own routes.
A route takes keys only when its controller uses
WorkspaceMemberOrApiKeyGuard
(backend/src/api-keys/workspace-member-or-api-key.guard.ts) in place of
SessionGuard, WorkspaceMemberGuard, and POLICY in
route-auth.spec.ts states it as MEMBER_OR_API_KEY, which that spec
allows in no other combination. Today that is ItemsController alone.
SessionGuard refuses anything shaped like a key, so no other route
accepts one. A write a key reaches passes the guard's viaApiKey to
lockWorkspaceRole, which holds the key to the member role under the
lock too.
When a key stops working. A key belongs to the membership it was
created under: api_keys (workspace_id, created_by) references
workspace_members with ON DELETE CASCADE. The database deletes it,
and its next request answers 401, when:
- an admin or the owner revokes it in Settings;
- its creator leaves or is removed from the workspace, so rejoining does not bring it back;
- its creator deletes their account;
- the workspace is deleted.
last_used_at moves at most once a minute per key, so a busy key does
not write on every request. Key requests share the global rate limit,
counted per client address like every other request.
Billing
A workspace's plan is its owner's plan. Billing stays keyed to the user:
billing_subscriptions, checkout and the Stripe webhook know nothing of
workspaces. The one place that reads a plan for workspace data, the
free-plan item cap in ItemsService.create, reads the workspace owner's
entitlement and counts the items of every workspace that owner owns, so a
free user cannot lift the cap by creating workspaces, and a free member
gets the paid limits in a paid owner's workspace. It locks the owner's
users row, so creates in any of the owner's workspaces cannot race past
the cap together. A transfer of ownership moves the workspace to the new
owner's plan at once.
Invites
An admin or the owner invites an address with a role, admin or member (the owner role only moves by transfer).
- The backend makes a random 32-byte token, stores only its SHA-256, and
queues the email (
renderInviteEmailininvite-email.ts, the magic-link email's markup) through the email queue. The link isFRONTEND_URL/invite?token=.... - An invite expires after 7 days and works once: accepting deletes it.
The hourly sweep in
session-cleanup.worker.tsdeletes expired ones. - Accepting needs a signed-in user whose email matches the invite,
ignoring case. Someone else's attempt is refused and leaves the invite
usable by its addressee.
/invitesends a signed-out visitor through sign-in and back. - Inviting an address again replaces its pending invite, so the old link stops working. Admins list pending invites and revoke them in Settings.
Send limits
INVITE_LIMITS in workspace-invites.service.ts holds them. They are
recorded by the same atomic limiter that rate-limits magic links
(MagicLinkRateLimitService.record, in magic_link_attempts), keyed by
user and address, never by workspace, so creating or deleting workspaces
resets none of them. A send that would pass one answers 429 with a plain
message, which the invite form shows, and queues no email.
| Limit | Key | Default |
|---|---|---|
| Invites one user sends in 24 hours | invite-by:<userId> |
50 |
| Gap between one sender's invites to one address | invite-to:<inviterId>:<digest> |
10 minutes |
| Invites one sender sends one address in 24 hours | the same key | 3 |
| Invites one address receives in 24 hours, from everyone | invite-to-any:<digest> |
20 |
| Invites one workspace has pending (a count of rows) | 50 |
<digest> is the SHA-256 hex of the normalized address
(src/rate-limit/invite-limit-keys.ts), so the keys hold no cleartext
address and cannot collide with a sign-in key: the magic-link check
refuses any address without an @. Account deletion deletes the keys
naming the user's id or address.
The global per-address cap is high on purpose: an attacker can use it up for an address, and it has to stay above what real teams send. The sends are recorded in the invite's own transaction, so a send refused for any reason counts for nothing, and the limiter's row lock serializes concurrent sends.
The frontend
The active workspace is the httpOnly workspace cookie, which the
switcher in components/WorkspaceSwitcher.tsx (rendered by AppHeader)
sets through
switchWorkspaceAction. getWorkspaceContext() in
lib/workspace-server.ts returns the user's workspaces and the active
one, falling back to the personal workspace when the cookie is missing or
names a workspace the user has left. The cookie only chooses among the
user's own workspaces: the backend checks membership on every request.
Settings shows the active workspace's name, members and roles, pending
invites with the invite form, and leave or delete; the owner transfers
ownership from the member list. /items lists the active workspace's
items.
Account deletion
- Refused with a 409 while the user owns a workspace that has other members. The message names the workspaces; the user transfers ownership or removes the members first.
- Otherwise the deletion takes every workspace the user owns, with its items and members, removes the user's memberships elsewhere, and deletes invites addressed to them and the invite limits keyed by them.
- Items the user wrote in someone else's workspace stay with that
workspace, their
created_bypointing at the ghosted user row. The API keys they created there go with their memberships. - While the deletion is queued, nobody can accept an invite into a workspace the user owns, and nobody can transfer a workspace to them; both answer 409.
- The erasure checks again, with the user's owned workspaces locked in
the same order acceptance and transfer lock them (the
usersrow, thenworkspaces). If one has gained a member anyway, it erases nothing, clears the request rather than retrying it (only the user can unblock it), and emails the user to transfer ownership or remove the members and then delete their account again. That new request is checked and queued like a first one; no operator is needed.
DELETION_POLICY in backend/src/users/deletion-policy.ts states this
per table, and What Ghost Deletion Erases
has the full list.
Add a workspace-scoped resource
The new-module skill does this by default, and
Adding a Module walks through it with a
tasks table. The parts that make a resource workspace-scoped:
- Migration: a
workspace_idforeign key toworkspaceswithonDelete('CASCADE'), indexed, and acreated_byfor the author. - Controller: under
@Controller('workspaces/:workspaceId/<plural>')with@UseGuards(SessionGuard, WorkspaceMemberGuard)(or the admin or owner guard for a narrower route) and@ApiWorkspaceParam(), which documents the path parameter for the generated client. Handlers take@CurrentMembership() mand passm.workspace.idto the service. - Service: every list, update and delete filters on
workspace_idin the same statement, and returnsnullorfalsefor a row in another workspace, which the controller answers 404. A write that an invariant depends on callslockWorkspaceRolefirst in its transaction; see the write rule. POLICY:classGuards: MEMBER, or per-route entries when roles differ. Only if the workspace's API keys should call it:WorkspaceMemberOrApiKeyGuardalone, its module importingApiKeysModule, andMEMBER_OR_API_KEY.- Account deletion: in
ghostDelete, delete the rows of the workspaces the user owns, next to theitemsline, and add the table toDELETION_POLICY. - A spec that a member of one workspace gets 404 for another's rows, both by the other workspace's id and by a row id under their own path.
- Frontend: read
getWorkspaceContext()and passactive.idin the API path, asapp/items/page.tsxdoes.
A resource that is not workspace data, such as a user's own preferences,
keeps the old shape: SessionGuard only, and the user id in the WHERE.