@blamejs/blamejs-shop 0.1.15 → 0.1.17
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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/lib/admin.js +139 -17
- package/lib/catalog.js +16 -0
- package/lib/storefront.js +131 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.17 (2026-05-25) — **Admin console — an inventory screen.** Inventory joins the admin console. `/admin/inventory` lists stock per SKU — on hand, held, and available — with a low-stock filter, restocks a SKU, sets or clears its per-SKU low-stock threshold, and tracks a new SKU, all from a signed-in browser. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Inventory management screen* — `/admin/inventory` renders the inventory table (SKU, on-hand, held, available) with a low-stock filter (`?low=1`) that surfaces SKUs at or below their threshold, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each row's form restocks (add quantity) and sets the low-stock threshold (blank clears it) in one submit, and a form below tracks a new SKU. The create / restock JSON API is unchanged for tooling; the browser forms post and redirect (PRG), and a bad SKU is a no-op notice rather than a 500. · *catalog.inventory.list* — `catalog.inventory.list({ limit, low_only })` returns inventory rows (SKU ascending), optionally only those at or below their configured low-stock threshold — the read backing the console list and the JSON endpoint. **Changed:** *Console nav gains Inventory* — The signed-in admin nav now includes Inventory alongside Products, Orders, Returns, and Reviews. The `/admin/inventory` list and the create / restock endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token on these paths now returns the sign-in form on a GET and redirects on a write, matching the other console screens.
|
|
12
|
+
|
|
13
|
+
- v0.1.16 (2026-05-25) — **PayPal express checkout — the on-page button.** PayPal checkout is now usable from the storefront. When PayPal is configured, the checkout page shows a native PayPal button (distinct from PayPal-through-Stripe): it opens a PayPal order for the current cart, the buyer approves in the PayPal popup, and the order is captured and marked paid. A verified PayPal webhook is the asynchronous backstop. This completes the native PayPal integration on top of the adapter and checkout orchestration shipped in the previous two releases. Card / Stripe checkout is unchanged. **Added:** *PayPal button + create/capture routes on the storefront* — The checkout page renders a PayPal button when `PAYPAL_CLIENT_ID` is configured. Its create step posts to `POST /checkout/paypal/create` (prices the cart, opens a PayPal order, persists the local order pending) and its approve step posts to `POST /checkout/paypal/capture` (captures and advances the order to paid, then redirects to the order page). Both validate input and never 500 on a missing cart or id. The buttons collect the same shipping fields as the card form. · *PayPal webhook endpoint* — `POST /api/webhooks/paypal` is the asynchronous backstop for captures completed or refunded out of band. The container verifies each event server-to-server through PayPal's verify-webhook-signature API (no edge HMAC pre-check, unlike Stripe), then advances the order; re-deliveries are idempotent. Point a PayPal webhook at `/api/webhooks/paypal`. **Changed:** *PayPal listed as configurable; CSP note* — The integrations status page and README document PayPal as a first-class checkout option once configured. As with the Stripe pay page's `js.stripe.com`, operators must allow `www.paypal.com` in their Content-Security-Policy `script-src` / `frame-src` for the PayPal SDK and approval popup.
|
|
14
|
+
|
|
11
15
|
- v0.1.15 (2026-05-25) — **PayPal checkout orchestration.** Checkout can now run a PayPal order end to end, building on the adapter from the previous release. The orchestrator prices the cart, opens a PayPal order and persists the local order as pending, captures it after the buyer approves and advances the order to paid, and has a webhook backstop for captures completed or refunded out of band. Wired in when a PayPal app's credentials are present, and surfaced on the integrations status page. The storefront button and routes that drive this from the pay page come next; card / Stripe checkout is unchanged. **Added:** *checkout PayPal methods* — With a PayPal adapter wired (`paypal` dep), checkout gains three methods. `createPaypalOrder({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key, return_url?, cancel_url? })` prices the cart, opens a PayPal Orders-v2 order, persists the local order in `pending` with the PayPal order id linked, and marks the cart converted. `capturePaypalOrder(paypalOrderId)` captures the approved order and advances the local order to `paid` (idempotent — a retry or a webhook that beat it won't double-transition). `handlePaypalEvent({ headers, rawBody })` is the asynchronous backstop: it verifies the event through PayPal, then maps `PAYMENT.CAPTURE.COMPLETED` → paid and `PAYMENT.CAPTURE.REFUNDED` → refunded, idempotent across re-deliveries. · *PayPal wired from configuration* — When `PAYPAL_CLIENT_ID` + `PAYPAL_SECRET` are set (with `PAYPAL_ENV` and `PAYPAL_WEBHOOK_ID`), the server builds the PayPal adapter and passes it to checkout; the integrations status page lists PayPal as action-needed once card checkout is also live. Off until configured; the existing checkout flow is untouched.
|
|
12
16
|
|
|
13
17
|
- v0.1.14 (2026-05-25) — **PayPal payment adapter (Orders v2).** `payment.create({ adapter: "paypal", … })` is a new native PayPal adapter alongside the Stripe one — a from-scratch Orders-v2 client over the framework's SSRF-gated HTTP client, with no PayPal SDK dependency. It exchanges an OAuth2 client-credentials token (cached until it nears expiry), creates and captures orders, fetches and refunds them, and verifies inbound webhooks through PayPal's verify-webhook-signature API. This ships the adapter only; wiring it into the checkout flow and a storefront button comes next. Card / Stripe checkout is unchanged. **Added:** *PayPal Orders-v2 adapter* — `payment.create({ adapter: "paypal", clientId, secret, sandbox?, webhookId?, apiBase? })` returns `{ createOrder, captureOrder, getOrder, refund, verifyWebhook }`. `createOrder({ amount_minor, currency, order_id?, return_url?, cancel_url? })` opens a CAPTURE-intent order (amounts converted to PayPal's decimal-string major units, including 0-decimal currencies); `captureOrder(id)` finalizes it; `refund({ capture_id, amount_minor?, currency? })` refunds full or partial; `getOrder(id)` reads status. Every call carries an OAuth2 bearer token exchanged once and cached until ~2 minutes before expiry, and a `PayPal-Request-Id` for idempotency (plus the shared idempotency cache when a `query` handle is wired). `verifyWebhook(headers, rawBody, { webhookId })` confirms an inbound event through PayPal's verify-webhook-signature API and returns `{ ok, event }`. Outbound HTTP goes through `b.httpClient` — no `paypal` npm dependency. Off until the operator supplies credentials; the Stripe adapter and existing checkout are unchanged.
|
package/README.md
CHANGED
|
@@ -57,9 +57,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
57
57
|
| **`lib/pricing.js`** | Pure-function money math — `lineTotal`, `subtotal`, `totals`, `format`. Multi-currency refused, banker's-style rounding, locale-aware via `Intl.NumberFormat`. |
|
|
58
58
|
| **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
|
|
59
59
|
| **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
|
|
60
|
-
| **`lib/payment.js`** |
|
|
60
|
+
| **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
|
|
61
61
|
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
|
|
62
|
-
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates PaymentIntent + persists order
|
|
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 — 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
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. |
|
|
@@ -72,7 +72,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
72
72
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
|
73
73
|
| **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
|
|
74
74
|
| **`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. |
|
|
75
|
-
| **`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; **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; **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. The Returns and Reviews 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. |
|
|
75
|
+
| **`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; **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; **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. The Returns and Reviews 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. |
|
|
76
76
|
| **`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. |
|
|
77
77
|
| **`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. |
|
|
78
78
|
|
|
@@ -163,10 +163,10 @@ variables. A signed-in operator can see the live on/off status of each at
|
|
|
163
163
|
| **Apple Pay & Google Pay** | One-tap wallet buttons (Express Checkout Element) on the pay page. | Stripe (above) **+** register each web domain: `POST /admin/payment-method-domains {"domain_name":"shop.example.com"}` | Stripe performs Apple merchant validation and hosts the association file — **no Apple Developer account needed**. Apex, `www`, and each subdomain register separately; a live-mode registration also covers sandbox. |
|
|
164
164
|
| **Sign in with Google** | A *Continue with Google* button on `/account/login` (OIDC). | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `SHOP_ORIGIN` (e.g. `https://shop.example.com`) | Create a Google Cloud **OAuth 2.0 Web** client; add `<SHOP_ORIGIN>/account/auth/google/callback` as an Authorized redirect URI; consent-screen scopes `openid email profile`. The button appears only when all three are set. |
|
|
165
165
|
| **Sign in with Apple** | A *Continue with Apple* button on `/account/login` (OIDC). | `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID` (your **Services ID**), `APPLE_PRIVATE_KEY` (the `.p8` key contents), `SHOP_ORIGIN` | Needs an **Apple Developer Program** membership. Create a Services ID, enable Sign in with Apple, add `<SHOP_ORIGIN>/account/auth/apple/callback` as a Return URL, and create a Sign-in-with-Apple key (`.p8`). The shop mints Apple's ES256 client secret from the key at boot (re-minted each deploy, inside Apple's 6-month window). The button appears only when all five are set. |
|
|
166
|
+
| **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
|
|
166
167
|
|
|
167
168
|
**Planned / not available:**
|
|
168
169
|
|
|
169
|
-
- **PayPal** — a separate adapter (Orders v2 + its own webhook); planned.
|
|
170
170
|
- **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
|
|
171
171
|
non-Shopify store: the credentials only issue from a Shopify Admin and payment
|
|
172
172
|
flows through Shopify Payments. There is no path to enable it here.
|
package/lib/admin.js
CHANGED
|
@@ -400,22 +400,90 @@ function mount(router, deps) {
|
|
|
400
400
|
|
|
401
401
|
// ---- inventory ------------------------------------------------------
|
|
402
402
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
403
|
+
// Inventory list — JSON for the bearer token, HTML console for a signed-in
|
|
404
|
+
// browser. `?low=1` filters to SKUs at/below their low-stock threshold.
|
|
405
|
+
router.get("/admin/inventory", _pageOrApi(true,
|
|
406
|
+
R(async function (req, res) {
|
|
407
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
408
|
+
var page = await catalog.inventory.list({ low_only: !!(url && url.searchParams.get("low")), limit: 500 });
|
|
409
|
+
_json(res, 200, page);
|
|
410
|
+
}),
|
|
411
|
+
async function (req, res) {
|
|
412
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
413
|
+
var low = !!(url && url.searchParams.get("low"));
|
|
414
|
+
var page = await catalog.inventory.list({ low_only: low, limit: 500 });
|
|
415
|
+
_sendHtml(res, 200, renderAdminInventory({
|
|
416
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
417
|
+
inventory: page.rows || [], low: low,
|
|
418
|
+
notice: url && url.searchParams.get("err") ? "That SKU wasn't found — nothing was changed." : null,
|
|
419
|
+
updated: !!(url && url.searchParams.get("updated")),
|
|
420
|
+
created: !!(url && url.searchParams.get("created")),
|
|
421
|
+
}));
|
|
422
|
+
},
|
|
423
|
+
));
|
|
410
424
|
|
|
411
|
-
router.post("/admin/inventory
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
425
|
+
router.post("/admin/inventory", _pageOrApi(false,
|
|
426
|
+
W("inventory.create", async function (req, res) {
|
|
427
|
+
var body = req.body || {};
|
|
428
|
+
if (!body.sku) throw new TypeError("admin.inventory.create: body.sku required");
|
|
429
|
+
var inv = await catalog.inventory.create(body.sku, body);
|
|
430
|
+
_json(res, 201, inv);
|
|
431
|
+
return Object.assign({ id: body.sku }, inv);
|
|
432
|
+
}),
|
|
433
|
+
async function (req, res) {
|
|
434
|
+
var body = req.body || {};
|
|
435
|
+
try {
|
|
436
|
+
if (!body.sku) throw new TypeError("sku required");
|
|
437
|
+
await catalog.inventory.create(body.sku, { stock_on_hand: parseInt(body.stock_on_hand, 10) || 0 });
|
|
438
|
+
} catch (e) {
|
|
439
|
+
if (e instanceof TypeError || /exists|duplicate|UNIQUE/i.test(e.message || "")) {
|
|
440
|
+
var page = await catalog.inventory.list({ limit: 500 });
|
|
441
|
+
return _sendHtml(res, 400, renderAdminInventory({
|
|
442
|
+
shop_name: deps.shop_name, nav_available: navAvailable, inventory: page.rows || [],
|
|
443
|
+
notice: (e && e.message) || "Couldn't create that SKU.",
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
throw e;
|
|
447
|
+
}
|
|
448
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.create", outcome: "success", metadata: { sku: body.sku } });
|
|
449
|
+
_redirect(res, "/admin/inventory?created=1");
|
|
450
|
+
},
|
|
451
|
+
));
|
|
452
|
+
|
|
453
|
+
router.post("/admin/inventory/:sku/restock", _pageOrApi(false,
|
|
454
|
+
W("inventory.restock", async function (req, res) {
|
|
455
|
+
var qty = parseInt((req.body || {}).qty, 10);
|
|
456
|
+
if (!Number.isFinite(qty)) throw new TypeError("admin.inventory.restock: body.qty required (integer)");
|
|
457
|
+
var inv = await catalog.inventory.restock(req.params.sku, qty);
|
|
458
|
+
if (!inv) return _problem(res, 404, "inventory-not-found");
|
|
459
|
+
_json(res, 200, inv);
|
|
460
|
+
return Object.assign({ id: req.params.sku }, inv);
|
|
461
|
+
}),
|
|
462
|
+
async function (req, res) {
|
|
463
|
+
// Browser row form: restock by qty (when > 0) and/or set the low-stock
|
|
464
|
+
// threshold (when the field is non-empty; blank clears it). A bad sku is
|
|
465
|
+
// a no-op notice, never a 500.
|
|
466
|
+
var body = req.body || {};
|
|
467
|
+
var sku = req.params.sku;
|
|
468
|
+
var changed = false;
|
|
469
|
+
try {
|
|
470
|
+
var qty = parseInt(body.qty, 10);
|
|
471
|
+
if (Number.isFinite(qty) && qty > 0) { if (await catalog.inventory.restock(sku, qty)) changed = true; }
|
|
472
|
+
if (Object.prototype.hasOwnProperty.call(body, "threshold")) {
|
|
473
|
+
var raw = String(body.threshold).trim();
|
|
474
|
+
var threshold = raw === "" ? null : parseInt(raw, 10);
|
|
475
|
+
if (threshold === null || (Number.isInteger(threshold) && threshold >= 0)) {
|
|
476
|
+
if (await catalog.inventory.setThreshold(sku, threshold)) changed = true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
} catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
480
|
+
// restock / setThreshold return null for an unknown SKU — don't report
|
|
481
|
+
// success on a stale/tampered form to a non-existent SKU.
|
|
482
|
+
if (!changed) return _redirect(res, "/admin/inventory?err=1");
|
|
483
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.restock", outcome: "success", metadata: { sku: sku } });
|
|
484
|
+
_redirect(res, "/admin/inventory?updated=1");
|
|
485
|
+
},
|
|
486
|
+
));
|
|
419
487
|
|
|
420
488
|
// Per-SKU low-stock threshold. Body `{ threshold }` — null clears.
|
|
421
489
|
router.patch("/admin/inventory/:sku/threshold", W("inventory.set_threshold", async function (req, res) {
|
|
@@ -1497,6 +1565,9 @@ var DASHBOARD_LAYOUT =
|
|
|
1497
1565
|
" .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
|
|
1498
1566
|
" .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
|
|
1499
1567
|
" .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
|
|
1568
|
+
" .inv-row-form { display:flex; gap:.4rem; align-items:center; }\n" +
|
|
1569
|
+
" .inv-row-form input { padding:.4rem .5rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
|
|
1570
|
+
" tr.row--low td { background:#fff8e1; }\n" +
|
|
1500
1571
|
" .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
|
|
1501
1572
|
" .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
|
|
1502
1573
|
" .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
|
|
@@ -1677,6 +1748,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
1677
1748
|
{ key: "home", href: "/admin", label: "Home" },
|
|
1678
1749
|
{ key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
|
|
1679
1750
|
{ key: "products", href: "/admin/products", label: "Products" },
|
|
1751
|
+
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
1680
1752
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
1681
1753
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
1682
1754
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
@@ -1785,8 +1857,8 @@ var INTEGRATIONS_CATALOG = [
|
|
|
1785
1857
|
set: "GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/google/callback as a Google OAuth redirect URI." },
|
|
1786
1858
|
{ key: "apple_signin", name: "Sign in with Apple", enables: "A “Continue with Apple” button on the account login page.",
|
|
1787
1859
|
set: "APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CLIENT_ID (your Services ID), APPLE_PRIVATE_KEY (the .p8 key contents), SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/apple/callback as a Return URL on the Services ID. Requires an Apple Developer Program membership." },
|
|
1788
|
-
{ key: "paypal", name: "PayPal checkout", enables: "
|
|
1789
|
-
set: "PAYPAL_CLIENT_ID, PAYPAL_SECRET (a PayPal REST app), PAYPAL_WEBHOOK_ID, PAYPAL_ENV (sandbox|live). Card checkout (Stripe) must be live too." },
|
|
1860
|
+
{ key: "paypal", name: "PayPal checkout", enables: "A native PayPal button on the checkout page (create / capture via PayPal Orders v2) — distinct from PayPal-through-Stripe.",
|
|
1861
|
+
set: "PAYPAL_CLIENT_ID, PAYPAL_SECRET (a PayPal REST app), PAYPAL_WEBHOOK_ID, PAYPAL_ENV (sandbox|live). Card checkout (Stripe) must be live too. Point a PayPal webhook at /api/webhooks/paypal." },
|
|
1790
1862
|
];
|
|
1791
1863
|
|
|
1792
1864
|
function renderAdminIntegrations(opts) {
|
|
@@ -2178,6 +2250,55 @@ function renderAdminReviews(opts) {
|
|
|
2178
2250
|
return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
|
|
2179
2251
|
}
|
|
2180
2252
|
|
|
2253
|
+
function renderAdminInventory(opts) {
|
|
2254
|
+
opts = opts || {};
|
|
2255
|
+
var rows = opts.inventory || [];
|
|
2256
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">SKU created.</div>" : "";
|
|
2257
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Inventory updated.</div>" : "";
|
|
2258
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
2259
|
+
|
|
2260
|
+
var chips = "<div class=\"order-filters\">" +
|
|
2261
|
+
"<a class=\"chip" + (opts.low ? "" : " chip--on") + "\" href=\"/admin/inventory\">All</a>" +
|
|
2262
|
+
"<a class=\"chip" + (opts.low ? " chip--on" : "") + "\" href=\"/admin/inventory?low=1\">Low stock</a>" +
|
|
2263
|
+
"</div>";
|
|
2264
|
+
|
|
2265
|
+
var body = rows.map(function (r) {
|
|
2266
|
+
var available = (r.stock_on_hand || 0) - (r.stock_held || 0);
|
|
2267
|
+
var th = r.low_stock_threshold;
|
|
2268
|
+
var isLow = th != null && available <= th;
|
|
2269
|
+
var thVal = th == null ? "" : String(th);
|
|
2270
|
+
return "<tr" + (isLow ? " class=\"row--low\"" : "") + ">" +
|
|
2271
|
+
"<td><code class=\"order-id\">" + _htmlEscape(r.sku) + "</code>" + (isLow ? " <span class=\"status-pill pending\">low</span>" : "") + "</td>" +
|
|
2272
|
+
"<td class=\"num\">" + _htmlEscape(String(r.stock_on_hand)) + "</td>" +
|
|
2273
|
+
"<td class=\"num\">" + _htmlEscape(String(r.stock_held)) + "</td>" +
|
|
2274
|
+
"<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
|
|
2275
|
+
"<td>" +
|
|
2276
|
+
"<form method=\"post\" action=\"/admin/inventory/" + _htmlEscape(r.sku) + "/restock\" class=\"inv-row-form\">" +
|
|
2277
|
+
"<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" style=\"width:6rem;\">" +
|
|
2278
|
+
"<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" style=\"width:6rem;\">" +
|
|
2279
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
|
|
2280
|
+
"</form>" +
|
|
2281
|
+
"</td></tr>";
|
|
2282
|
+
}).join("");
|
|
2283
|
+
|
|
2284
|
+
var table = rows.length
|
|
2285
|
+
? "<div class=\"panel\"><table><thead><tr><th>SKU</th><th class=\"num\">On hand</th><th class=\"num\">Held</th><th class=\"num\">Available</th><th>Restock / threshold</th></tr></thead><tbody>" + body + "</tbody></table></div>"
|
|
2286
|
+
: "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
|
|
2287
|
+
|
|
2288
|
+
var createForm =
|
|
2289
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
|
|
2290
|
+
"<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Track a new SKU</h3>" +
|
|
2291
|
+
"<form method=\"post\" action=\"/admin/inventory\">" +
|
|
2292
|
+
_setupField("SKU", "sku", "", "text", "Must match a variant SKU.", " maxlength=\"128\" required") +
|
|
2293
|
+
_setupField("Starting stock on hand", "stock_on_hand", "0", "number", "", " min=\"0\"") +
|
|
2294
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Track SKU</button></div>" +
|
|
2295
|
+
"</form>" +
|
|
2296
|
+
"</div>";
|
|
2297
|
+
|
|
2298
|
+
var bodyHtml = "<section><h2>Inventory</h2>" + created + updated + notice + chips + table + createForm + "</section>";
|
|
2299
|
+
return _renderAdminShell(opts.shop_name, "Inventory", bodyHtml, "inventory", opts.nav_available);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2181
2302
|
module.exports = {
|
|
2182
2303
|
mount: mount,
|
|
2183
2304
|
AUDIT_NAMESPACE: AUDIT_NAMESPACE,
|
|
@@ -2187,6 +2308,7 @@ module.exports = {
|
|
|
2187
2308
|
renderAdminSetup: renderAdminSetup,
|
|
2188
2309
|
renderAdminIntegrations: renderAdminIntegrations,
|
|
2189
2310
|
renderAdminProducts: renderAdminProducts,
|
|
2311
|
+
renderAdminInventory: renderAdminInventory,
|
|
2190
2312
|
renderAdminOrders: renderAdminOrders,
|
|
2191
2313
|
renderAdminOrder: renderAdminOrder,
|
|
2192
2314
|
renderAdminReturns: renderAdminReturns,
|
package/lib/catalog.js
CHANGED
|
@@ -629,6 +629,22 @@ function _inventoryModule(query, opts) {
|
|
|
629
629
|
return r.rows[0] || null;
|
|
630
630
|
},
|
|
631
631
|
|
|
632
|
+
// Operator-facing inventory list (sku ASC), optionally only the SKUs at
|
|
633
|
+
// or below their configured low-stock threshold. Capped + uncursored —
|
|
634
|
+
// it backs the admin inventory console, not a hot path.
|
|
635
|
+
list: async function (listOpts) {
|
|
636
|
+
listOpts = listOpts || {};
|
|
637
|
+
var limit = listOpts.limit == null ? 200 : listOpts.limit;
|
|
638
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
|
|
639
|
+
throw new TypeError("catalog.inventory.list: limit must be 1..1000");
|
|
640
|
+
}
|
|
641
|
+
var sql = listOpts.low_only
|
|
642
|
+
? "SELECT * FROM inventory WHERE low_stock_threshold IS NOT NULL AND " +
|
|
643
|
+
"(stock_on_hand - stock_held) <= low_stock_threshold ORDER BY sku ASC LIMIT ?1"
|
|
644
|
+
: "SELECT * FROM inventory ORDER BY sku ASC LIMIT ?1";
|
|
645
|
+
return { rows: (await query(sql, [limit])).rows };
|
|
646
|
+
},
|
|
647
|
+
|
|
632
648
|
// Hot-path decrement happens via the Worker's InventoryLock
|
|
633
649
|
// Durable Object — this primitive is for admin restock / release
|
|
634
650
|
// operations that don't need the DO serialization. Concurrent
|
package/lib/storefront.js
CHANGED
|
@@ -1620,6 +1620,15 @@ function renderCheckoutForm(opts) {
|
|
|
1620
1620
|
});
|
|
1621
1621
|
}
|
|
1622
1622
|
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
|
|
1623
|
+
// When PayPal is configured, append its button below the card form. The
|
|
1624
|
+
// block is built as raw HTML (appended after the strict render) so the SDK
|
|
1625
|
+
// script + handlers survive; the client-id is the only interpolation and is
|
|
1626
|
+
// attribute-escaped. The PayPal button's createOrder/onApprove drive the
|
|
1627
|
+
// /checkout/paypal/create + /capture routes. (Operators must allow
|
|
1628
|
+
// www.paypal.com in their CSP script-src/frame-src, as for js.stripe.com.)
|
|
1629
|
+
if (opts.paypal_client_id) {
|
|
1630
|
+
body += _paypalCheckoutBlock(opts.paypal_client_id, totals.currency);
|
|
1631
|
+
}
|
|
1623
1632
|
return _wrap({
|
|
1624
1633
|
title: "Checkout",
|
|
1625
1634
|
shop_name: shopName,
|
|
@@ -1629,6 +1638,31 @@ function renderCheckoutForm(opts) {
|
|
|
1629
1638
|
});
|
|
1630
1639
|
}
|
|
1631
1640
|
|
|
1641
|
+
function _paypalCheckoutBlock(clientId, currency) {
|
|
1642
|
+
var esc = b.template.escapeHtml;
|
|
1643
|
+
var cid = esc(String(clientId));
|
|
1644
|
+
var cur = esc(String(currency || "USD"));
|
|
1645
|
+
return "\n<div class=\"checkout-paypal\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
1646
|
+
"<div class=\"pay-card__divider\"><span>or pay with PayPal</span></div>" +
|
|
1647
|
+
"<div id=\"paypal-button-container\"></div>" +
|
|
1648
|
+
"<p id=\"paypal-error\" class=\"auth-form__message auth-form__message--err\" hidden></p>" +
|
|
1649
|
+
"</div>" +
|
|
1650
|
+
"<script src=\"https://www.paypal.com/sdk/js?client-id=" + cid + "¤cy=" + cur + "&intent=capture\"></script>" +
|
|
1651
|
+
"<script>(function(){" +
|
|
1652
|
+
"if(!window.paypal){return;}" +
|
|
1653
|
+
"var form=document.querySelector('.checkout-page form');" +
|
|
1654
|
+
"var errEl=document.getElementById('paypal-error');" +
|
|
1655
|
+
"function vals(){var d={};['email','name','country','state','postal'].forEach(function(k){var el=form&&form.elements[k];if(el){d[k]=el.value;}});return d;}" +
|
|
1656
|
+
"function showErr(m){if(errEl){errEl.hidden=false;errEl.textContent=m||'PayPal checkout could not be completed.';}}" +
|
|
1657
|
+
"paypal.Buttons({" +
|
|
1658
|
+
"onClick:function(_d,actions){if(form&&form.reportValidity&&!form.reportValidity()){return actions.reject();}return actions.resolve();}," +
|
|
1659
|
+
"createOrder:function(){return fetch('/checkout/paypal/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(vals())}).then(function(r){return r.json();}).then(function(d){if(!d.id){throw new Error(d.error||'create failed');}return d.id;});}," +
|
|
1660
|
+
"onApprove:function(data){return fetch('/checkout/paypal/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({paypal_order_id:data.orderID})}).then(function(r){return r.json();}).then(function(d){if(d.redirect){window.location.href=d.redirect;}else{showErr(d.error);}});}," +
|
|
1661
|
+
"onError:function(){showErr();}" +
|
|
1662
|
+
"}).render('#paypal-button-container');" +
|
|
1663
|
+
"})();</script>\n";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1632
1666
|
// Stripe Elements payment page — embeds Stripe.js + a minimal
|
|
1633
1667
|
// mount block. The publishable key is operator-supplied (env
|
|
1634
1668
|
// `STRIPE_PUBLISHABLE_KEY` → forwarded into the rendered HTML).
|
|
@@ -2808,7 +2842,7 @@ function mount(router, deps) {
|
|
|
2808
2842
|
return res.end ? res.end() : res.send("");
|
|
2809
2843
|
}
|
|
2810
2844
|
var totals = pricing.totals(c, lines, {});
|
|
2811
|
-
_send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme }));
|
|
2845
|
+
_send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme, paypal_client_id: deps.paypal ? deps.paypal_client_id : null }));
|
|
2812
2846
|
});
|
|
2813
2847
|
|
|
2814
2848
|
router.post("/checkout", async function (req, res) {
|
|
@@ -2867,6 +2901,76 @@ function mount(router, deps) {
|
|
|
2867
2901
|
}
|
|
2868
2902
|
});
|
|
2869
2903
|
|
|
2904
|
+
// PayPal express checkout (Orders v2). Mounts when the PayPal adapter is
|
|
2905
|
+
// wired. The pay-page PayPal button drives two AJAX calls: `create` opens
|
|
2906
|
+
// a PayPal order for the current cart (returns its id for the SDK to
|
|
2907
|
+
// approve in the popup); `capture` finalizes after approval and advances
|
|
2908
|
+
// the local order to paid. Both read the session cart; the button posts
|
|
2909
|
+
// the same ship_to fields the card form collects.
|
|
2910
|
+
if (deps.paypal) {
|
|
2911
|
+
router.post("/checkout/paypal/create", async function (req, res) {
|
|
2912
|
+
function _json(status, obj) {
|
|
2913
|
+
res.status(status);
|
|
2914
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2915
|
+
var s = JSON.stringify(obj);
|
|
2916
|
+
return res.end ? res.end(s) : res.send(s);
|
|
2917
|
+
}
|
|
2918
|
+
var body = req.body || {};
|
|
2919
|
+
var sid = _readSidCookie(req);
|
|
2920
|
+
if (!sid) return _json(400, { error: "no-session" });
|
|
2921
|
+
var c = await deps.cart.bySession(sid);
|
|
2922
|
+
if (!c || c.status !== "active") return _json(409, { error: "no-active-cart" });
|
|
2923
|
+
var shipTo = {
|
|
2924
|
+
country: (body.country || "").toUpperCase(),
|
|
2925
|
+
state: body.state ? String(body.state).toUpperCase() : undefined,
|
|
2926
|
+
postal: body.postal || undefined,
|
|
2927
|
+
};
|
|
2928
|
+
try {
|
|
2929
|
+
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
2930
|
+
? await deps.default_shipping_id() : deps.default_shipping_id;
|
|
2931
|
+
var created = await deps.checkout.createPaypalOrder({
|
|
2932
|
+
cart_id: c.id,
|
|
2933
|
+
ship_to: shipTo,
|
|
2934
|
+
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
2935
|
+
customer: { email: body.email, name: body.name },
|
|
2936
|
+
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
2937
|
+
return_url: body.return_url || undefined,
|
|
2938
|
+
cancel_url: body.cancel_url || undefined,
|
|
2939
|
+
});
|
|
2940
|
+
// The PayPal JS SDK's createOrder expects `{ id }`.
|
|
2941
|
+
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
2942
|
+
} catch (e) {
|
|
2943
|
+
return _json(e instanceof TypeError ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
|
|
2944
|
+
}
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
router.post("/checkout/paypal/capture", async function (req, res) {
|
|
2948
|
+
function _json(status, obj) {
|
|
2949
|
+
res.status(status);
|
|
2950
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2951
|
+
var s = JSON.stringify(obj);
|
|
2952
|
+
return res.end ? res.end(s) : res.send(s);
|
|
2953
|
+
}
|
|
2954
|
+
var body = req.body || {};
|
|
2955
|
+
var paypalOrderId = body.paypal_order_id || body.orderID || body.orderId;
|
|
2956
|
+
if (typeof paypalOrderId !== "string" || !paypalOrderId.length) return _json(400, { error: "paypal_order_id required" });
|
|
2957
|
+
try {
|
|
2958
|
+
var result = await deps.checkout.capturePaypalOrder(paypalOrderId);
|
|
2959
|
+
if (!result.order) return _json(404, { error: "order-not-found" });
|
|
2960
|
+
// Only redirect to the order page when the capture actually
|
|
2961
|
+
// completed (or the order is already paid). A non-completing capture
|
|
2962
|
+
// leaves the order pending — surface it as an error so the client
|
|
2963
|
+
// doesn't show success for an unpaid order.
|
|
2964
|
+
if (!result.handled && result.order.status !== "paid") {
|
|
2965
|
+
return _json(502, { error: "capture-incomplete", status: result.order.status });
|
|
2966
|
+
}
|
|
2967
|
+
return _json(200, { order_id: result.order.id, status: result.order.status, redirect: "/orders/" + result.order.id });
|
|
2968
|
+
} catch (e) {
|
|
2969
|
+
return _json(502, { error: (e && e.message) || "paypal-capture-failed" });
|
|
2970
|
+
}
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2870
2974
|
router.get("/pay/:order_id", async function (req, res) {
|
|
2871
2975
|
var orderId = req.params && req.params.order_id;
|
|
2872
2976
|
if (!orderId) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
@@ -2952,6 +3056,32 @@ function mount(router, deps) {
|
|
|
2952
3056
|
return res.end ? res.end("handler error") : res.send("");
|
|
2953
3057
|
}
|
|
2954
3058
|
});
|
|
3059
|
+
|
|
3060
|
+
// PayPal webhook — the async backstop for captures completed/refunded out
|
|
3061
|
+
// of band (the create/capture flow is primary). Verified server-to-server
|
|
3062
|
+
// through PayPal's API (handlePaypalEvent), so unlike Stripe there is no
|
|
3063
|
+
// edge HMAC pre-check; the raw body is captured upstream by
|
|
3064
|
+
// webhookRawBodyCapture. Mounts only when the PayPal adapter is wired.
|
|
3065
|
+
if (deps.paypal) {
|
|
3066
|
+
router.post("/api/webhooks/paypal", async function (req, res) {
|
|
3067
|
+
var raw = Buffer.isBuffer(req.body) ? req.body.toString("utf8")
|
|
3068
|
+
: (typeof req.body === "string" ? req.body : "");
|
|
3069
|
+
try {
|
|
3070
|
+
var result = await deps.checkout.handlePaypalEvent({ headers: req.headers || {}, rawBody: raw });
|
|
3071
|
+
res.status(200);
|
|
3072
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
3073
|
+
var payload = JSON.stringify({ ok: true, handled: !!(result && result.handled) });
|
|
3074
|
+
return res.end ? res.end(payload) : res.send(payload);
|
|
3075
|
+
} catch (e) {
|
|
3076
|
+
if (e && e.code === "WEBHOOK_INVALID") {
|
|
3077
|
+
res.status(400);
|
|
3078
|
+
return res.end ? res.end("invalid signature") : res.send("");
|
|
3079
|
+
}
|
|
3080
|
+
res.status(500);
|
|
3081
|
+
return res.end ? res.end("handler error") : res.send("");
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
2955
3085
|
}
|
|
2956
3086
|
|
|
2957
3087
|
// ---- customer accounts (passkey-only) ------------------------------
|
package/package.json
CHANGED