Module kit / Commerce and payments
Product catalog CRUD and content
commerce-payments/product-catalog-crud
| Kind | backend |
|---|---|
| Domain | commerce-payments |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, nh3, alembic |
| Depends on | _shared/fastapi-project-layout, _shared/workspace-scoping, _shared/alembic-migrations, _shared/auth-dependency-pattern, _shared/error-envelope |
| Frontends | commerce-payments/ui/storefront-preview |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
An operator's catalog (name, price, rich copy, up to two images) is created, listed, read, updated and deleted through one workspace-scoped admin CRUD, and every product image the public storefront ever renders was minted by this API's own upload endpoint, never accepted as a caller-supplied id. The invariant that makes it trustworthy is that image_file_id/image2_file_id are validated against a closed, server-issued id shape (_PRODUCT_IMAGE_ID_RE, pimg_<uuid>.<ext>) on every write path that can set them, so a workspace member can never paste another asset's storage id - most dangerously a paid infoproduct's file_id - into a product's image field and turn the public, unauthenticated image endpoint into a free mirror of it (module comment, commerce_router.py:64-77).
When to use
Read it before touching create_product, list_products, get_product, update_product, delete_product, upload_product_image, _stream_product_image, public_get_product_image/image2/images/{file_id}, detach_product_image/image2, or _require_product_image_id. Trigger phrases: "product CRUD", "catalog", "product image", "pimg_", "sanitize content_html", "archive a product", "hard delete a product", "stolen file_id", "public product image".
Not this recipe: POST /api/products/{id}/emdash/push, /emdash/unpublish, DELETE /api/products/{id}/emdash/entry, GET /api/commerce/emdash/targets (commerce_router.py:659-849) - pushing/pulling a product into a client's EmDash CMS site, a separate mirroring concern with its own failure mode (409 on delete, 502 on push); POST /api/commerce/checkout, GET /api/commerce/public/product/{id}/availability, the Payrexx webhook handler, refunds, ScheduledSubscription/intro_then_sub conversion, and the customer self-service cancel flow (commerce_router.py:851 onward) - all Payrexx-side, a neighbouring recipe; POST/DELETE /api/products/{id}/asset (infoproduct file_id/filename attach, commerce_router.py:302-360) - the asset a product's download is fulfilled from, adjacent but distinct from the image triple this recipe owns; GET /api/commerce/download/{grant_id} and DownloadGrant - the metered download path, reads file_id but does not mint or validate it the way this recipe's image id does; PUT /api/commerce/tags, form-product attachment (attach_product_to_form) - catalog-adjacent, not catalog content.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), psycopg2-binary==2.9.10, alembic==1.16.5, nh3>=0.2.15 (the Rust HTML sanitizer rich_text.py wraps). Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router (the router is mounted plain, surfaces=LABS_SURFACES, no feature flag - app_fastapi.py:838); S5-equivalent workspace scoping (_shared/workspace-scoping) for WorkspaceID = Depends(_get_workspace_id) (api/dependencies.py:680-779), which every admin route in this recipe filters by; the auth dependency pattern for VerifiedUser = Depends(get_current_verified_user) (api/dependencies.py:351-362, 403 when settings.ENABLE_EMAIL_VERIFICATION is on and the user is unverified); alembic-migrations for the five chained-into-head revisions under Data model.
No dedicated env var gates this recipe (no PRODUCT_* or COMMERCE_* flag was found in core/config.py for the CRUD or image routes; grep -rn "COMMERCE_ENABLED\|FEATURE_COMMERCE" in app_fastapi.py/core/config.py returns nothing). Storage is RenderStorageService(str(workspace_id)) (services/storage/render_storage.py:427-449), which prefixes every key with workspace/{workspace_id}/files - the public image stream instantiates it from product.workspace_id, read off the row, never from caller input, so a guessed id cannot be resolved against the wrong workspace's prefix even if the regex check were bypassed.
Data model
products (models/commerce_models.py:20-107), the columns this recipe owns (billing/subscription columns are the neighbouring checkout recipe's concern, listed here only for context):
products (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
name varchar(255) NOT NULL,
description text,
price_cents integer NOT NULL, -- CHECK >= 0
currency varchar(10) NOT NULL DEFAULT 'CHF',
type varchar(20) NOT NULL, -- 'one_time' | 'subscription' (checkout concern)
is_active boolean NOT NULL DEFAULT true,
kind varchar(20) NOT NULL DEFAULT 'product', -- CHECK IN ('product','workshop','infoproduct','appointment','credits')
file_id varchar(255), -- infoproduct asset (POST/DELETE .../asset, not this recipe's write path)
filename varchar(255),
content_html text, -- ALWAYS sanitize_product_html() on write, both create and update
image_file_id varchar(255), -- server-issued pimg_<uuid>.<ext> only
image_filename varchar(255),
image_mime varchar(100), -- image/jpeg | image/png | image/webp | image/gif
image2_file_id varchar(255), -- second slot: card/thumbnail vs. hero
image2_filename varchar(255),
image2_mime varchar(100),
emdash_project_id uuid REFERENCES projects(id) ON DELETE SET NULL, -- EmDash concern, out of scope
emdash_collection varchar(100), emdash_slug varchar(200), emdash_entry_id varchar(100),
emdash_status varchar(20), emdash_last_pushed_at timestamptz, emdash_last_error text,
metadata jsonb NOT NULL DEFAULT '{}', -- extra_metadata in Python; carries {"archived": true} and {"images": [...]} (ordered gallery)
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CHECK ck_products_type: type IN ('one_time','subscription')
CHECK ck_products_price_nonneg: price_cents >= 0
CHECK ck_products_kind: kind IN ('product','workshop','infoproduct','appointment','credits')
INDEX ix_products_workspace_kind (workspace_id, kind)
orders (models/commerce_models.py:110-157) is read by delete_product's existence check only (Order.product_id, FK RESTRICT); everything else on Order is the checkout recipe's. Migrations that shaped these columns, in the order they were added (each down_revision points elsewhere in the full chain, not at one another - the base table long predates the image/content columns): commerce_module_001 (20260426_add_commerce_module.py, down gmail_backfill_001) - products/orders themselves; commerce_kind_infoproducts_001 (20260609_0001..., down review_original_snapshot_001) - kind, file_id, filename; commerce_img_content_emdash_001 (20260903_0001..., down booking_msg_steps_001) - image_*, content_html, emdash_*, all nullable and additive, no server default (module docstring: a NULL emdash_status means "never pushed", distinct from 'draft'); commerce_second_image_001 (20260903_0003..., down projects_workspace_id_001) - image2_*, added because one client's real content collection needed a separate card/thumbnail image distinct from the hero, and a single image slot was always duplicated into both (migration docstring).
What is NOT stored: a rendered/raw copy of content_html before sanitization (only the cleaned string is ever written, both create and update go through sanitize_product_html before db.add/setattr); an <img> tag or a data: URL inside content_html (the sanitizer's allowlist has no img tag at all, rich_text.py:21-42); any evidence of which caller-chosen id was attempted and rejected by _require_product_image_id beyond the 400 response itself (no rejected-id audit column or log line was found in commerce_router.py).
API surface
| Route | Auth | Tenancy | Status codes | Notes |
|---|---|---|---|---|
POST /api/products (create_product, commerce_router.py:97-146) | VerifiedUser | workspace_id from WorkspaceID, stamped on the row | 201; 422 on schema validation (e.g. missing interval for a subscription); 400 if image_file_id/image2_file_id fails _require_product_image_id | content_html sanitized before insert; image ids validated before insert |
GET /api/products (list_products, :149-164) | VerifiedUser | filtered Product.workspace_id == workspace_id | 200 | optional is_active filter; archived products (metadata.archived) are always excluded regardless of is_active |
GET /api/products/{product_id} (get_product, :167-179) | VerifiedUser | and_(id==, workspace_id==) | 200; 404 | cross-workspace id 404s, does not leak existence |
PUT /api/products/{product_id} (update_product, :182-212) | VerifiedUser | and_(id==, workspace_id==) | 200; 404; 400 on either image id failing _require_product_image_id | payload.model_dump(exclude_unset=True) - omitted fields are untouched, not nulled; content_html re-sanitized on every update through the same generic setattr loop |
DELETE /api/products/{product_id} (delete_product, :215-262) | VerifiedUser | and_(id==, workspace_id==) | 204 (hard delete or archive, see Application logic); 404; 409 if the live EmDash entry cannot be removed first | |
POST /api/commerce/images (upload_product_image, :365-426) | VerifiedUser | keyed by workspace_id (storage prefix only - the id itself carries no workspace) | 200 ImageUploadResponse{file_id,filename,mime,size}; 400 (mime not in {image/jpeg,image/png,image/webp,image/gif}, >5MB measured on bytes read, or empty); 500 if storage write fails | product-independent: can be called before the product is ever saved; mints pimg_<uuid>.<ext> itself, never reads the extension off the uploaded filename except to pick within a closed set |
GET /api/commerce/public/product/{product_id}/image (public_get_product_image, :459-478) | none | workspace derived from the product row | 200 stream; 404 (no product, no image_file_id, or blob missing from storage) | does NOT require is_active - a published page keeps its image after the product stops selling |
GET /api/commerce/public/product/{product_id}/image2 (public_get_product_image2, :481-489) | none | same | same | secondary slot, identical contract |
GET /api/commerce/public/product/{product_id}/images/{file_id} (public_get_product_image_by_id, :492-518) | none | same, plus _require_product_image_id(file_id) | 200; 400 (malformed id); 404 (product not found, or file_id is not one of this product's two column slots or metadata.images entries) | the ordered-gallery route; a well-formed pimg_ id that this product does not own is 404, not served (test_gallery_refuses_an_id_that_is_not_this_products) |
DELETE /api/products/{product_id}/image (detach_product_image, :521-538) | VerifiedUser | and_(id==, workspace_id==) | 204; 404 | clears image_file_id/image_filename/image_mime; the stored blob is left in storage (deliberate, same choice as the infoproduct asset detach) |
DELETE /api/products/{product_id}/image2 (detach_product_image2, :541-558) | VerifiedUser | same | 204; 404 | secondary slot, identical contract |
Application logic
Create (create_product, :97-146): validate both image ids with _require_product_image_id (400 before anything is built if either fails); build Product(...) with content_html=sanitize_product_html(payload.content_html); db.add, db.commit, db.refresh, return. sanitize_product_html(None) returns None (field simply not supplied).
List (list_products, :149-164): query filtered to workspace_id, optional is_active filter, ordered by created_at desc, THEN a Python-side filter drops any row carrying metadata.archived - archived products never appear in the list at all, in either is_active state, though they still resolve individually by id (for the orders that reference them).
Update (update_product, :182-212): load and 404-check; payload.model_dump(exclude_unset=True) so a key the client never sent is left alone; if image_file_id/image2_file_id is present in the sent keys, re-validate with _require_product_image_id - the same guard as create, repeated here deliberately, because this is literally the endpoint that would turn a stolen file_id into a public download if it were skipped (:196-197 comment); if content_html is present, re-sanitize it through the exact same allowlist as create, so the generic setattr loop below is not a bypass; extra_metadata is popped and assigned directly (not looped through setattr) so SQLAlchemy sees the JSONB replacement; every remaining sent key is setattr'd onto the ORM object; commit, refresh, return.
Delete (delete_product, :215-262): load and 404-check. If emdash_entry_id is set, the remote CMS entry is deleted FIRST (delete_entry); on EmDashPublishError nothing in this transaction has been touched yet (the call only reads and does HTTP before touching the row), so no rollback is needed, and the request is refused 409 with the provider's reason - the Labs row must never be deleted out from under a page that is still live on a client's site with no way back. Then: has_orders = db.query(Order.id).filter(Order.product_id == product.id).first() is not None. If any order references the product, it is archived, not deleted - is_active = False, metadata.archived = True (a fresh dict copy so SQLAlchemy detects the JSONB mutation), commit, return 204; the row is permanently kept out of list_products but still resolves by id for the orders that reference it. If no order references it, db.delete(product), commit, 204 - the row is gone for good, which is only safe because orders.product_id is ON DELETE RESTRICT (a delete against a referenced row would fail at the database before this branch could ever be reached in error).
Image upload (upload_product_image, :365-426): mime check against the closed raster set (SVG explicitly excluded - a script carrier once served from the API's own origin); bytes read in bounded 64 KB chunks, checked against the 5 MB cap after every chunk so an attacker-controlled Content-Length cannot be trusted to park a large body in memory before rejection; empty upload (0 bytes after the loop) is a 400; the extension on the minted id is taken from the uploaded filename ONLY if it is in the closed extension set, otherwise from a fixed mime→extension map - never copied verbatim from an arbitrary filename; id minted as f"pimg_{uuid.uuid4()}{ext}"; stored via RenderStorageService(str(workspace_id)).save_file(...); 500 with a logged (not returned) storage error on write failure.
Public image stream (_stream_product_image, shared helper :429-456, called by all three public GET routes): 404 if the product has no id in that slot, or if storage.file_exists(file_id) is false; Content-Type is pinned to the stored mime ONLY if it is still one of the four allowed raster mimes, else forced to application/octet-stream - this re-check at read time protects rows written before image_mime became a closed Literal on the schema, and its removal would let an attacker-chosen text/html be served unauthenticated from the API's own origin (docstring, :469-473); response headers set Cache-Control: public, max-age=300, ETag to the file id, X-Content-Type-Options: nosniff, Content-Disposition: inline, and Content-Length when the storage backend can report a size.
Gallery-by-id (public_get_product_image_by_id, :492-518): _require_product_image_id(file_id) first (malformed id never even reaches the database); then the id must equal product.image_file_id, product.image2_file_id, or be present in metadata.images (each entry a dict with its own file_id/mime) - any other well-formed pimg_ id is a 404 even though storage would happily stream it, because ownership is enforced at this route, not by storage.
Invariants
- Every image id that ever reaches
products.image_file_id/image2_file_idthrough this API matches_PRODUCT_IMAGE_ID_RE(^pimg_[0-9a-f]{8}-...-[0-9a-f]{12}\.[A-Za-z0-9]{1,8}\Z), enforced identically on create and on update, because onlyPOST /api/commerce/imagesmints ids of this shape and every other id format in the system (a bare infoproductfile_id) cannot match it (module comment,:64-77; no dedicated test found exercising the rejection path itself - see Gotchas). content_htmlis never stored except throughsanitize_product_html, on both the create insert and the updatesetattrloop - there is exactly one write path into the column and it always runs the allowlist (test_create_product_persists_sanitized_html,test_update_product_persists_sanitized_html).Nonemeans "field not supplied, leave the column alone";""/whitespace-only means "the operator cleared it" - the sanitizer returnsNoneforNoneand''for blank, andupdate_product'sexclude_unset=Trueis what makes "the key was never sent" distinguishable from "the key was sent as blank" at the schema layer (test_update_product_can_clear_html_but_omitting_it_leaves_it).- A product with at least one order can never be hard-deleted; it is archived (
is_active=False,metadata.archived=True) instead, and archived products never appear inlist_productsregardless of theis_activefilter, though they still resolve by id (delete_productdocstring,:222-231). - The public image endpoints never require
is_active- an image kept alive on a client's published page must keep resolving after the product itself stops selling (public_get_product_imagedocstring,:461-467; not the same rule aspublic_get_product, which DOES requireis_activefor the buy page). - A product is reachable by every admin route only through
and_(Product.id==, Product.workspace_id==); a cross-workspace id 404s rather than 403ing, so existence is never leaked cross-tenant (test_detach_image_cross_workspace_rejected,test_attach_asset_cross_workspace_rejected).
Frontend
The module that renders this, by id:
commerce-payments/ui/storefront-preview the operator-facing product editor (name, price, rich content,
the two image slots, live buy-page preview) and the
public buy page's product block; at 62ba4fc the real
frontend components are alchemy_labs/frontend/src/
pages/commerce/ProductEditor.tsx (admin) and
pages/commerce/BuyPage.tsx (public), with
components/commerce/adapter.ts building the ordered
image gallery off image_url/image2_url/the by-id
route (imagesFromApi, adapter.ts:150-179) - this
prima-modules recipe does not exist yet in this repo
Minimum admin/debug surface (no dedicated route beyond the CRUD itself exists at this commit): SELECT id, name, is_active, metadata->>'archived' AS archived FROM products WHERE workspace_id = :ws ORDER BY created_at DESC (what list_products would show, including the archived rows it hides); SELECT id, image_file_id, image2_file_id FROM products WHERE image_file_id IS NOT NULL AND image_file_id !~ '^pimg_' (should always return zero rows - anything else means a row was written outside this API's guard, e.g. by a direct migration or script).
Security & tenancy
Auth dependency: VerifiedUser = Depends(get_current_verified_user) on every admin route (api/dependencies.py:351-362, chains through get_current_active_user; 403 "Please verify your email address" when settings.ENABLE_EMAIL_VERIFICATION is true and the account is unverified). Tenancy: WorkspaceID = Depends(_get_workspace_id) (api/dependencies.py:680-779), resolution order is request.state.workspace_id set by WorkspaceMiddleware for JWT requests (already access-checked), else the X-Workspace-ID header re-validated against _user_has_workspace_access (owner or an active workspace_users row), else a single-workspace inference, else 409 telling the client to bootstrap a workspace; every admin query in this recipe filters Product.workspace_id == workspace_id explicitly, it is never left to the dependency alone.
Fail direction: image-id validation closed (_require_product_image_id raises 400 on anything that does not match the exact regex, including a syntactically-close-but-wrong id); the public image stream closed on Content-Type (pinned to the allowed-mime set or forced to application/octet-stream, never trusts a stored value that predates the schema becoming a closed Literal); a cross-workspace admin request 404s rather than ever returning another tenant's product.
Secrets and PII at rest: none of this recipe's columns hold a credential; image_mime/image2_mime are the one field where an unvalidated value would become attacker-controlled response metadata served from the API's own origin, which is why the read-path pin exists independently of the write-path Literal. Rate limits: none dedicated to POST /api/commerce/images or the public image routes was found in this recipe's files - the 5 MB body cap and the closed mime set are the only bounds on the upload; the public streaming routes have no auth and no per-IP throttling at this layer. Attacker-controlled inputs: image_file_id/image2_file_id on create and update (gated by the regex, the entire point of this recipe's headline invariant); the uploaded file's declared Content-Type header and Content-Length (neither trusted - content type is checked against the allowlist, size is measured on bytes actually read); content_html (gated by the nh3 allowlist, tags/attributes/URL schemes all closed sets); the file_id path parameter on the gallery route (regex-checked before any query).
Tests
Two files, both under alchemy_labs/backend/tests/, using the transactional db_session fixture and the dependency-override admin_tc/client TestClients (no real JWT, no middleware) documented in test_commerce_images.py's own module docstring.
test_commerce_images.py (643 lines) is this recipe's primary file: sanitizer tests (test_sanitize_none_and_blank, test_sanitize_strips_script_tag_and_its_content, test_sanitize_strips_javascript_href, test_sanitize_strips_event_handlers, test_sanitize_strips_img_and_iframe, test_sanitize_strips_style_and_style_attribute, test_sanitize_keeps_the_editor_formatting_vocabulary, test_sanitize_keeps_http_links_and_stamps_rel, test_sanitize_allowlist_constants_are_exported); upload guard tests (test_upload_image_rejects_non_image_mime, test_upload_image_rejects_svg, test_upload_image_rejects_oversize_payload, test_upload_image_accepts_small_png, test_upload_image_rejects_empty_file); public stream tests (test_public_image_404_when_product_has_no_image, test_public_image_404_for_unknown_product, test_public_image_streams_bytes_with_media_type, test_public_image_404_when_blob_is_gone); detach tests (test_detach_image_clears_the_three_columns, test_detach_image_cross_workspace_rejected, test_detach_image_unknown_product_404); write-path sanitize tests (test_create_product_persists_sanitized_html, test_update_product_persists_sanitized_html, test_update_product_can_clear_html_but_omitting_it_leaves_it, test_product_response_exposes_image_url_only_when_an_image_exists); gallery tests (test_gallery_serves_a_third_image_from_metadata, test_gallery_uses_the_entry_mime, test_gallery_serves_slot_one_by_id_too, test_gallery_refuses_an_id_that_is_not_this_products, test_gallery_rejects_a_malformed_id); delete tests (test_delete_without_orders_removes_the_row, test_delete_refuses_when_the_site_entry_cannot_be_removed). test_commerce_phase1.py contributes the adjacent infoproduct-asset tests cited under When to use (test_attach_asset_sets_file_and_metadata, test_attach_asset_requires_infoproduct, test_attach_asset_cross_workspace_rejected) which exercise the neighbouring, out-of-scope file_id attach path.
Hermetic vs live: stub_storage (test_commerce_images.py:75-107) monkeypatches RenderStorageService's five async methods so no upload, stream, or detach test touches a real bucket or filesystem; PNG_1PX is a real 1x1 PNG's actual bytes, so the mime and size checks run against genuine image data, not a placeholder string.
Gap found, stated plainly: no test in either file drives POST /api/products or PUT /api/products/{id} with an image_file_id that does NOT match _PRODUCT_IMAGE_ID_RE (e.g. a bare infoproduct file_id, or a pimg_-prefixed string with a malformed UUID) and asserts the 400 this recipe's own Purpose section calls its headline invariant. grep -rn "must come from\|_require_product_image_id\|_PRODUCT_IMAGE_ID_RE" tests/ returns nothing. The gallery route's malformed-id test (test_gallery_rejects_a_malformed_id) exercises the same regex helper but only via the GET path, not via create/update.
Deploy & ops
No dedicated config key gates this recipe (ENABLE_EMAIL_VERIFICATION is the only setting cited above, and it is a global auth switch, not commerce-specific). Migrations, in the order the columns landed (see Data model for revision ids and down_revisions - they are not chained to each other, each was appended at its own point in the full migration history): commerce_module_001, commerce_kind_infoproducts_001, commerce_img_content_emdash_001, commerce_second_image_001. Router mount: mount_router(app, commerce_router.router, prefix="/api", tags=["Commerce"], surfaces=LABS_SURFACES) (app_fastapi.py:838), the standard surface set, no flag. Rollback: products rows are never destructively migrated by this recipe's four revisions (all four are additive ADD COLUMNs with no data backfill beyond defaults on the base table); a code rollback needs no data unwind.
I did not run alembic upgrade head or the test suite against a live database for this recipe (read-only source access, per the task); the migration chain and router mount are confirmed by reading the files, not by a live boot. Treat "applies cleanly to head" as unverified until an Acceptance-check run confirms it.
Gotchas
- 2026-09-12 (measured at 62ba4fc): the four migrations that shaped
image_*/content_html/image2_*each have a different, unrelateddown_revision(gmail_backfill_001,review_original_snapshot_001,booking_msg_steps_001,projects_workspace_id_001) rather than chaining onto one another - normal for a large shared migrations directory where many features land between them, but it means "commerce migrations" is not a contiguous run of revisions and a rebuild must resolve the full chain, not just these four in isolation. - 2026-09-12 (measured at 62ba4fc,
commerce_second_image_001docstring): the second image slot exists because a real client's content collection needed a separate card/thumbnail image distinct from the hero, and a singleimage_*triple was always sending the same picture to both. The docstring names that client directly; this recipe does not repeat the name. - 2026-09-12 (measured at 62ba4fc): see the Tests section's stated gap - the create/update 400 path for a caller-supplied, non-
pimg_image id has no dedicated test at this commit, only the read-side gallery-route regex check does. - 2026-09-12 (measured at 62ba4fc):
upload_product_imagelogs the storage error on a failed write (logger.error(...)) but returns a generic 500 "Failed to store the image" - the operator-facing message carries no detail, which is consistent with_shared/error-envelope's rule that no provider/internal error text crosses into the response, but means a rebuild must go to the server log, not the API response, to diagnose a storage failure here.
Reference implementation
Paths relative to the repo root; all at alchemy_labs branch main, commit 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12).
alchemy_labs/backend/api/routers/commerce_router.py(1739 lines total, this recipe's scope is the first third): module docstring (1-7); constants and_PRODUCT_IMAGE_ID_RE/_require_product_image_id(46-89);create_product(97-146);list_products(149-164);get_product(167-179);update_product(182-212);delete_product(215-262);upload_product_image(365-426);_stream_product_image(429-456);public_get_product_image(459-478);public_get_product_image2(481-489);public_get_product_image_by_id(492-518);detach_product_image(521-538);detach_product_image2(541-558). (attach_product_asset/detach_product_asset, 302-360, and the EmDash/checkout/webhook/order sections beyond line 561 are cited only under When to use, not owned by this recipe.)alchemy_labs/backend/models/commerce_models.py(271 lines):Product(20-107, all columns and CHECKs);Order(110-157, cited for the delete-guard FK only).alchemy_labs/backend/schemas/commerce_schemas.py:ProductImageMime(15);ProductBase/ProductCreate(20-91);ProductUpdate(94-126);ProductResponseincl.image_url/image2_urlcomputed fields (129-189);ImageUploadResponse(256-262).alchemy_labs/backend/services/commerce/rich_text.py(93 lines, whole file in scope):ALLOWED_TAGS/ALLOWED_ATTRIBUTES/ALLOWED_URL_SCHEMES/LINK_REL(21-70);sanitize_product_html(73-93).alchemy_labs/backend/api/dependencies.py:VerifiedUser/DatabaseSessionaliases (565-572);get_current_verified_user(351-362);_get_workspace_id/WorkspaceID(680-779).alchemy_labs/backend/services/storage/render_storage.py:RenderStorageService.__init__/_get_workspace_prefix(432-449);save_file(465),read_file_stream(527),file_exists(576),get_file_size(588).alchemy_labs/backend/app_fastapi.py: the router import and mount (53, 838).alchemy_labs/backend/migrations/versions/:20260426_add_commerce_module.py,20260609_0001_commerce_kind_infoproducts.py,20260903_0001_commerce_image_content_emdash.py,20260903_0003_commerce_second_image.py.- Tests:
alchemy_labs/backend/tests/test_commerce_images.py(643 lines, whole file in scope),alchemy_labs/backend/tests/test_commerce_phase1.py(lines 616-660 only, the adjacent asset-attach tests cited under When to use). - Frontend, cited for context only (not this recipe's scope):
alchemy_labs/frontend/src/pages/commerce/ProductEditor.tsx,pages/commerce/BuyPage.tsx,components/commerce/adapter.ts(imagesFromApi, 150-179).
Deviations of the reference from an ideal implementation, dated 2026-09-12: no test exercises the create/update 400 for a non-pimg_ image id (Tests, Gotchas); no rate limit on the image upload or the three public stream routes; a failed storage write on upload returns no diagnosable detail to the caller, only a server log line; the migration chain for this feature is not contiguous (interleaved with unrelated revisions), which is unremarkable for the shared migrations directory but worth knowing before assuming "four commerce migrations" is a clean, isolable unit.
Acceptance checks
Commands a verifier runs without the author; I have not executed these against a live environment for this recipe (read-only source access), so treat the expected output as derived from reading the source, not as a measured run.
grep -n "^_PRODUCT_IMAGE_ID_RE = re.compile" -A 4 alchemy_labs/backend/api/routers/commerce_router.pyshows the pattern anchored with\Z(not$), matchingpimg_<8-4-4-4-12 hex>.<1-8 alnum ext>.grep -n "_require_product_image_id(" alchemy_labs/backend/api/routers/commerce_router.pyprints 6 lines: thedefitself (80) plus 5 call sites - two increate_product(106, 107, one per image slot), two inupdate_product(199, 201), one in the gallery routepublic_get_product_image_by_id(502).- From
alchemy_labs/backend, with a scratch Postgres migrated to head:python -m pytest tests/test_commerce_images.py -q --no-cov -o addopts="" -p no:cacheprovider- expected to pass all tests in the file (I did not run this;grep -c "^def test_" tests/test_commerce_images.pyprints32). grep -n "clean_content_tags=_CLEAN_CONTENT_TAGS" alchemy_labs/backend/services/commerce/rich_text.pyconfirms<script>/<style>drop their content, not just their tags - the propertytest_sanitize_strips_script_tag_and_its_contentasserts on.grep -n "ON DELETE RESTRICT\|ondelete='RESTRICT'" alchemy_labs/backend/models/commerce_models.pyshowsOrder.product_id's FK, the database-level guarantee behinddelete_product's archive-vs-hard-delete branch.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.