Open source (MIT), version 0.0.1. The WooCommerce-equivalent for EmDash, Cloudflare's TypeScript CMS.
The reference storefront running locally, with prices and stock served by the commerce service — this is what the quick start below gives you.
Otta turns an EmDash site into a store. It ships as three parts:
- Otta plugin — a sandbox-clean EmDash plugin: storefront routes, content-sync
hooks, cart/checkout orchestration, an admin console (pricing & inventory, orders,
reports, settings), and x402 gating for digital goods. Talks to the commerce service
over HTTP only (
network:request+allowedHosts). The CMS owns content; every commercial field lives in the commerce service and is edited in the admin console. - Otta commerce service — a standalone Node/Hono + Postgres service that owns all money and stock truth: catalog, inventory, cart, checkout, orders, customers, payments, tax, shipping, discounts, entitlements, reporting, and webhooks.
- The reference site (
sites/staging) — a default EmDash site with the plugin already registered, so there's something to actually run. It's the storefront in the screenshot above and what the quick start boots: product listing pages, cart, and the admin console. Treat it as the worked example to copy from when wiring Otta into your own site — it covers catalog + cart only today (see Status).
A full store on your laptop — no Cloudflare account, no deploy. The site's D1 content database and R2 media bucket are emulated locally by the Astro Cloudflare adapter; only the commerce Postgres is real.
pnpm install
# 1. Commerce database — any Postgres works; a throwaway container is fastest.
# (Host port 55432, not 5432, so it can't collide with a local Postgres.)
docker run -d --name otta-pg \
-e POSTGRES_USER=otta -e POSTGRES_PASSWORD=otta -e POSTGRES_DB=otta \
-p 127.0.0.1:55432:5432 postgres:16
# 2. Commerce service — migrates itself forward on boot, then listens on :3000.
PG_CONNECTION_STRING=postgres://otta:otta@127.0.0.1:55432/otta \
pnpm dlx tsx@4 packages/service/src/index.ts# 3. Storefront + admin, in a second terminal.
COMMERCE_SERVICE_URL=http://127.0.0.1:3000 pnpm --filter @otta-sh/site-staging devCheck the service with curl http://127.0.0.1:3000/health → {"ok":true}. Then open the
dev-only setup bypass, which claims the site and applies the full seed including three
sample products:
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
The seed creates the three sample products as CMS content. Their prices and stock live in the commerce service, which the seed does not touch, so give them some:
# 4. Price, stock and activate the demo products (third terminal, or reuse the first).
# It reads the products' real ids from the CMS (matching the seed's slugs),
# then prices and activates each one in the commerce service.
SITE_URL=http://localhost:4321 COMMERCE_SERVICE_URL=http://127.0.0.1:3000 \
pnpm dlx tsx@4 sites/staging/scripts/seed-demo-commerce.ts/products now renders a priced catalog and add-to-cart takes a real inventory hold
against Postgres. Open Pricing & inventory in the admin to reprice, restock, or price
a product of your own — that page is the only place commercial fields are edited; the CMS
owns the title, description and images.
Two things to know: the service is run through tsx rather than its built dist bin
because the @otta-sh/* packages aren't published yet and their workspace export maps point
at TypeScript sources (#44); and this
storefront covers catalog + cart only — see Status.
To deploy this for free on Cloudflare Workers, follow
DEPLOYMENT.md §3.
EmDash's plugin sandbox has no atomic write, compare-and-set, or transaction. Plugins
get no direct database access — everything crosses a capability-scoped RPC bridge as JSON
copies — and ctx.storage is an unconditional upsert whose declared unique indexes are
silently downgraded. Any read-then-write spans two bridge calls and can interleave, so the
sandbox can't express a guarded update, a uniqueness constraint, or a multi-document
commit. Those are the ordinary building blocks of an order pipeline, so for now the
transactional database sits off to the side, in the commerce service.
The gap is closing. emdash-cms/emdash#2169
(ours, currently a draft) adds ctx.storage.<collection>.updateIf(id, { where, set?, delta? }) — a guarded UPDATE … RETURNING run inside the sandbox. Two sibling primitives,
not yet proposed upstream, cover the rest: an atomic insert that classifies unique
violations, and ctx.storage.batch([...]) for all-or-nothing multi-collection writes.
With all three, a @otta-sh/store-emdash adapter already passes the domain's full
InventoryStore contract in-process. Once orders, payments, webhooks, and reporting
follow, the split becomes a deployment choice rather than a correctness requirement.
- Product model = hybrid. Content (title, description, images, SEO, taxonomies)
lives in a native EmDash
productscollection; commercial data (price, SKU, stock, tax, shipping) lives in the commerce service. Link key = the CMS contentid. - Separate databases. Commerce Postgres is independent of the EmDash content DB (independent scaling; no cross-DB joins — joined in app code at render time).
- Ports and adapters.
@otta-sh/domainis pure (no IO); every store is a Kysely adapter dialect-parameterized over better-sqlite3 (dev) and Postgres (CI/prod). The REST API in@otta-sh/servicemirrors the domain ports 1:1, and the same client-side contract suite runs over the wire so the HTTP format can't drift from the port. - Pluggable payments. Stripe (async webhook) and x402 (HTTP-402 at the page layer)
behind one
PaymentGatewayinterface. - Deployment. Runs on Cloudflare Workers via Hyperdrive over Neon Postgres, with
cron sweeps for cart/reservation expiry. First-party sites may register the plugin
trusted (in-process) to stay on the Workers free plan — the plugin still passes the
full workerd sandbox suite on every CI run, which is the binding contract (ADR-0006).
Step-by-step bootstrap guide:
DEPLOYMENT.md.
| Package | What it is |
|---|---|
@otta-sh/domain |
Pure ports, use-cases, branded money types, contract-test suites. No IO. |
@otta-sh/service |
Thin Hono REST API + Cloudflare Worker entry mirroring the domain ports. |
@otta-sh/store-postgres |
Kysely store adapters (better-sqlite3 local, pg CI/prod) + forward-only migrations. |
@otta-sh/payments-stripe |
Stripe PaymentGateway adapter (async-webhook, raw-body HMAC). |
@otta-sh/payments-x402 |
x402 PaymentGateway adapter (synchronous page-gate, facilitator-verified). |
@otta-sh/plugin |
The EmDash plugin: storefront routes, admin console, content-sync hooks. |
sites/staging |
Staging storefront + admin — EmDash on Cloudflare Workers, plugin registered trusted. |
Design decisions live in adr/; development practices in
DEVELOPMENT.md; the agent-facing contract in CLAUDE.md.
pnpm workspace · tsdown builds · vitest tests · oxfmt (tabs) · oxlint (type-aware) · strict TypeScript.
pnpm lint # oxlint + domain-purity dependency check
pnpm typecheck # tsc -b
pnpm test # vitest (better-sqlite3 by default)
pnpm format # oxfmt, tabsThe concurrency tests are Postgres-required — better-sqlite3 serializes writes in one
process, so it verifies the SQL is correct, not that it's race-safe under contention. See
DEVELOPMENT.md for the TDD / contract-first workflow and commerce invariants.
v0.0.1 — first open-source release. The @otta-sh/* packages are all at 0.0.1 and are
not published to npm yet; consume them from the workspace.
The commerce service is feature-complete (Phases 0–7 merged): catalog, inventory, cart, checkout, orders, customers with magic-link auth, Stripe + x402 payments, tax, shipping, discounts, entitlements, reporting, and settings.
The reference storefront (sites/staging) deliberately covers catalog + cart
only. The checkout / payment / download pages
(#27) and the customer account pages are
not built yet — so today you get a browsable catalog and carts with real inventory
holds, but completing a purchase end-to-end means building those pages or driving the
service API directly.
MIT
