← All @molecule/* packages · App templates
@molecule/api-resource-shareAPI resource · resource-share · API (Node) · v1.0.1 · Apache-2.0
Generic ACL table keyed by (resource_type, resource_id, user_id) + role + expiry + public-link slug.
npm install @molecule/api-resource-share@molecule/api-resource-share is an API resource: the routes, validation and storage for resource-share, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { routes, requestHandlerMap } from '@molecule/api-resource-share'
// Auto-mountable surface (via mlcl inject) — read-only + public link resolve:
// GET /resource-shares/:resourceType/:resourceId (full ACL — ownership-gated)
// GET /resource-shares/:resourceType/:resourceId/role (caller's own effective role)
// GET /resource-share-links/:resourceType/:resourceId (link list — ownership-gated)
// GET /resource-share-links/resolve/:slug (public — the slug is the credential)
//
// The MUTATING handlers (`create`/`update`/`del` grants, `createLink`/`revokeLink`)
// are intentionally NOT in `routes` — mount them yourself behind a
// resource-ownership gate. See @remarks.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.
Resource-share resource for molecule.dev.
Generic ACL primitive: collaborator role grants keyed by (resourceType, resourceId, principalType, principalId), plus a separate public link-share token table. Includes service helpers for role lookup, effective-role resolution across user/team/public grants, and access predicates that downstream resources can compose into their own authorization checks.
import { routes, requestHandlerMap } from '@molecule/api-resource-share'
// Auto-mountable surface (via mlcl inject) — read-only + public link resolve:
// GET /resource-shares/:resourceType/:resourceId (full ACL — ownership-gated)
// GET /resource-shares/:resourceType/:resourceId/role (caller's own effective role)
// GET /resource-share-links/:resourceType/:resourceId (link list — ownership-gated)
// GET /resource-share-links/resolve/:slug (public — the slug is the credential)
//
// The MUTATING handlers (`create`/`update`/`del` grants, `createLink`/`revokeLink`)
// are intentionally NOT in `routes` — mount them yourself behind a
// resource-ownership gate. See @remarks.
import { canAccess, requireRole } from '@molecule/api-resource-share'
// Inside another resource's handler:
await requireRole('document', docId, 'editor', userId, teamIds)
resource
npm install @molecule/api-resource-share @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource zod
CreateShareLinkInputInput for creating a public share link.
interface CreateShareLinkInput {
/** Resource type. */
resourceType: string
/** Resource ID. */
resourceId: string
/** Role granted to anyone holding the link. */
role: ShareRole
/** Optional expiry timestamp (ISO 8601). */
expiresAt?: string | null
/** Optional ID of the creating user. */
createdBy?: string | null
}
GrantShareInputInput for granting a share.
interface GrantShareInput {
/** Resource type (e.g. 'document'). */
resourceType: string
/** Resource ID. */
resourceId: string
/** Principal category. */
principalType: PrincipalType
/** Principal ID (user ID, team ID, or '*' for public). */
principalId: string
/** Role to grant. */
role: ShareRole
/** Optional expiry timestamp (ISO 8601). */
expiresAt?: string | null
/** Optional ID of the granting user. */
grantedBy?: string | null
}
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
}
ShareA persisted share grant linking one principal to one resource at one role.
interface Share {
/** Unique share identifier. */
id: string
/** The type of resource being shared (e.g. 'document', 'board'). */
resourceType: string
/** The ID of the resource being shared. */
resourceId: string
/** The category of principal receiving the grant. */
principalType: PrincipalType
/** The ID of the principal (user ID, team ID, or '*' for public). */
principalId: string
/** The role granted on the resource. */
role: ShareRole
/** ID of the user who granted the share, if known. */
grantedBy: string | null
/** Optional expiry timestamp (ISO 8601). After this the grant is inactive. */
expiresAt: string | null
/** When the share was created (ISO 8601). */
createdAt: string
/** When the share was last updated (ISO 8601). */
updatedAt: string
}
ShareLinkA public-link share token. Anyone holding the slug can access the resource at the embedded role until the link is revoked or expires.
interface ShareLink {
/** Unique link identifier. */
id: string
/** The type of resource exposed by this link. */
resourceType: string
/** The ID of the resource exposed by this link. */
resourceId: string
/** Opaque slug/token used in the public URL. */
slug: string
/** The role granted to anyone who follows this link. */
role: ShareRole
/** ID of the user who created the link, if known. */
createdBy: string | null
/** Optional expiry timestamp (ISO 8601). After this the link is inactive. */
expiresAt: string | null
/** When the link was revoked (ISO 8601), or `null` if active. */
revokedAt: string | null
/** When the link was created (ISO 8601). */
createdAt: string
/** When the link was last updated (ISO 8601). */
updatedAt: string
}
ShareQueryFilters for listing shares for a resource.
interface ShareQuery {
/** Filter by principal type. */
principalType?: PrincipalType
/** Maximum number of results to return. */
limit?: number
/** Number of results to skip. */
offset?: number
}
PrincipalTypeCategories of principals that can hold a share grant.
type PrincipalType = 'user' | 'team' | 'public'
ShareAdminAuthorizerResource-ownership predicate: returns true when userId is permitted to
administer (grant / update / revoke) shares on the given resource. This is
the gate that decides who may hand out access — it is deliberately distinct
from {@link resolveRole}, which answers "what can this user already do",
because the raw share table has no inherent knowledge of which user owns
an arbitrary (resourceType, resourceId). Only the consuming app does.
type ShareAdminAuthorizer = (
resourceType: string,
resourceId: string,
userId: string,
) => Promise<boolean> | boolean
ShareRoleA role assigned to a principal on a shared resource.
type ShareRole = (typeof SHARE_ROLES)[number]
canAccess(resourceType, resourceId, required, userId, teamIds)Convenience predicate: does the user have at least the required role on the resource?
function canAccess(
resourceType: string,
resourceId: string,
required: 'viewer' | 'commenter' | 'editor' | 'owner',
userId: string | null,
teamIds?: string[],
): Promise<boolean>
resourceType — Resource type.resourceId — Resource ID.required — Minimum role.userId — The user ID, or null for anonymous.teamIds — IDs of teams the user belongs to.Returns: true if the effective role satisfies required.
canAdministerResource(resourceType, resourceId, userId)Default-DENY ownership gate. Returns true only when an authorizer has been
registered via {@link setShareAdminAuthorizer} AND that authorizer allows
userId to administer the resource. When no authorizer is registered, this
returns false — the share grant/update/revoke handlers respond 403.
function canAdministerResource(
resourceType: string,
resourceId: string,
userId: string,
): Promise<boolean>
resourceType — Resource type.resourceId — Resource ID.userId — The authenticated user ID.Returns: true if the mutation is allowed, otherwise false.
compareRoles(a, b)Compares two roles. Returns 0 when equal, negative when a < b, positive
when a > b.
function compareRoles(
a: 'viewer' | 'commenter' | 'editor' | 'owner',
b: 'viewer' | 'commenter' | 'editor' | 'owner',
): number
a — The first role.b — The second role.Returns: Comparison result based on SHARE_ROLES ordering.
create(req, res)Grants a share on a resource. Idempotent — re-granting to the same principal updates the existing role/expiry instead of duplicating.
function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with grant body (resourceType, resourceId, principalType, principalId, role, expiresAt?).res — The response object.createLink(req, res)Creates a public share link for a resource.
function createLink(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with link body (resourceType, resourceId, role, expiresAt?).res — The response object.createShareLink(input)Creates a public share link for a resource.
function createShareLink(input: CreateShareLinkInput): Promise<ShareLink>
input — The link input.Returns: The persisted link including its slug.
del(req, res)Revokes a share grant by its ID.
function del(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with share id param.res — The response object.generateSlug()Generates an opaque slug for a public share link.
function generateSlug(): string
Returns: A 32-character lowercase alphanumeric slug.
getEffectiveRole(resourceType, resourceId, userId, teamIds)Returns the highest role a user has on a resource across direct user grants, team grants the user is a member of, and any active public grant.
function getEffectiveRole(
resourceType: string,
resourceId: string,
userId: string | null,
teamIds?: string[],
): Promise<'viewer' | 'commenter' | 'editor' | 'owner' | null>
resourceType — Resource type.resourceId — Resource ID.userId — The user ID, or null for an anonymous viewer.teamIds — IDs of teams the user belongs to.Returns: The highest active role, or null when none applies.
getPrincipalRole(resourceType, resourceId, principalType, principalId)Returns the role a single principal holds on a resource, accounting for
expiry. Returns null when no active grant exists.
function getPrincipalRole(
resourceType: string,
resourceId: string,
principalType: PrincipalType,
principalId: string,
): Promise<'viewer' | 'commenter' | 'editor' | 'owner' | null>
resourceType — Resource type.resourceId — Resource ID.principalType — Principal type.principalId — Principal ID.Returns: The active role, or null.
getShareAdminAuthorizer()Returns the currently-registered share-admin authorizer, or null when
none has been registered.
function getShareAdminAuthorizer(): ShareAdminAuthorizer | null
Returns: The registered authorizer, or null.
getShareById(id)Fetches a single share grant by its ID. Used by the update/revoke handlers
to resolve a share's (resourceType, resourceId) before authorizing the
caller against THAT resource.
function getShareById(id: string): Promise<Share | null>
id — The share ID.Returns: The share, or null if not found.
getShareLinkById(id)Fetches a single public share link by its ID. Used by the revoke handler to
resolve a link's (resourceType, resourceId) before authorizing the caller
against THAT resource — the link mirror of {@link getShareById} for grants.
function getShareLinkById(id: string): Promise<ShareLink | null>
id — The share-link ID.Returns: The link, or null if not found.
grantShare(input)Grants a share to a principal. If a grant already exists for the same (resource, principal) tuple, its role and expiry are updated.
function grantShare(input: GrantShareInput): Promise<Share>
input — The share grant input.Returns: The persisted share.
isExpired(timestamp, now)Returns true when an ISO 8601 timestamp is in the past.
function isExpired(timestamp: string | null | undefined, now?: number): boolean
timestamp — ISO 8601 string or null.now — Current time (defaults to Date.now()).Returns: true when timestamp is non-null and already elapsed.
list(req, res)Lists shares attached to a resource, identified by resourceType and
resourceId query/path params.
function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType, resourceId params and optional pagination/filter query.res — The response object.listLinks(req, res)Lists all public share links attached to a resource.
function listLinks(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType and resourceId params.res — The response object.listShareLinks(resourceType, resourceId)Lists all share links attached to a resource.
function listShareLinks(resourceType: string, resourceId: string): Promise<ShareLink[]>
resourceType — Resource type.resourceId — Resource ID.Returns: Active and revoked links, newest first.
listShares(resourceType, resourceId, options)Lists shares attached to a single resource, with pagination.
function listShares(
resourceType: string,
resourceId: string,
options?: ShareQuery,
): Promise<PaginatedResult<Share>>
resourceType — Resource type.resourceId — Resource ID.options — Optional filters.Returns: Paginated list of shares.
listSharesForUser(userId)Lists every active resource a user can reach via direct user grants.
function listSharesForUser(userId: string): Promise<Share[]>
userId — The user ID.Returns: Array of (resourceType, resourceId, role) records.
read(req, res)Returns the effective role the current user has on the resource,
considering direct user grants, team grants (if teamIds provided in
res.locals.session), and any active public grant.
function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType and resourceId params.res — The response object.requireRole(resourceType, resourceId, required, userId, teamIds)Throws Error('forbidden') unless the user has at least the required
role on the resource. Intended for use inside other resource handlers
that integrate with shares.
function requireRole(
resourceType: string,
resourceId: string,
required: 'viewer' | 'commenter' | 'editor' | 'owner',
userId: string | null,
teamIds?: string[],
): Promise<void>
resourceType — Resource type.resourceId — Resource ID.required — Minimum role required.userId — User ID, or null for anonymous.teamIds — Team IDs the user belongs to.resolveLink(req, res)Resolves a public share-link slug to its ShareLink record. Returns
404 when the slug is unknown, revoked, or expired. Does NOT require
authentication — the slug is the credential.
function resolveLink(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with slug param.res — The response object.resolveRole(resourceType, resourceId, userId, teamIds)Resolves the highest active role a user has on a resource, considering
direct user grants, team grants, and any active public grant. Returns
null when no active grant applies.
function resolveRole(
resourceType: string,
resourceId: string,
userId: string | null,
teamIds?: string[],
): Promise<'viewer' | 'commenter' | 'editor' | 'owner' | null>
resourceType — Resource type (e.g. 'document').resourceId — Resource ID.userId — User ID, or null for anonymous viewers.teamIds — Team IDs the user belongs to.Returns: The effective role, or null.
resolveShareLink(slug)Resolves a public share-link slug to its ShareLink record. Returns
null if the slug is unknown, revoked, or expired.
function resolveShareLink(slug: string): Promise<ShareLink | null>
slug — The opaque slug from the URL.Returns: The active link, or null.
revokeLink(req, res)Revokes a public share link by ID. Idempotent.
function revokeLink(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with link id param.res — The response object.revokeShare(resourceType, resourceId, principalType, principalId)Revokes a single share grant.
function revokeShare(
resourceType: string,
resourceId: string,
principalType: PrincipalType,
principalId: string,
): Promise<void>
resourceType — Resource type.resourceId — Resource ID.principalType — Principal type.principalId — Principal ID.revokeShareById(id)Revokes a share grant by its ID.
function revokeShareById(id: string): Promise<void>
id — Share ID.revokeShareLink(id)Revokes a public share link by ID. Idempotent — sets revokedAt if not
already set.
function revokeShareLink(id: string): Promise<ShareLink | null>
id — The link ID.Returns: The updated link, or null if not found.
roleSatisfies(role, required)Determines whether role is at least as privileged as required.
function roleSatisfies(
role: 'viewer' | 'commenter' | 'editor' | 'owner',
required: 'viewer' | 'commenter' | 'editor' | 'owner',
): boolean
role — The role held.required — The minimum role required.Returns: true when role >= required.
setShareAdminAuthorizer(authorizer)Registers the resource-ownership authorizer consulted by the share
grant/update/revoke handlers before any mutation. Until an app registers
one, every share mutation is DENIED (secure by default) — the share table
cannot know who owns an arbitrary resource, so the consuming app MUST supply
that knowledge (e.g. "is userId the owner of / an admin on this project?").
Pass null to clear a previously-registered authorizer (restores default
deny).
function setShareAdminAuthorizer(authorizer: ShareAdminAuthorizer | null): void
authorizer — The ownership predicate, or null to clear.update(req, res)Updates an existing share's role and/or expiry.
function update(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with share id param and patch body.res — The response object.updateShare(id, patch)Updates the role and/or expiry of an existing share by ID.
function updateShare(
id: string,
patch: { role?: ShareRole; expiresAt?: string | null },
): Promise<Share | null>
id — The share ID.patch — Fields to update.Returns: The updated share, or null if not found.
createShareLinkSchemaSchema for creating a public share link.
const createShareLinkSchema: z.ZodObject<
{
resourceType: z.ZodString
resourceId: z.ZodString
role: z.ZodEnum<{ viewer: 'viewer'; commenter: 'commenter'; editor: 'editor'; owner: 'owner' }>
expiresAt: z.ZodOptional<z.ZodNullable<z.ZodString>>
},
z.core.$strip
>
grantShareSchemaSchema for granting a share to a principal.
const grantShareSchema: z.ZodObject<
{
resourceType: z.ZodString
resourceId: z.ZodString
principalType: z.ZodEnum<{ user: 'user'; team: 'team'; public: 'public' }>
principalId: z.ZodString
role: z.ZodEnum<{ viewer: 'viewer'; commenter: 'commenter'; editor: 'editor'; owner: 'owner' }>
expiresAt: z.ZodOptional<z.ZodNullable<z.ZodString>>
},
z.core.$strip
>
requestHandlerMapHandler map for the auto-mountable resource-share routes (see routes.ts).
It contains EXACTLY the four routes those routes mount — the read-only
list/read/listLinks plus the public resolveLink — and nothing else.
SECURITY: all five mutating handlers — the create / update / del grant
handlers AND the createLink / revokeLink public-link handlers — are
deliberately absent, because the share table cannot know who owns an
arbitrary resource, so none of them may be auto-mounted. Import them directly
from @molecule/api-resource-share and mount each behind your own
resource-ownership gate (plus a setShareAdminAuthorizer registration; the
handlers also default-DENY on their own until one is registered).
const requestHandlerMap: {
readonly list: typeof list
readonly read: typeof read
readonly listLinks: typeof listLinks
readonly resolveLink: typeof resolveLink
}
routesHTTP routes for share reads and public link tokens.
const routes: readonly [
{
readonly method: 'get'
readonly path: '/resource-shares/:resourceType/:resourceId'
readonly handler: 'list'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/resource-shares/:resourceType/:resourceId/role'
readonly handler: 'read'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/resource-share-links/:resourceType/:resourceId'
readonly handler: 'listLinks'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/resource-share-links/resolve/:slug'
readonly handler: 'resolveLink'
},
]
SHARE_ROLESAvailable roles, ordered from least to most privileged. Higher index implies all lower-indexed permissions.
const SHARE_ROLES: readonly ['viewer', 'commenter', 'editor', 'owner']
updateShareSchemaSchema for updating an existing share's role and/or expiry.
const updateShareSchema: z.ZodObject<
{
role: z.ZodOptional<
z.ZodEnum<{ viewer: 'viewer'; commenter: 'commenter'; editor: 'editor'; owner: 'owner' }>
>
expiresAt: z.ZodOptional<z.ZodNullable<z.ZodString>>
},
z.core.$strip
>
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.
SECURITY — the raw grant/update/revoke handlers DENY by default (every
mutation returns 403 until an ownership authorizer is registered) and MUST
be mounted behind a resource-ownership gate; never auto-mount them. The
share table has no inherent knowledge of who owns an arbitrary
(resourceType, resourceId), so without a gate any authenticated user could
POST /resource-shares to grant themselves the highest role on ANY resource
(or revoke/escalate others' grants by id).
Two things enforce this:
None of the five mutating handlers (create/update/del grants,
createLink/revokeLink public links) are in routes or
requestHandlerMap — only the read-only list/read/listLinks and
the public resolveLink route auto-mount. Note list (full ACL) and
listLinks (share-link slugs) disclose manage-level data, so even though
they auto-mount they self-enforce the SAME default-DENY ownership gate
(canAdministerResource) as the mutating handlers — only read (the
caller's OWN effective role) is an ungated read primitive. Mount the
mutating handlers explicitly behind your own ownership check,
with the resource identity fixed by the server (never trusted from the
request body):
import { create as grantShareHandler } from '@molecule/api-resource-share'
router.post('/projects/:projectId/shares', async (req, res) => {
if (!(await assertProjectAccess(req, res))) return // owner/admin gate
await grantShareHandler(req, res)
})
Defense in depth: the handlers themselves DENY by default. They call a registerable ownership authorizer before any mutation; until an app registers one, every mutation — grant/update/revoke AND public-link mint/revoke — returns 403:
import { setShareAdminAuthorizer } from '@molecule/api-resource-share'
setShareAdminAuthorizer(
async (resourceType, resourceId, userId) =>
resourceType === 'project' && (await userOwnsOrAdminsProject(userId, resourceId)),
)
update/del (and revokeLink) resolve the stored row first to learn its
(resourceType, resourceId) and authorize the caller against THAT resource;
create/createLink authorize against the resource identity in the
(validated) request body.
Tables: src/__setup__/resource-shares.sql and
src/__setup__/resource-share-links.sql create resource-shares and
resource-share-links. An mlcl-scaffolded API replays __setup__/*.sql
automatically on migrate; anywhere else run them once — nothing at runtime
creates them.
ACCESS-CONTROL integration checklist — shares ARE the authorization boundary, so prove each grant admits exactly who it should and no one else; do not settle for "the CRUD compiled". Drive the real UI (live preview, no mocks) with TWO signed-in accounts (a resource owner + a collaborator) plus one signed-out/anonymous session, adapt every item to this app's actual shareable resource (document, board, project, ...), and check each box off one by one. A box you can't check is an access-control bug to fix, never a skip — never mock the check or weaken a role gate to go green: