npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@molecule/api-resource-workspace

v1.0.3

Published

Workspaces + members + invites + role-aware authz.

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.ts JSDoc, 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/accept

Type

resource

Installation

npm install @molecule/api-resource-workspace @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource zod

API

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',
): 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.

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.1
  • zod ^4.0.0

Runtime Dependencies

  • @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.

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 a workspace_members row at role owner.
  • [ ] 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 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.
  • [ ] 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 owner is refused (409), and ownership transfer works — an owner promotes another member to owner, after which the original owner can safely leave.