← All @molecule/* packages · App templates
@molecule/api-resource-workspaceAPI resource · resource-workspace · API (Node) · v1.0.1 · Apache-2.0
Workspaces + members + invites + role-aware authz.
npm install @molecule/api-resource-workspace@molecule/api-resource-workspace is an API resource: the routes, validation and storage for resource-workspace, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { routes, requestHandlerMap } from '@molecule/api-resource-workspace'
// Wire routes into your Express app via mlcl inject:
// POST /workspaces
// GET /workspaces
// GET /workspaces/:id
// PATCH /workspaces/:id
// DELETE /workspaces/:id
// GET /workspaces/:id/members
// PATCH /workspaces/:id/members/:userId
// DELETE /workspaces/:id/members/:userId
// POST /workspaces/:id/invites
// GET /workspaces/:id/invites
// DELETE /workspaces/:id/invites/:inviteId
// POST /workspaces/invites/acceptAuto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Workspace resource for molecule.dev.
Ships the unified workspaces, workspace_members, workspace_invites
schema, role-aware authz helpers (owner / admin / member), and
an invite-by-email flow with single-use tokens. Replaces ad-hoc
per-app workspace tables.
import { routes, requestHandlerMap } from '@molecule/api-resource-workspace'
// Wire routes into your Express app via mlcl inject:
// POST /workspaces
// GET /workspaces
// GET /workspaces/:id
// PATCH /workspaces/:id
// DELETE /workspaces/:id
// GET /workspaces/:id/members
// PATCH /workspaces/:id/members/:userId
// DELETE /workspaces/:id/members/:userId
// POST /workspaces/:id/invites
// GET /workspaces/:id/invites
// DELETE /workspaces/:id/invites/:inviteId
// POST /workspaces/invites/accept
resource
npm install @molecule/api-resource-workspace @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource zod
CreateWorkspaceInputInput payload for creating a workspace.
interface CreateWorkspaceInput {
/** The workspace's display name. */
name: string
/** Optional URL-safe slug; auto-generated from `name` when omitted. */
slug?: string
}
PaginatedResultA paginated result set.
interface PaginatedResult<T> {
/** The result items for the current page. */
data: T[]
/** Total number of matching items across all pages. */
total: number
/** Maximum number of results per page. */
limit: number
/** Number of results skipped. */
offset: number
}
UpdateWorkspaceInputInput payload for updating a workspace.
interface UpdateWorkspaceInput {
/** Updated workspace display name. */
name?: string
/** Updated URL-safe slug. */
slug?: string
}
WorkspaceA workspace — a shared scope owned by one user with optional members.
interface Workspace {
/** Unique workspace identifier. */
id: string
/** The ID of the user who owns the workspace. */
ownerId: string
/** Human-readable workspace name. */
name: string
/** URL-safe slug for the workspace. */
slug: string
/** When the workspace was created (ISO 8601). */
createdAt: string
/** When the workspace was last updated (ISO 8601). */
updatedAt: string
/** Soft-delete timestamp; `null` for active workspaces (ISO 8601). */
deletedAt: string | null
}
WorkspaceInviteA pending email invitation to join a workspace.
interface WorkspaceInvite {
/** Unique invite identifier. */
id: string
/** The workspace the invitee will join on accept. */
workspaceId: string
/** The invitee's email address. */
email: string
/** The role the invitee will receive on accept. */
role: WorkspaceRole
/** Opaque single-use token used to accept the invite. */
token: string
/** ISO 8601 timestamp at which the invite stops being valid. */
expiresAt: string
/** When the invite was created (ISO 8601). */
createdAt: string
/** When the invite was accepted (ISO 8601), or `null` if pending. */
acceptedAt: string | null
}
WorkspaceMemberMembership row linking a user to a workspace with a role.
interface WorkspaceMember {
/** The workspace this membership belongs to. */
workspaceId: string
/** The member's user ID. */
userId: string
/** The member's role within the workspace. */
role: WorkspaceRole
/** When the user joined the workspace (ISO 8601). */
joinedAt: string
}
WorkspaceQueryQuery options for listing workspaces a user belongs to.
interface WorkspaceQuery {
/** Maximum number of results to return. */
limit?: number
/** Number of results to skip. */
offset?: number
}
WorkspaceRoleA workspace member's role within a workspace.
type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]
accept(req, res)Accepts an invite using its single-use token. The current user joins the invite's workspace with the invite's role.
function accept(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with body { token }.res — The response object.acceptInvite(token, userId)Accepts an invite and creates the membership row. Idempotent — if the user is already a member, returns the existing membership without downgrading the role.
function acceptInvite(token: string, userId: string): Promise<WorkspaceMember>
token — Single-use invite token.userId — The accepting user's id.Returns: The new (or existing) membership row.
assertCanGrantRole(callerRole, targetRole)Asserts the caller may grant targetRole. A member may never grant a role
strictly higher than their own — an admin may grant up to admin, and only
an owner may grant owner. Throws workspace.error.cannotGrantHigherRole
otherwise. Fails closed: the caller's authority is derived from callerRole,
never from the requested role.
function assertCanGrantRole(
callerRole: 'member' | 'admin' | 'owner',
targetRole: 'member' | 'admin' | 'owner',
): void
callerRole — The granting caller's own role.targetRole — The role the caller is attempting to assign.assertMember(workspaceId, userId, minRole)Asserts that userId is a member of workspaceId with at least minRole.
Throws when the user is not a member or has insufficient role. Returns the
caller's membership row so callers can authorize against the caller's own
role (e.g. to block granting a role higher than their own).
function assertMember(
workspaceId: string,
userId: string,
minRole?: 'member' | 'admin' | 'owner',
): Promise<WorkspaceMember>
workspaceId — Workspace to check membership in.userId — User whose membership to check.minRole — Minimum role required (defaults to member).Returns: The caller's membership row.
create(req, res)Creates a new workspace owned by the current user.
function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with the workspace creation body (name, optional slug).res — The response object.createWorkspace(ownerId, input)Creates a workspace and the owner's membership row.
function createWorkspace(ownerId: string, input: CreateWorkspaceInput): Promise<Workspace>
ownerId — The user creating (and owning) the workspace.input — The new workspace's name and optional slug.Returns: The created workspace.
del(req, res)Soft-deletes a workspace. Caller must be the owner.
function del(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id param.res — The response object.deleteWorkspace(id)Soft-deletes a workspace and removes all member rows.
function deleteWorkspace(id: string): Promise<void>
id — Workspace id.generateInviteToken()Generates an opaque single-use invite token.
function generateInviteToken(): string
Returns: A hex-encoded random token.
getMembership(workspaceId, userId)Looks up a single membership row.
function getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null>
workspaceId — The workspace.userId — The user.Returns: The membership, or null when the user is not a member.
getPendingInvite(token)Looks up an invite by token (pending invites only).
function getPendingInvite(token: string): Promise<WorkspaceInvite | null>
token — The opaque token issued at invite time.Returns: The pending invite, or null when missing/expired/accepted.
getWorkspace(id)Reads a single workspace by id (active rows only).
function getWorkspace(id: string): Promise<Workspace | null>
id — Workspace id.Returns: The workspace, or null when missing or soft-deleted.
invite(req, res)Creates a pending invite for a workspace. Caller must be at least an admin.
function invite(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id (workspace id) param and body { email, role? }.res — The response object.inviteMember(workspaceId, email, callerRole, role, ttlMs)Creates an invite record for an email. Idempotent on (workspace, email) pending invites — returns the existing pending invite if one exists.
function inviteMember(
workspaceId: string,
email: string,
callerRole: 'member' | 'admin' | 'owner',
role?: 'member' | 'admin' | 'owner',
ttlMs?: number,
): Promise<WorkspaceInvite>
workspaceId — The workspace to invite into.email — The invitee's email.callerRole — The inviting caller's own role (for escalation guard).role — The role to grant on accept (defaults to member).ttlMs — Override the default 7-day expiry (in milliseconds).Returns: The pending invite record.
list(req, res)Lists workspaces the current user is a member of, paginated.
function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with optional limit/offset query params.res — The response object.listAll(req, res)Lists members of a workspace. Caller must be a member.
function listAll(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id (workspace id) param.res — The response object.listInvites(req, res)Lists pending invites for a workspace. Caller must be at least an admin.
function listInvites(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id (workspace id) param.res — The response object.listMembers(workspaceId)Lists all members of a workspace.
function listMembers(workspaceId: string): Promise<WorkspaceMember[]>
workspaceId — The workspace.Returns: Array of memberships.
listPendingInvites(workspaceId)Lists pending invites for a workspace.
function listPendingInvites(workspaceId: string): Promise<WorkspaceInvite[]>
workspaceId — The workspace.Returns: Array of pending (unaccepted, unexpired) invites.
listWorkspacesForUser(userId, options)Lists workspaces the user is a member of, paginated.
function listWorkspacesForUser(
userId: string,
options?: WorkspaceQuery,
): Promise<PaginatedResult<Workspace>>
userId — The user whose workspaces to list.options — Pagination options.Returns: A paginated set of workspaces.
read(req, res)Reads a single workspace by id. Caller must be a member.
function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id param.res — The response object.remove(req, res)Removes a member from a workspace. Caller must be at least an admin (or removing themself). Refuses to remove the last owner.
function remove(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id (workspace) and :userId params.res — The response object.removeMember(workspaceId, userId)Removes a member from a workspace. Refuses to remove the last owner.
function removeMember(workspaceId: string, userId: string): Promise<void>
workspaceId — Workspace.userId — Member to remove.revoke(req, res)Revokes a pending invite. Caller must be at least an admin of the workspace the invite belongs to.
function revoke(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id (workspace) and :inviteId params.res — The response object.revokeInvite(workspaceId, inviteId)Revokes (deletes) a pending invite.
function revokeInvite(workspaceId: string, inviteId: string): Promise<void>
workspaceId — The workspace.inviteId — The invite to revoke.roleAtLeast(actual, required)Compares two roles using the canonical strength ordering
(member < admin < owner).
function roleAtLeast(
actual: 'member' | 'admin' | 'owner',
required: 'member' | 'admin' | 'owner',
): boolean
actual — The role the member actually has.required — The minimum role required.Returns: true when actual is at least as strong as required.
slugify(input)Slugify a free-form workspace name into URL-safe [a-z0-9-]+.
function slugify(input: string): string
input — Source string to slugify.Returns: Lowercased, hyphen-separated slug.
update(req, res)Updates a workspace's mutable fields. Caller must be at least an admin.
function update(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id param and patch body.res — The response object.updateMemberRole(workspaceId, userId, role, callerRole)Updates a member's role. The sole owner cannot be demoted. The caller may
not assign a role strictly higher than their own callerRole — an admin can
grant up to admin; only an owner can grant owner.
function updateMemberRole(
workspaceId: string,
userId: string,
role: 'member' | 'admin' | 'owner',
callerRole: 'member' | 'admin' | 'owner',
): Promise<WorkspaceMember>
workspaceId — Workspace.userId — Member to update.role — New role.callerRole — The acting caller's own role (for escalation guard).Returns: The updated membership.
updateRole(req, res)Updates a member's role. Caller must be at least an admin.
function updateRole(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with :id (workspace) and :userId params and body { role }.res — The response object.updateWorkspace(id, input)Updates a workspace's mutable fields.
function updateWorkspace(id: string, input: UpdateWorkspaceInput): Promise<Workspace>
id — Workspace id.input — Patch of name/slug.Returns: The updated workspace.
acceptInviteSchemaSchema for validating invite acceptance input.
const acceptInviteSchema: z.ZodObject<{ token: z.ZodString }, z.core.$strip>
createWorkspaceSchemaSchema for validating workspace creation input.
const createWorkspaceSchema: z.ZodObject<
{ name: z.ZodString; slug: z.ZodOptional<z.ZodString> },
z.core.$strip
>
inviteMemberSchemaSchema for validating member invite input.
const inviteMemberSchema: z.ZodObject<
{
email: z.ZodString
role: z.ZodDefault<z.ZodEnum<{ member: 'member'; admin: 'admin'; owner: 'owner' }>>
},
z.core.$strip
>
requestHandlerMapHandler map for workspace routes.
const requestHandlerMap: {
readonly create: typeof create
readonly list: typeof list
readonly read: typeof read
readonly update: typeof update
readonly del: typeof del
readonly listAll: typeof listAll
readonly updateRole: typeof updateRole
readonly remove: typeof remove
readonly invite: typeof invite
readonly listInvites: typeof listInvites
readonly revoke: typeof revoke
readonly accept: typeof accept
}
routesRoutes for workspaces, members, and invites.
const routes: readonly [
{
readonly method: 'post'
readonly path: '/workspaces'
readonly handler: 'create'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/workspaces'
readonly handler: 'list'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/workspaces/invites/accept'
readonly handler: 'accept'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/workspaces/:id'
readonly handler: 'read'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'patch'
readonly path: '/workspaces/:id'
readonly handler: 'update'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'delete'
readonly path: '/workspaces/:id'
readonly handler: 'del'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/workspaces/:id/members'
readonly handler: 'listAll'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'patch'
readonly path: '/workspaces/:id/members/:userId'
readonly handler: 'updateRole'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'delete'
readonly path: '/workspaces/:id/members/:userId'
readonly handler: 'remove'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/workspaces/:id/invites'
readonly handler: 'invite'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/workspaces/:id/invites'
readonly handler: 'listInvites'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'delete'
readonly path: '/workspaces/:id/invites/:inviteId'
readonly handler: 'revoke'
readonly middlewares: readonly ['authenticate']
},
]
updateMemberRoleSchemaSchema for validating member role updates.
const updateMemberRoleSchema: z.ZodObject<
{ role: z.ZodEnum<{ member: 'member'; admin: 'admin'; owner: 'owner' }> },
z.core.$strip
>
updateWorkspaceSchemaSchema for validating workspace update input.
const updateWorkspaceSchema: z.ZodObject<
{ name: z.ZodOptional<z.ZodString>; slug: z.ZodOptional<z.ZodString> },
z.core.$strip
>
WORKSPACE_ROLESAllowed workspace member roles, ordered weakest → strongest.
const WORKSPACE_ROLES: readonly ['member', 'admin', 'owner']
Peer dependencies:
@molecule/api-database ^1.0.1@molecule/api-i18n ^1.0.1@molecule/api-logger ^1.0.1@molecule/api-resource ^1.0.1zod ^4.0.0@molecule/api-database
@molecule/api-i18n
@molecule/api-logger
@molecule/api-resource
zod
List endpoints return a PAGINATED envelope { data, total, limit, offset }, not a
bare array — read the rows off result.data (server). On the client, unwrapList(res)
from @molecule/app-http normalizes this envelope (pass it the whole HttpResponse), so
the rows come back; reading the response as a bare array — or res.data alone (which is
the envelope) — yields an EMPTY list.
Session-auth prerequisite: every route requires an authenticated session
(authenticate) — handlers read res.locals.session.userId and fail closed
with 401; mount behind your global auth middleware.
Role gates are enforced in-handler via assertMember(workspaceId, userId, minRole) with the owner > admin > member hierarchy: list returns
only workspaces the CALLER belongs to, reads require membership, member-role
changes/removals and all invite management require at least admin, and an
inviter cannot grant a role higher than their own. Non-members are denied
(403). Never accept a client-supplied user id for any of these checks.
Invite delivery is YOUR app's concern: POST /workspaces/:id/invites stores
a single-use, expiring token and returns it to the (admin) caller — this
package sends no email. Build the accept link from the token; POST /workspaces/invites/accept with { token } joins the CURRENT session user
at the invite's role.
Tables: src/__setup__/workspaces.sql creates workspaces,
workspace_members, and workspace_invites. An mlcl-scaffolded API replays
__setup__/*.sql automatically on migrate; anywhere else run it once —
nothing at runtime creates them.
Membership/isolation checklist — drive the real UI (live preview, no mocks) with at least TWO distinct signed-in users, adapt each item to this app's actual workspace screens/flows, and check every box off one by one. A box you can't check is a tenancy bug to fix — not a skip:
owner: it appears in their
own workspace list (GET /workspaces) and GET /workspaces/:id returns it,
backed by a workspace_members row at role owner.member is 403 on every privileged endpoint —
invite, revoke an invite, change a member's role, edit settings (PATCH
/workspaces/:id), delete (DELETE /workspaces/:id) — while an admin/owner
can perform them. A caller can never grant a role higher than their own.owner is
refused (409), and ownership transfer works — an owner promotes another
member to owner, after which the original owner can safely leave.