@blamejs/blamejs-shop 0.2.15 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/README.md +2 -2
  3. package/lib/admin.js +81 -3
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,7 +8,11 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
- - v0.2.15 (2026-05-28) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* — `/admin/tax-rates` pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* `/admin/shipping`create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* `/admin/discounts` — create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).
11
+ - v0.2.18 (2026-05-29) — **Admin: a clear "payments aren't live" warning, and void a gift card.** Two admin-console fixes. The landing page now warns that payments aren't live whenever Stripe isn't configured independently of the setup wizard so an operator who finishes the identity wizard isn't misled into thinking customers can check out when they still can't. And gift cards can now be voided from the console: the card detail screen gets a confirm-gated Void action for active cards, composing the existing void capability. **Added:** *Gift-card void* — The gift-card detail screen shows a confirm-gated "Void card" action for active cards. Voiding goes through a confirmation step and is content-negotiated like the rest of the console (a bearer-token client gets the voided card as JSON; already-redeemed cards are refused). **Changed:** *Payment-readiness banner on the admin landing* The admin landing shows a "Payments aren't live yet customers can't check out. See Integrations" banner whenever Stripe isn't enabled, gated on live payment readiness rather than on whether the setup wizard was marked complete. The existing not-set-up identity banner is unchanged; both can appear together.
12
+
13
+ - v0.2.17 (2026-05-29) — **Manage your passkeys and edit your profile from your account.** Signed-in customers can now manage their own passkeys and edit their profile, instead of only being able to enrol a passkey at registration. A new account screen lists each enrolled passkey (device transport + when it was added) with an add-another flow and a confirm-gated revoke; revocation is scoped to the signed-in account and refuses to remove your last sign-in method when no other (OAuth) method is linked, so you can't lock yourself out. A separate profile screen edits the display name. **Added:** *Passkey management* — `/account/passkeys` lists your enrolled passkeys, lets you register an additional one, and revoke one you no longer use (behind a confirmation step). Revocation only ever affects your own credentials, and the last remaining sign-in method is protected against removal. · *Profile editing* — `/account/profile` edits your display name with a confirmation notice. Account links for both screens were added to the account dashboard.
14
+
15
+ - v0.2.16 (2026-05-29) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* — `/admin/tax-rates` — pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* — `/admin/shipping` — create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* — `/admin/discounts` — create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).
12
16
 
13
17
  - v0.2.14 (2026-05-28) — **Manage products end-to-end from the admin console — variants, prices, and images.** The admin console can now build a complete, sellable product without dropping to the JSON API. Previously creating a product only captured its name, slug, status, and description — there was no way to add a variant, set a price, or attach an image from the console, so a product made there was not sellable. Each product now has a management screen (reachable from the product list) with variant create/edit/delete, per-variant price setting plus price history, and media attach / upload / delete, with confirmation steps for deletes. The screen is content-negotiated: a bearer-token API client still gets JSON (now including the full product model), a signed-in browser gets the management UI. **Added:** *Product management screen in the admin console* — Each product's title in the admin product list now links to a detail screen where an operator can edit the product's fields, add/edit/remove variants, set a variant's price and see its price history, and attach/upload/remove product images — the full path to a sellable product, all from the console. Destructive actions (remove variant/image) go through a confirmation step. · *Catalog read helpers* — `prices.currencies(variantId)` (the distinct currencies a variant is priced in) and `media.get(id)` (single media row) were added to support the screen. **Changed:** *`GET /admin/products/:id` is content-negotiated* — It previously returned JSON only; it now returns the management screen to a signed-in browser and a fuller JSON model (product + variants + prices + media) to a bearer-token client. The existing variant/price/media write endpoints are unchanged in contract — the new browser forms route to the same handlers.
14
18
 
package/README.md CHANGED
@@ -62,7 +62,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
62
62
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
63
63
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
64
64
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
- | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
65
+ | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront; signed-in customers manage their own passkeys (`/account/passkeys` — list, add another, confirm-gated revoke scoped to the account with a last-sign-in-method guard) and edit their profile (`/account/profile`). |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
67
  | **`lib/product-qa.js`** | Customer questions and operator/customer answers per product, operator-moderated, distinct from the rating-based reviews. A signed-in shopper asks at `/products/:slug/question`; questions land `pending` and surface only after approval. Author identity is the customer id (verified against the customers primitive) or a hash-only email — the raw address is never stored. The product page renders approved questions with their approved answers (seller / customer / system badge, pinned 'top answer' first) in both the edge and container paths. `/admin/questions` is the moderation console: the cross-product queue (`listQuestionsByStatus`), and a per-question detail to approve / reject the question, post the seller answer (`submitAnswer`), approve / reject / pin answers. |
