Prima Get an API key

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
  • alchemy_labs/backend/api/routers/commerce_router.py
  • alchemy_labs/backend/models/commerce_models.py
  • alchemy_labs/backend/schemas/commerce_schemas.py
  • alchemy_labs/backend/services/commerce/rich_text.py
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

RouteAuthTenancyStatus codesNotes
POST /api/products (create_product, commerce_router.py:97-146)VerifiedUserworkspace_id from WorkspaceID, stamped on the row201; 422 on schema validation (e.g. missing interval for a subscription); 400 if image_file_id/image2_file_id fails _require_product_image_idcontent_html sanitized before insert; image ids validated before insert
GET /api/products (list_products, :149-164)VerifiedUserfiltered Product.workspace_id == workspace_id200optional is_active filter; archived products (metadata.archived) are always excluded regardless of is_active
GET /api/products/{product_id} (get_product, :167-179)VerifiedUserand_(id==, workspace_id==)200; 404cross-workspace id 404s, does not leak existence
PUT /api/products/{product_id} (update_product, :182-212)VerifiedUserand_(id==, workspace_id==)200; 404; 400 on either image id failing _require_product_image_idpayload.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)VerifiedUserand_(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)VerifiedUserkeyed 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 failsproduct-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)noneworkspace derived from the product row200 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)nonesamesamesecondary slot, identical contract
GET /api/commerce/public/product/{product_id}/images/{file_id} (public_get_product_image_by_id, :492-518)nonesame, 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)VerifiedUserand_(id==, workspace_id==)204; 404clears 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)VerifiedUsersame204; 404secondary 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_id through 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 only POST /api/commerce/images mints ids of this shape and every other id format in the system (a bare infoproduct file_id) cannot match it (module comment, :64-77; no dedicated test found exercising the rejection path itself - see Gotchas).
  • content_html is never stored except through sanitize_product_html, on both the create insert and the update setattr loop - 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).
  • None means "field not supplied, leave the column alone"; ""/whitespace-only means "the operator cleared it" - the sanitizer returns None for None and '' for blank, and update_product's exclude_unset=True is 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 in list_products regardless of the is_active filter, though they still resolve by id (delete_product docstring, :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_image docstring, :461-467; not the same rule as public_get_product, which DOES require is_active for 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, unrelated down_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_001 docstring): the second image slot exists because a real client's content collection needed a separate card/thumbnail image distinct from the hero, and a single image_* 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_image logs 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); ProductResponse incl. image_url/image2_url computed 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/DatabaseSession aliases (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.

  1. grep -n "^_PRODUCT_IMAGE_ID_RE = re.compile" -A 4 alchemy_labs/backend/api/routers/commerce_router.py shows the pattern anchored with \Z (not $), matching pimg_<8-4-4-4-12 hex>.<1-8 alnum ext>.
  2. grep -n "_require_product_image_id(" alchemy_labs/backend/api/routers/commerce_router.py prints 6 lines: the def itself (80) plus 5 call sites - two in create_product (106, 107, one per image slot), two in update_product (199, 201), one in the gallery route public_get_product_image_by_id (502).
  3. 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.py prints 32).
  4. grep -n "clean_content_tags=_CLEAN_CONTENT_TAGS" alchemy_labs/backend/services/commerce/rich_text.py confirms <script>/<style> drop their content, not just their tags - the property test_sanitize_strips_script_tag_and_its_content asserts on.
  5. grep -n "ON DELETE RESTRICT\|ondelete='RESTRICT'" alchemy_labs/backend/models/commerce_models.py shows Order.product_id's FK, the database-level guarantee behind delete_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.