@molecule/api-resource-workspace
v1.0.3
Published
Workspaces + members + invites + role-aware authz.
Maintainers
Readme
@molecule/api-resource-workspace
Auto-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.
Quick Start
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/acceptType
resource
Installation
npm install @molecule/api-resource-workspace @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource zodAPI
Interfaces
CreateWorkspaceInput
Input 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
}PaginatedResult
A 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
}UpdateWorkspaceInput
Input payload for updating a workspace.
interface UpdateWorkspaceInput {
/** Updated workspace display name. */
name?: string
/** Updated URL-safe slug. */
slug?: string
}Workspace
A 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
}WorkspaceInvite
A 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
}WorkspaceMember
Membership 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
}WorkspaceQuery
Query 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
}Types
WorkspaceRole
A workspace member's role within a workspace.
type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]Functions
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',
): voidcallerRole— 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 tomember).
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, optionalslug).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:idparam.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(): stringReturns: 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 tomember).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 optionallimit/offsetquery 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:idparam.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:userIdparams.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:inviteIdparams.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',
): booleanactual— 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): stringinput— 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:idparam 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:userIdparams 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.
Constants
acceptInviteSchema
Schema for validating invite acceptance input.
const acceptInviteSchema: z.ZodObject<{ token: z.ZodString }, z.core.$strip>createWorkspaceSchema
Schema for validating workspace creation input.
const createWorkspaceSchema: z.ZodObject<
{ name: z.ZodString; slug: z.ZodOptional<z.ZodString> },
z.core.$strip
>inviteMemberSchema
Schema 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
>requestHandlerMap
Handler 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
}routes
Routes 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']
},
]updateMemberRoleSchema
Schema for validating member role updates.
const updateMemberRoleSchema: z.ZodObject<
{ role: z.ZodEnum<{ member: 'member'; admin: 'admin'; owner: 'owner' }> },
z.core.$strip
>updateWorkspaceSchema
Schema for validating workspace update input.
const updateWorkspaceSchema: z.ZodObject<
{ name: z.ZodOptional<z.ZodString>; slug: z.ZodOptional<z.ZodString> },
z.core.$strip
>WORKSPACE_ROLES
Allowed workspace member roles, ordered weakest → strongest.
const WORKSPACE_ROLES: readonly ['member', 'admin', 'owner']Injection Notes
Requirements
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
Runtime Dependencies
@molecule/api-database@molecule/api-i18n@molecule/api-logger@molecule/api-resourcezodList endpoints return a PAGINATED envelope
{ data, total, limit, offset }, not a bare array — read the rows offresult.data(server). On the client,unwrapList(res)from@molecule/app-httpnormalizes this envelope (pass it the whole HttpResponse), so the rows come back; reading the response as a bare array — orres.dataalone (which is the envelope) — yields an EMPTY list. Session-auth prerequisite: every route requires an authenticated session (authenticate) — handlers readres.locals.session.userIdand 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.
E2E Tests
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:
- [ ] Creating a workspace makes the creator its
owner: it appears in their own workspace list (GET /workspaces) and GET /workspaces/:id returns it, backed by aworkspace_membersrow at roleowner. - [ ] Inviting a user (POST /workspaces/:id/invites, admin-only) issues a single-use token; the invited user accepting it (POST /workspaces/invites/accept) becomes a member at the invited role and can now read the workspace and its scoped data — data they could NOT see before.
- [ ] Role enforcement: a plain
memberis 403 on every privileged endpoint — invite, revoke an invite, change a member's role, edit settings (PATCH /workspaces/:id), delete (DELETE /workspaces/:id) — while anadmin/ownercan perform them. A caller can never grant a role higher than their own. - [ ] Removing a member (an admin removes them, or they leave via DELETE /workspaces/:id/members/:selfId) revokes access immediately: their next read of the workspace or its scoped data is 403, and it drops from their list.
- [ ] Isolation: a user who is NOT a member of workspace W cannot read or mutate W or its scoped data by guessing W's id — every such call is 403 (or 404), never leaking W's contents. Verify with a real second account.
- [ ] No self-join: accepting a bogus/expired token, or any attempt to add yourself without a valid invite, is rejected — the only way in is a token an admin issued for you.
- [ ] A workspace is never orphaned: removing or demoting the LAST
owneris refused (409), and ownership transfer works — an owner promotes another member toowner, after which the original owner can safely leave.