68
68
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
@@ -87,7 +87,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
87
87
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
88
88
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
89
89
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
90
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
90
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
91
91
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
92
92
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
93
93
 
package/lib/admin.js CHANGED
@@ -3062,6 +3062,7 @@ function mount(router, deps) {
3062
3062
  // once and clear the cookie. A subsequent reload reveals nothing —
3063
3063
  // the code never lands in the URL / history / access log.
3064
3064
  var issuedCode = _takeGiftCardReveal(req, res, req.params.id);
3065
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3065
3066
  var history = [];
3066
3067
  if (giftCardLedger) {
3067
3068
  try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
@@ -3070,9 +3071,66 @@ function mount(router, deps) {
3070
3071
  _sendHtml(res, 200, renderAdminGiftCard({
3071
3072
  shop_name: deps.shop_name, nav_available: navAvailable, card: card,
3072
3073
  ledger: history, issued_code: issuedCode || undefined,
3074
+ voided: !!(url && url.searchParams.get("voided")),
3075
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for this card." : null,
3073
3076
  }));
3074
3077
  },
3075
3078
  ));
3079
+
3080
+ // Browser confirmation interstitial for void — voiding revokes a
3081
+ // money instrument (the remaining balance can no longer be redeemed),
3082
+ // so the console confirms before the real POST. Bearer clients skip
3083
+ // this and POST .../void directly (mirrors the webhook delete pattern).
3084
+ router.post("/admin/gift-cards/:id/void/confirm", _pageOrApi(false,
3085
+ R(async function (_req, res) {
3086
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/gift-cards/:id/void directly for the JSON API");
3087
+ }),
3088
+ async function (req, res) {
3089
+ var card = await giftcards.getById(req.params.id);
3090
+ if (!card) return _redirect(res, "/admin/gift-cards?err=1");
3091
+ if (card.status !== "active") return _redirect(res, "/admin/gift-cards/" + encodeURIComponent(req.params.id) + "?err=1");
3092
+ var cur = String(card.currency || "").toUpperCase();
3093
+ _sendHtml(res, 200, renderAdminConfirm({
3094
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "giftcards",
3095
+ heading: "Void this gift card?",
3096
+ consequence: "Voiding is permanent — the remaining balance can no longer be redeemed at checkout.",
3097
+ detail: "Card ••••" + card.code_hint + " with " + pricing.format(card.balance_minor, cur) + " remaining will be voided.",
3098
+ action: "/admin/gift-cards/" + _htmlEscape(req.params.id) + "/void",
3099
+ confirm_label: "Void card",
3100
+ cancel_href: "/admin/gift-cards/" + encodeURIComponent(req.params.id),
3101
+ }));
3102
+ },
3103
+ ));
3104
+
3105
+ // Void a card. Bearer → JSON (the voided row); browser form → void,
3106
+ // then PRG back to the card's detail page. `void` is idempotent on an
3107
+ // already-voided card and refuses a fully-redeemed one; both surfaces
3108
+ // translate that to a 4xx / err flag rather than a 500.
3109
+ router.post("/admin/gift-cards/:id/void", _pageOrApi(false,
3110
+ W("gift_card.void", async function (req, res) {
3111
+ var card;
3112
+ try { card = await giftcards.void(req.params.id, { reason: (req.body && req.body.reason) || undefined }); }
3113
+ catch (e) {
3114
+ if (e && e.code === "GIFTCARD_ALREADY_REDEEMED") return _problem(res, 409, "gift-card-redeemed", e.message);
3115
+ throw e;
3116
+ }
3117
+ if (!card) return _problem(res, 404, "gift-card-not-found");
3118
+ _json(res, 200, card);
3119
+ return { id: card.id };
3120
+ }),
3121
+ async function (req, res) {
3122
+ var card = null;
3123
+ try { card = await giftcards.void(req.params.id, { reason: (req.body && req.body.reason) || undefined }); }
3124
+ catch (e) {
3125
+ if (e && e.code === "GIFTCARD_ALREADY_REDEEMED") return _redirect(res, "/admin/gift-cards/" + encodeURIComponent(req.params.id) + "?err=1");
3126
+ if (!(e instanceof TypeError)) throw e;
3127
+ return _redirect(res, "/admin/gift-cards?err=1");
3128
+ }
3129
+ if (!card) return _redirect(res, "/admin/gift-cards?err=1");
3130
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift_card.void", outcome: "success", metadata: { id: req.params.id } });
3131
+ _redirect(res, "/admin/gift-cards/" + encodeURIComponent(req.params.id) + "?voided=1");
3132
+ },
3133
+ ));
3076
3134
  }
3077
3135
 
3078
3136
  // Issue a card from operator input, recording the opening balance as
