← All @molecule/* packages · App templates
@molecule/api-resource-productAPI resource · products · API (Node) · v1.0.1 · Apache-2.0
Product catalog resource handlers with CRUD, soft-delete, and variant sub-resources
npm install @molecule/api-resource-product@molecule/api-resource-product is an API resource: the routes, validation and storage for products, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { routes, requestHandlerMap } from '@molecule/api-resource-product'Works with: @molecule/api-i18n, @molecule/api-locales-product
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.
Product catalog resource for molecule.dev.
Provides CRUD handlers for products with soft-delete, pagination, status filtering, and variant sub-resources. All user-facing text is i18n-ready via companion locale bonds.
import { routes, requestHandlerMap } from '@molecule/api-resource-product'
resource
npm install @molecule/api-resource-product @molecule/api-database @molecule/api-i18n @molecule/api-locales-product @molecule/api-logger @molecule/api-permissions @molecule/api-resource
CreateProductInputInput for creating a new product.
interface CreateProductInput {
/** Display name. */
name: string
/** Optional description. */
description?: string | null
/** Base price in smallest currency unit. */
price: number
/** ISO 4217 currency code. Defaults to 'USD'. */
currency?: string
/** Product status. Defaults to 'draft'. */
status?: ProductStatus
/** Optional image URL. */
imageUrl?: string | null
/** Optional SKU. */
sku?: string | null
/** Optional inventory count. */
inventory?: number | null
}
CreateVariantInputInput for creating a product variant.
interface CreateVariantInput {
/** Variant display name. */
name: string
/** Optional SKU. */
sku?: string | null
/** Optional price override. */
price?: number | null
/** Optional inventory count. */
inventory?: number | null
}
ProductA product record in the catalog.
interface Product {
/** Unique identifier. */
id: string
/** Display name. */
name: string
/** URL-friendly slug derived from name. */
slug: string
/** Optional long-form description. */
description: string | null
/** Base price in the smallest currency unit (e.g. cents). */
price: number
/** ISO 4217 currency code (e.g. 'USD'). */
currency: string
/** Product status controlling visibility. */
status: ProductStatus
/** Optional URL to the primary product image. */
imageUrl: string | null
/** Optional stock-keeping unit identifier. */
sku: string | null
/** Available inventory count, or null if untracked. */
inventory: number | null
/** ISO 8601 creation timestamp. */
createdAt: string
/** ISO 8601 last-updated timestamp. */
updatedAt: string
/** ISO 8601 soft-delete timestamp, or null if active. */
deletedAt: string | null
}
ProductVariantA product variant representing a specific option (size, color, etc.).
interface ProductVariant {
/** Unique identifier. */
id: string
/** Foreign key to the parent product. */
productId: string
/** Variant display name (e.g. 'Large', 'Red'). */
name: string
/** Optional SKU for this variant. */
sku: string | null
/** Price override in the smallest currency unit, or null to use product price. */
price: number | null
/** Available inventory count, or null if untracked. */
inventory: number | null
/** ISO 8601 creation timestamp. */
createdAt: string
/** ISO 8601 last-updated timestamp. */
updatedAt: string
}
ProductStatusProduct status indicating visibility and availability.
type ProductStatus = 'draft' | 'active' | 'archived'
UpdateProductInputInput for updating an existing product.
type UpdateProductInput = Partial<CreateProductInput>
create(req, res)Creates a new product with a unique slug derived from the name.
Admin-only and enforced here (not merely via route middleware): a product is a
shared catalog entity with no per-user owner, so a non-admin caller is rejected
(401 when unauthenticated, 403 otherwise) before any catalog row is inserted —
defense-in-depth that does not depend on the requireAdmin route middleware
being wired.
function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The incoming request with {@link CreateProductInput} body.res — The response object.createVariant(req, res)Creates a variant for a given product.
Admin-only and enforced here (not merely via route middleware): a product is a
shared catalog entity with no per-user owner, so a non-admin caller is rejected
(401 when unauthenticated, 403 otherwise) before a variant (price/stock) is
added — defense-in-depth that does not depend on the requireAdmin route
middleware being wired.
function createVariant(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request object with id param (product ID) and {@link CreateVariantInput} body.res — The response object.del(req, res)Soft-deletes a product by setting its deletedAt timestamp.
Admin-only and enforced here (not merely via route middleware): a product is a
shared catalog entity with no per-user owner, so a non-admin caller is rejected
(401 when unauthenticated, 403 otherwise) before anything is deleted —
defense-in-depth that does not depend on the requireAdmin route middleware
being wired.
function del(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request object with id param.res — The response object.isProductAdmin(res)Resolves whether the current request's session belongs to an actor authorized
to administer products (create/update/delete/create variants). Fail-closed: returns
false when there is no authenticated session, and otherwise only true when
the session carries an admin claim or a bonded permissions provider grants the
manage product permission.
Use this for in-handler defense-in-depth (it does not depend on the route middleware being preserved by the injector).
function isProductAdmin(res: MoleculeResponse): Promise<boolean>
res — The response whose locals.session is inspected.Returns: true when the session is an authorized product admin.
list(req, res)Lists products with pagination and optional status filter. Excludes soft-deleted products.
function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request object with optional page, perPage, and status query params.res — The response object.listVariants(req, res)Lists all variants for a given product.
function listVariants(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request object with id param (product ID).res — The response object.read(req, res)Reads a single product by ID. Returns 404 if not found or soft-deleted.
function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request object with id param.res — The response object.requireAdmin()Route middleware that gates the admin-only product mutation routes (create,
update, del, createVariant). Calls next() only for an authenticated admin;
otherwise forwards an error to the framework error handler — Unauthorized
when no session is present, Forbidden when the session is authenticated but
not an admin.
Exposed as a requestHandlerMap key so the injector's route scanner keeps it
(unlike the inert global 'authenticate' string, which is dropped).
function requireAdmin(): MoleculeRequestHandler
Returns: An Express-compatible middleware function.
update(req, res)Updates a product by ID. Only provided fields are modified.
Admin-only and enforced here (not merely via route middleware): a product is a
shared catalog entity with no per-user owner, so a non-admin caller is rejected
(401 when unauthenticated, 403 otherwise) before any price/stock change —
defense-in-depth that does not depend on the requireAdmin route middleware
being wired.
function update(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request object with id param and {@link UpdateProductInput} body.res — The response object.i18nRegisteredWhether i18n registration has completed.
const i18nRegistered: true
PRODUCT_ADMIN_PERMISSIONSession-claim permission string ('product:manage') that, when present in a
session's permissions array, grants product administration without a bonded
permissions provider.
const PRODUCT_ADMIN_PERMISSION: 'product:manage'
PRODUCT_PERMISSION_ACTIONPermission action checked against @molecule/api-permissions for product
administration.
const PRODUCT_PERMISSION_ACTION: 'manage'
PRODUCT_PERMISSION_RESOURCEPermission resource checked against @molecule/api-permissions for product
administration.
const PRODUCT_PERMISSION_RESOURCE: 'product'
requestHandlerMapHandler map keyed by route handler name.
requireAdmin is the admin authorizer middleware referenced by the
update/del/createVariant routes. It must live here (as a real handler-map
key) so the mlcl injector's route scanner preserves it — a bare middleware
string that isn't a handler-map key is silently dropped.
const requestHandlerMap: {
readonly create: typeof create
readonly createVariant: typeof createVariant
readonly list: typeof list
readonly listVariants: typeof listVariants
readonly read: typeof read
readonly update: typeof update
readonly del: typeof del
readonly requireAdmin: MoleculeRequestHandler
}
routesRoute array for product CRUD plus variant sub-resource.
const routes: readonly [
{
readonly method: 'post'
readonly path: '/products'
readonly handler: 'create'
readonly middlewares: readonly ['requireAdmin']
},
{ readonly method: 'get'; readonly path: '/products'; readonly handler: 'list' },
{ readonly method: 'get'; readonly path: '/products/:id'; readonly handler: 'read' },
{
readonly method: 'patch'
readonly path: '/products/:id'
readonly handler: 'update'
readonly middlewares: readonly ['requireAdmin']
},
{
readonly method: 'delete'
readonly path: '/products/:id'
readonly handler: 'del'
readonly middlewares: readonly ['requireAdmin']
},
{
readonly method: 'get'
readonly path: '/products/:id/variants'
readonly handler: 'listVariants'
},
{
readonly method: 'post'
readonly path: '/products/:id/variants'
readonly handler: 'createVariant'
readonly middlewares: readonly ['requireAdmin']
},
]
Peer dependencies:
@molecule/api-database ^1.0.1@molecule/api-i18n ^1.0.1@molecule/api-locales-product ^1.0.1@molecule/api-logger ^1.0.1@molecule/api-permissions ^1.0.1@molecule/api-resource ^1.0.1@molecule/api-database@molecule/api-i18n@molecule/api-locales-product@molecule/api-logger@molecule/api-permissions@molecule/api-resourceTables: src/__setup__/products.sql creates products +
product_variants. An mlcl-scaffolded API replays __setup__/*.sql
automatically on migrate; anywhere else run it once.
A product is a SHARED catalog entity — there is no per-user owner column.
Reads (GET /products, GET /products/:id, variant reads) are PUBLIC.
Mutations (create, update, del, createVariant) are admin-only and
DENY BY DEFAULT: the caller needs an admin session claim (isAdmin,
role: 'admin', permissions: ['product:manage']) or an
@molecule/api-permissions grant (manage product). Out of the box NO ONE
can mutate the catalog — grant your merchant/catalog-manager role first; a
403 here means "grant the permission", never "remove the gate". The gate is
enforced both as the requireAdmin route middleware and inside every
mutation handler (fail-closed), so it holds however the routes are wired.
Deletes are SOFT (deletedAt timestamp) — deleted products drop out of
list/read automatically; don't add a hard DELETE FROM products path.
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual catalog/admin screens, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
POST /products) persists its real fields
— name, price, currency, sku, inventory, status — the auto-derived slug is
set, and the new row appears in the catalog list (GET /products).price is stored in the smallest
currency unit (cents), so the UI formats it with currency (1999 with 'USD'
renders as "$19.99", never a raw 1999) and inventory shows the real count.GET /products?status=active, or filter in the UI): create a
draft and an active product — the shopper list shows the active one and
hides the draft; flipping status to active makes it appear.?status=
filter and page/perPage pagination, plus any category/price/text search the
app layers on — each returns only matching, non-deleted products.POST /products/:id/variants) carries
its own sku, price override (null inherits the product price) and
inventory, and those per-variant values render on the product.PATCH /products/:id) reflects immediately in the
list and detail, and the app's purchase/decrement flow never drives inventory
below zero — an oversell or negative-stock edit is rejected with a visible
error, not silently stored.isAdmin,
role: 'admin', or a product:manage grant) succeeds; and no unpublished
product leaks to the public by id — GET /products/:id on a draft must not
expose it to a shopper.