@@ -3743,6 +3801,11 @@ function mount(router, deps) {
3743
3801
  _sendHtml(res, 200, renderAdminLanding({
3744
3802
  shop_name: deps.shop_name,
3745
3803
  setup_complete: await _setupComplete(),
3804
+ // Live payment status from the entry-point integration map. The
3805
+ // landing flags "payments aren't live" until Stripe is "enabled",
3806
+ // INDEPENDENT of the identity-only setup step — finishing the setup
3807
+ // wizard doesn't make checkout work if the Stripe secrets are unset.
3808
+ payments_live: ((deps.integrations || {}).stripe === "enabled"),
3746
3809
  nav_available: navAvailable,
3747
3810
  }));
3748
3811
  });
@@ -4135,8 +4198,14 @@ function renderAdminLanding(opts) {
4135
4198
  var setupBanner = opts.setup_complete
4136
4199
  ? ""
4137
4200
  : "<div class=\"banner banner--warn\">Your shop isn't set up yet. <a href=\"/admin/setup\">Finish setup &rarr;</a></div>";
4201
+ // Payments are gated on Stripe env secrets, not the identity-only setup
4202
+ // step — so this banner shows independently of `setup_complete`. Until
4203
+ // Stripe is live, checkout 404/503s even for a "finished" shop.
4204
+ var paymentsBanner = opts.payments_live
4205
+ ? ""
4206
+ : "<div class=\"banner banner--warn\">Payments aren't live yet — customers can't check out. <a href=\"/admin/integrations\">See Integrations &rarr;</a></div>";
4138
4207
  var body =
4139
- "<section>" + setupBanner +
4208
+ "<section>" + setupBanner + paymentsBanner +
4140
4209
  "<h2>Admin</h2>" +
4141
4210
  "<div class=\"nav-cards\">" +
4142
4211
  "<a class=\"nav-card\" href=\"/admin/setup\"><h3>Setup wizard</h3><p>Shop identity, currency, and contact details.</p></a>" +
@@ -5308,6 +5377,8 @@ function renderAdminGiftCard(opts) {
5308
5377
  ? "<div class=\"banner banner--ok\">Copy the code now — it is shown once and cannot be retrieved again: " +
5309
5378
  "<code class=\"order-id\">" + _htmlEscape(opts.issued_code) + "</code></div>"
5310
5379
  : "";
5380
+ var voidedBanner = opts.voided ? "<div class=\"banner banner--ok\">Gift card voided. Its remaining balance can no longer be redeemed.</div>" : "";
5381
+ var noticeBanner = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
5311
5382
 
5312
5383
  var statusCls = gc.status === "active" ? "paid" : (gc.status === "redeemed" ? "pending" : "cancelled");
5313
5384
  var summary =
@@ -5338,9 +5409,16 @@ function renderAdminGiftCard(opts) {
5338
5409
  ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Type</th><th scope=\"col\" class=\"num\">Amount</th><th scope=\"col\" class=\"num\">Balance after</th><th scope=\"col\">Detail</th><th scope=\"col\">When</th></tr></thead><tbody>" + ledgerRows + "</tbody></table></div>"
5339
5410
  : "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
5340
5411
 
5341
- var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
5412
+ // Void is offered only while the card is active a redeemed card has
5413
+ // no balance to revoke, a voided card is already revoked. The confirm
5414
+ // interstitial guards the destructive POST.
5415
+ var voidAction = gc.status === "active"
5416
+ ? "<form method=\"post\" action=\"/admin/gift-cards/" + _htmlEscape(gc.id) + "/void/confirm\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Void card</button></form>"
5417
+ : "";
5418
+
5419
+ var body = "<section><h2>Gift card</h2>" + issuedBanner + voidedBanner + noticeBanner + summary +
5342
5420
  "<h3 class=\"subhead subhead--sp-lg\">Ledger</h3>" + ledgerTable +
5343
- "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
5421
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a>" + voidAction + "</div></section>";
5344
5422
  return _renderAdminShell(opts.shop_name, "Gift card", body, "giftcards", opts.nav_available);
5345
5423
  }
5346
5424
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.15",
2
+ "version": "0.2.18",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -21,6 +21,10 @@
21
21
  "integrity": "sha384-XY3GHA5QDj/Nri03ZO1t7nsr9RGepZuanY45CTpFQnnTorQnygbtAVG4vVGfkNIP",
22
22
  "fingerprinted": "js/consent.59060d7723d050b2.js"
23
23
  },
24
+ "js/passkey-add.js": {
25
+ "integrity": "sha384-HSi+w0ampPZWcfPnnpV2Dkn/oGdgnyLe7QykGM5Sregud4CbH2KsU5Hb1FQQxJTy",
26
+ "fingerprinted": "js/passkey-add.c4b7350f2cac5477.js"
27
+ },
24
28
  "js/passkey-login.js": {
25
29
  "integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
26
30
  "fingerprinted": "js/passkey-login.f3d6787bdd5e20df.js"
package/lib/customers.js CHANGED
@@ -505,14 +505,81 @@ function create(opts) {
505
505
  return row;
506
506
  },
507
507
 
508
- removePasskey: async function (passkeyId) {
508
+ // Revoke an enrolled credential, scoped to its owner. The customer
509
+ // id is part of the WHERE clause (not just the lookup) so a forged or
510
+ // guessed passkey id belonging to ANOTHER customer is a silent no-op
511
+ // rather than a cross-customer deletion — the route layer hands us the
512
+ // authed customer id, and a credential only disappears when both keys
513
+ // agree. Idempotent: returns true when a row was removed, false when
514
+ // nothing matched (already-removed id, or an id owned by someone else).
515
+ removePasskey: async function (customerId, passkeyId) {
516
+ _uuid(customerId, "customer id");
509
517
  _uuid(passkeyId, "passkey id");
510
- var row = (await query("SELECT customer_id FROM customer_passkeys WHERE id = ?1", [passkeyId])).rows[0];
518
+ var row = (await query(
519
+ "SELECT id FROM customer_passkeys WHERE id = ?1 AND customer_id = ?2",
520
+ [passkeyId, customerId],
521
+ )).rows[0];
511
522
  if (!row) return false;
512
- await query("DELETE FROM customer_passkeys WHERE id = ?1", [passkeyId]);
513
- await query("UPDATE customers SET updated_at = ?1 WHERE id = ?2", [_now(), row.customer_id]);
523
+ await query(
524
+ "DELETE FROM customer_passkeys WHERE id = ?1 AND customer_id = ?2",
525
+ [passkeyId, customerId],
526
+ );
527
+ await query("UPDATE customers SET updated_at = ?1 WHERE id = ?2", [_now(), customerId]);
514
528
  return true;
515
529
  },
530
+
531
+ // Mutate a customer's editable profile fields. v1 covers display_name
532
+ // only — the one field a customer can safely change without a
533
+ // verification round trip.
534
+ //
535
+ // Email is deliberately NOT updatable here. The address is stored
536
+ // hash-only (raw never lands in D1) AND it's the OAuth account-linking
537
+ // key: signInWithOIDC links a federated identity to an existing
538
+ // customer ONLY on a provider-verified email match, so silently
539
+ // re-pointing email_hash would let a customer claim a federated login
540
+ // that belongs to a different address — an account-takeover vector.
541
+ // A correct email change needs a proof-of-control ceremony (mail a
542
+ // one-time code to the NEW address, confirm, only then re-derive the
543
+ // hash). No transactional-mail surface is wired into the storefront
544
+ // yet, so shipping an unverified email change would be the
545
+ // half-measure the no-MVP rule forbids. Re-open when an outbound-mail
546
+ // primitive lands: add `verifyEmailChange` (issue + confirm token) and
547
+ // let `update` accept `email` only behind a confirmed token. A caller
548
+ // that passes `email` today is refused with a typed error so the gap
549
+ // is loud, never silent.
550
+ update: async function (id, patch) {
551
+ _uuid(id, "customer id");
552
+ if (!patch || typeof patch !== "object") {
553
+ throw new TypeError("customers.update: patch object required");
554
+ }
555
+ if (Object.prototype.hasOwnProperty.call(patch, "email")) {
556
+ var emailErr = new Error("customers.update: email cannot be changed without a verification ceremony — not supported in this build");
557
+ emailErr.code = "EMAIL_CHANGE_UNSUPPORTED";
558
+ throw emailErr;
559
+ }
560
+ var sets = [];
561
+ var params = [];
562
+ var p = 1;
563
+ if (Object.prototype.hasOwnProperty.call(patch, "display_name")) {
564
+ var displayName = _displayName(patch.display_name);
565
+ sets.push("display_name = ?" + p); params.push(displayName); p += 1;
566
+ }
567
+ if (sets.length === 0) {
568
+ throw new TypeError("customers.update: nothing to update (display_name is the only mutable field)");
569
+ }
570
+ var owner = (await query("SELECT id FROM customers WHERE id = ?1", [id])).rows[0];
571
+ if (!owner) {
572
+ throw new TypeError("customers.update: customer " + id + " not found");
573
+ }
574
+ var ts = _now();
575
+ sets.push("updated_at = ?" + p); params.push(ts); p += 1;
576
+ params.push(id);
577
+ await query(
578
+ "UPDATE customers SET " + sets.join(", ") + " WHERE id = ?" + p,
579
+ params,
580
+ );
581
+ return (await query("SELECT * FROM customers WHERE id = ?1", [id])).rows[0];
582
+ },
516
583
  };
517
584
  return api;
518
585
  }