AswinPrints is a custom 3D printing business run by Aswin Zayasankaran, based in Pondicherry, India. Powered by the Bambu Lab A1, I print everything from intricate figurines and decorative pieces to functional prototypes and personalised gifts.
🎯 Goal: Make 3D printing accessible to everyone — fast turnaround, fair pricing, quality you can see.
| Service | Description |
|---|---|
| 🎨 Custom Figurines | Characters, mascots, collectibles printed with fine detail |
| ⚙️ Functional Parts | Replacement components, brackets, mechanical parts |
| 🏠 Home Décor | Vases, wall art, organizers, decorative accents |
| 🎁 Personalised Gifts | Name plates, keychains, one-of-a-kind custom pieces |
| 🔬 Prototypes | Rapid prototyping for product ideas and engineering models |
| 📦 Small Batch Runs | Multiple copies with consistent quality |
| Step | What Happens |
|---|---|
| 1 · Send Your Request | Fill in the quote form with your idea. Attach an STL, OBJ, or 3MF file if you have one — otherwise just describe it. |
| 2 · Get a Quote | I review the model, materials and quantity, then reply with pricing. Usually within a few hours. |
| 3 · Print & Review | Once approved, your piece goes on the printer. I share photos before it ships. |
| 4 · Deliver | Pickup in Pondicherry, or shipping anywhere in India. |
| Tool | |
|---|---|
| Printer | Bambu Lab A1 |
| Materials | PLA · PETG · TPU — with custom colour matching |
| File Formats | STL · OBJ · 3MF |
| Frontend | Pure HTML / CSS / JS — no framework, no build step |
| Backend | Cloudflare Worker (src/) — static assets + /api/* |
| Database | Cloudflare D1 (SQLite) — products and orders |
| Payments | Razorpay Standard Checkout |
| Resend, sent from the Worker |
A single Cloudflare Worker serves the static site and the API. Paths matching a
file in public/ are served by the assets binding; everything else falls
through to the Worker, so /api/* is handled in src/.
3d_printing/
├── public/ # Static site (served via [assets])
│ ├── index.html # Main website — shop, cart, sign-in, account menu
│ ├── shop.html # Owner dashboard (orders, products, quotes, coupons)
│ ├── 3d-printing-in-pondicherry.html # Local landing page
│ ├── contact/privacy/refunds/shipping/terms.html
│ └── assets/
│ ├── css/ # style.css (site), admin.css (dashboard), pdp.css
│ ├── js/ # main.js, admin.js, product.js, quote-modal.js, chat.js
│ ├── images/ # ~86 product photos
│ └── images.json # Generated manifest — `npm run images`, never by hand
├── src/ # Worker
│ ├── index.js # Router: /api/* → api(), else ASSETS.fetch
│ ├── lib.js # JSON/HMAC/cookies/escaping/Resend helpers
│ ├── security.js # CSP + security headers, rate-limit rules
│ ├── shop.js # Catalogue reads + cart pricing (server-side)
│ ├── razorpay.js # REST client, payment links, signature verification
│ ├── orders.js # Order create/verify/receipt + webhook
│ ├── quotes.js # Quote requests, and answering one with a price
│ ├── invoicing.js # Hands a paid order to invoicer.aswincloud.com
│ ├── auth.js # Owner sign-in via the auth.aswincloud.com broker
│ ├── customers.js # Customer sign-in (OTP/OAuth) + /api/me
│ ├── cart.js # Server-side cart + guest merge
│ ├── coupons.js # Discount codes
│ ├── chatcoupons.js # The chat bot's on-demand discount code
│ ├── agent.js # The listing agent's capability boundary
│ ├── admin.js # Owner-only: product CRUD, orders, refunds
│ ├── pdp.js # Product detail page HTML
│ ├── productpage.js # /p/<slug> routing
│ ├── seo.js # sitemap, robots, JSON-LD, homepage rewriting
│ └── emails.js # Email HTML templates
├── migrations/ # D1 schema (forward-only, 0001 … 0019)
├── test/ # Offline unit tests (`npm test`)
│ └── browser/ # Cross-engine layout checks (chromium + webkit)
├── wrangler.toml # Worker + D1 config; vars only, no secrets
└── .github/workflows/
├── test.yml # npm test, and the hero geometry check
└── auto-approve.yml # Dependabot auto-approval
npm install
cp .dev.vars.example .dev.vars # fill in; gitignored, never committed
npm run db:migrate:local
npm run dev # http://localhost:8787
npm test # offline unit tests, no network
npm run images # regenerate assets/images.json after adding a photo.github/workflows/test.yml runs on every push and pull request.
npm test — around 1,400 offline assertions over pricing, payments, refunds,
order state, the admin auth gate, coupons and quotes. No network, no D1, no
secrets, so there is nothing to configure and it finishes in about 20 seconds.
For four months this repo had no CI at all: the only automated things that happened to a pull request were approve it and deploy it.
Hero geometry, in Chromium and WebKit (test/browser/hero-geometry.mjs) — a
separate job, so the unit suite stays a 20-second answer. It serves public/
statically, because this is CSS geometry and needs no Worker.
It asserts the two engines agree on each hero card's shape, that nothing scrolls sideways — the page or the strip — and that no card hides more than 25% of its photo. Agreement is the assertion rather than any particular ratio, so it holds no opinion about the design and only fails when two browsers disagree about it.
It exists because they did. A card sized from its image's intrinsic width came
out 221px in Chromium and 335px in WebKit — a cropped photo on every iPhone,
invisible to anyone testing in Chromium, and reported from a real phone rather
than by anything in this repo. A browser that fails to start is a check that is
not running, so CI=true makes that fatal rather than skipped.
The pin control, in Chromium and WebKit (test/browser/pin-control.mjs) —
same job, same static server, with /api/* stubbed. It is the only admin control
on a public page, so which element each visitor gets is a browser question the
server tests cannot answer: a customer must get a plain badge and no controls, an
admin the toggle in its place. It also holds a bug it caught before release —
re-ordering the product array in place made pinning look right and left an
unpinned card stranded at the top until reload. See
Pinning.
The order tracker, in Chromium and WebKit (test/browser/order-tracker.mjs) —
the six-stage progress bar in My Orders, rendered from a stubbed payload in the
shapes real order data actually takes. See Order stages.
The promo banner, in Chromium and WebKit (test/browser/promo-banner.mjs) —
who the homepage banner is shown to. The offer comes from a shared, edge-cached
response and is withdrawn by a per-session one, so the decision is split across
two requests that arrive in either order; this pins down that race, and that a
hidden banner leaves the nav flush rather than floating above a transparent gap.
The site on a phone, in Chromium and WebKit (test/browser/checkout-mobile.mjs)
— the promo row's proportions at 360/390/430px, that no field anywhere is under
16px (iOS Safari zooms the viewport on focus below that) while desktop keeps its
smaller sizes, that every real control meets a 44px touch target, and that arriving
at /#cart opens the drawer. Every defect it guards was invisible on a desktop.
Deploys are not run from CI. Cloudflare Workers Builds deploys main on
push; branches produce an unpromoted preview version. Migrations are never run
by the deploy — apply them yourself with npm run db:migrate:remote.
The form posts JSON to POST /api/quote. The Worker validates it server-side,
records the request, then sends two emails through Resend:
- To me — the full request, with
reply_toset to the customer. - To the customer — an acknowledgement summarising what they submitted.
The customer copy is sent via ctx.waitUntil(), so a slow send never delays
the response.
The row comes first, and its failure is non-fatal. A request used to exist only as two emails, so losing the email lost the job — including the uploaded model, which was a link inside that one message and nowhere else. But a D1 outage must not swallow a real customer's request when the mail would have gone out fine, so a failed insert is logged and the emails still send.
→ Quotes, and answering one with a price below.
Every photo in public/assets/images/ is a listed product or a quote-only card;
migration 0005 named the 18 that once carried alt="3D print sample". The
catalogue is around 86 photos and grows whenever the listing agent pushes one, so
this README does not quote a count that would be wrong a week later —
assets/images.json is the list, and it is generated, never hand-written.
Every gallery photo also has "Request a quote for this" in its
lightbox, every product card has "Different colour or size?", and every
product page has both. All of them open a modal — assets/js/quote-modal.js
— carrying a thumbnail and the item's name, which travels as ref_item and
appears as an "About" row in the owner email. Without that, a request about one
of the 18 would arrive with no way to tell which photo it meant.
They used to scroll to the page-bottom form instead, and from a product page they navigated to it. Both took away the photo, the price and the size the question was about, which is most of what the person was looking at when they decided to ask.
The modal posts to the same POST /api/quote as that form — same server-side
validation, same RL_QUOTE rate limit, same two emails (owner with Reply-To set
to the customer, customer acknowledgement via ctx.waitUntil). A second endpoint
would have been a second thing to remember to rate limit.
It asks for name, email, phone, quantity and details. It does not ask the
category the page-bottom form asks for — someone who clicked "different colour or
size" on a specific product has already answered that — so type is sent as
"Variation of a listed item".
The script is shared by the homepage and the product pages, which load different
scripts (main.js and product.js), and it builds its own markup rather than
having it written into both public/index.html and src/pdp.js, where the two
copies would drift. The product-page links keep their href to /#quote as a
no-JS fallback; the click is intercepted only when the modal is actually loaded.
The page-bottom form stays: it is the #quote anchor target, it takes file
attachments, and it handles requests that aren't about any one product.
ref_item is customer-controlled text, so it's clipped and escaped like every
other field — never used as a URL or a lookup key.
GET /api/products returns the visible catalogue from D1 plus the shipping
config; assets/js/main.js renders the grid and the cart drawer from it.
Search and category filters are entirely client-side. That one request
already returns the whole catalogue, so filtering locally is instant and needs no
round trip. Category chips are built from the categories actually present, so
adding one to the database needs no code change; CATEGORY_LABELS in main.js
maps the internal slugs to the friendlier wording used elsewhere on the page
(figurine → "Figurines", decor → "Home Décor"), falling back to the
capitalised slug for anything unmapped.
A multi-word search matches all terms in any order across name, description and category, so "run marble" finds both marble runs. A search that matches nothing offers the quote form and carries the search term into its description — the best hint available about what they actually wanted.
The cart in localStorage stores only {id, qty} — no prices, no names.
Everything displayed is re-derived from the API on load, and when checkout
lands the browser will post only those id/qty pairs. priceCart() in
src/shop.js reads prices from D1 and computes the amount server-side, so a
hand-edited cart can change what you see but never what you pay.
Money is stored as integer paise (34900 = ₹349), matching Razorpay's API
and avoiding float rounding when summing line items.
Razorpay Standard Checkout, called through the REST API with fetch and signed
with WebCrypto. The razorpay npm SDK is deliberately not used: it does
require("crypto") and bundles axios's Node HTTP adapter, so it can't build for
a Worker without nodejs_compat (wrangler deploy --dry-run fails with
Could not resolve "crypto").
browser POST /api/orders {items:[{product_id,qty}], customer, delivery}
↑ no amount — the server prices it
worker priceCart() → Razorpay Orders API → insert order (pending)
browser Razorpay Checkout modal
├── success → POST /api/orders/verify (shows a receipt; does NOT mark paid)
├── dismiss → order stays pending, nothing charged
└── failed → error shown, order stays pending
razorpay POST /api/webhook/razorpay ← the source of truth
marks paid, sends both emails
Two things worth knowing before touching this code:
Two different secrets. KEY_SECRET signs the checkout callback
(HMAC(order_id|payment_id)); WEBHOOK_SECRET signs webhooks (HMAC(raw body)). WEBHOOK_SECRET is a string you choose in the dashboard. Conflating
them is the most common Razorpay bug, so test/payments.mjs asserts each is
rejected in the other's place.
Testing with cards. Use a domestic test card — 5267 3181 8797 5449
(Mastercard), CVV 123, any future expiry, OTP 1111 (4+ digits succeeds,
fewer fails). The card most tutorials give, 4111 1111 1111 1111, is classed as
international by Razorpay and fails on a stock test account with
international_transaction_not_allowed. The checkout modal also rejects some
obviously-fake mobile numbers (9876543210 among them); 9000090000 works.
The webhook, not the browser, marks an order paid. /api/orders/verify
only proves the callback is genuine so the customer sees a receipt. If the
browser could set paid, anyone could POST a fabricated callback; if only the
browser could, a closed tab would lose the order. The webhook route is also
dispatched before the router's shared request.json(), because its HMAC
covers the exact bytes received — re-serialising parsed JSON breaks
verification.
Five products depicting licensed characters are seeded visible = 0 — they
stay in the portfolio gallery but aren't listed for sale. Prices in
0002_seed_products.sql are placeholders and are meant to be corrected in
the admin dashboard before live keys are enabled.
Note: the quote form previously ran on GitHub Actions via
repository_dispatch, which required a GitHub PAT injected intomain.jsat deploy time — readable by anyone who viewed source. Now the only credential is a Worker secret and nothing sensitive reaches the browser.
Customers sign in on the main page — an inline modal, no separate route —
with Google, GitHub or Microsoft, or a 6-digit code emailed to them — no
password to set, forget, or leak. Built on @aswincloud/auth's OTP primitives
(generateOtp/hashOtp/otpHashEquals and the otp_codes table), so codes are
stored peppered-and-hashed with an attempt counter, never in plaintext. Its
higher-level signup()/verifyOtp() flows are deliberately unused: both require
a password.
Signing in changes the account button into a menu: My orders (a second tab
in the cart drawer), Sign out, and — only when /api/me reports is_admin —
Dashboard. That menu entry is the only route to /shop; nothing else links
it. The flag is a display hint, so a client faking it just gets a link that 401s.
/shop is the one page that remains separate, because a 31-row product editor
and an order table need the room.
Two auth schemes, kept apart. Admin uses ap_session with token purpose
owner_session; customers use ap_user with purpose customer_session. The
purpose is bound into the HMAC, so a customer cookie replayed at /api/admin/*
fails signature verification rather than a string comparison someone could later
refactor away. test/customers.mjs asserts this in both directions.
Order history is scoped by the session and nothing else. myOrders(env, user)
takes no url or query argument, so there is no parameter by which one customer
could request another's orders — the function has nowhere to put one. Tested
against a seeded second account with ?user_id=, ?email=, ?receipt= and
?id= all attempted.
The server cart still carries no price. Rows are (user_id, product_id, qty).
priceCart() remains the only thing that decides an amount.
OAuth is treated as equivalent proof to an emailed code. Google and GitHub
verify the address, so one account per email regardless of which route was used,
and guest orders are claimed either way. Identities link on
(provider, provider_user_id) rather than email — the provider's id is stable
and an email is not, so linking on email would orphan a customer from their own
order history the day they change their Google address.
The callback signs everyone in as a customer, and issues an additional admin
cookie when the email is on the OWNER_EMAIL allowlist. Being a customer never
implies being an admin.
Guest orders are claimed on first sign-in — UPDATE orders SET user_id … WHERE user_id IS NULL AND lower(cust_email) = ?. The code proves control of the
mailbox the order was placed with. Worth being plain about: order history is
therefore only as strong as the customer's email, and an order already attached
to another account is never re-claimed.
Rate limiting. POST /api/auth/code is unauthenticated and sends email, so:
5 sends per address per hour, the package's 5-attempt cap per code, its 60s
resend cooldown, and {ok:true} returned for unknown / throttled / failed-send
alike so the endpoint can't be used to discover which addresses have accounts.
There is no per-IP limit yet — Cloudflare's Rate Limiting binding is the right
tool and needs a dashboard change.
Reachable two ways, both ending at the same OWNER_EMAIL allowlist check:
- Broker OAuth (
ap_session) — preferred, but needssite=3dprintsregistered atprovision.aswincloud.com. - An emailed code (
ap_user) — sign in at/loginwith an address on the allowlist. This exists because the broker has no registration for this site yet, which left the dashboard unreachable and the placeholder prices uneditable.
currentAdmin() in src/auth.js tries the broker session first, then falls back
to a customer session whose verified email passes ownerAllowed(). It is a
second transport, not a second policy — a customer cannot self-promote, because
the allowlist only changes via a Worker var.
The trade is worth stating: route 2 makes admin access email-strength. Whoever can read the owner's inbox can issue refunds and read customer addresses. Prefer route 1 once the broker knows this site.
Products come out in this order, set by one clause in listProducts()
(src/shop.js) that both the shop and the dashboard share:
ORDER BY pinned DESC, created_at DESC, (sort = 0), sort ASC, name ASC- Pinned products.
- Newest first — this is a made-to-order shop and new pieces are added weekly, so burying today's print under a batch from three weeks ago is the wrong default.
- Within one batch, the curated
sortsequence, then alphabetically.
The last two keys coexist only because of a fact about the data: all 49 curated
products share a single created_at, so ordering by age moves them as one block
and leaves their chosen sequence intact.
This clause has been wrong twice, which is why it is tested the way it is. It
began as plain ORDER BY sort ASC — and since sort defaults to 0, the 36
products nobody had ordered sorted above the 49 that had been. Fixing that with
(sort = 0) then surfaced the opposite problem: the curated batch is the oldest,
so putting it first pushed every new piece to the bottom.
test/shop.mjs runs the shipped clause against real SQLite rather than a
fake, because the fake does not implement ORDER BY and would have proved
nothing. It also asserts the dashboard's clause is character-for-character the
same, since the failure nobody notices is changing one and not the other.
Pinning is done from the shop itself, not the dashboard. Signed in as an admin, every product card grows a Pin button in the top-left of its photo; pinned cards read Featured, which is the same badge customers see. It is the one piece of admin UI on a public page, so it is worth knowing where it lives.
It writes through the existing PATCH /api/admin/products/:id — no new route and
no new capability. is_admin from /api/me only decides whether the button is
drawn; the allowlist is re-checked server-side, so faking it in devtools buys a
button that earns a 401. Because /api/products is edge-cached for 60 seconds, a
pin reaches other visitors within a minute, while the admin's own tab reorders
immediately.
test/browser/pin-control.mjs covers this in chromium and webkit: a customer gets
a badge and no controls, an admin gets the toggle, and unpinning returns the card
to where it came from.
PROMO_CODE names a coupon the homepage advertises. featuredPromo()
(src/shop.js) only offers a code that would actually work — active, unexpired,
within max_uses — so the banner cannot advertise something checkout would then
refuse.
With one exception it could not see: a customer who has already used a
once-per-customer code. That answer is per-customer, and /api/products is
shared and edge-cached (cdn-cache-control: s-maxage=60), so putting it there
would compute it for whoever missed the cache and serve it to everyone else for
the next minute. It rides on /api/me instead — per-session and no-store — as
promo_used, and the banner withdraws itself.
This is cosmetic: applyCoupon() (src/coupons.js) refuses the second
redemption regardless, so nobody can double-redeem either way. What it prevents is
advertising a discount and then declining it at checkout.
The consequence is that the banner's fate depends on two responses that can arrive
in either order, which is most of what test/browser/promo-banner.mjs exists to
pin down. A guest who used the code while signed out still sees the banner — there
is no identity to check it against, and inventing one is not worth it.
An order used to have two states a customer ever saw: paid, then shipped. Between
them sat every day the print was actually being made, and nothing was said. The
pipeline is now six stages, defined once in ORDER_STAGES (src/lib.js) and
enforced by ALLOWED_TRANSITIONS (src/admin.js):
Placed → Confirmed → In production → Ready to ship → Shipped → Delivered
Skipping forward is legal. paid → shipped is still one click, so something
already on the shelf does not need four. cancelled, refunded and failed end
the pipeline rather than advancing it, and render a badge instead of a tracker.
Two invariants survive unchanged: paid is set only by the Razorpay webhook,
never from the dashboard, and refunded is reachable only through the refund
action that actually moves the money.
Four of the six email. Confirmed, in production, shipped, delivered. Ready to
ship advances the tracker and sends nothing — it is usually hours before shipping,
and two mails that close together read as padding. That decision is implemented as
its absence from STAGE_EMAIL, nothing more. Every send fires on the transition
only, so correcting a typo'd tracking number a day later does not tell the
customer their order shipped again.
Nothing auto-advances. There is no courier webhook, so delivered is a dashboard
click; guessing it from elapsed days would put a claim in a customer's inbox that
nothing verified.
The tracker is computed on the server. stageTimeline() returns the six
stages with done / current / at, so main.js, the dashboard and the chat bot
cannot drift into different ideas of what a stage is called. A stage counts as
reached when it is at or before the current status, or carries a timestamp, or a
later stage does — three clauses because order data arrives incomplete in three
ways: a skipped stage, rows predating the timestamp columns, and rows carrying a
status with no timestamp at all. The third was found by running the tracker over
the dev database, where such a row drew as one step done beneath a badge reading
Shipped; production has none today, but the schema permits one. The orders that
shipped before this feature are the second case, and they are live.
test/browser/order-tracker.mjs covers the rendering in chromium and webkit,
including all three of those shapes.
A quote request is a row, not just an email. /shop has a Quotes tab: the
request, the customer's contact details, the attached STL or image, and a status
(new → replied → paid, plus a manual won / lost).
Answering one prices the job, creates a Razorpay payment link, and sends a branded quotation carrying it. Every reply used to be hand-typed, so pricing emails looked different each time; now there is one format with the price in a block of its own.
A paid link becomes an ordinary order. payment_link.paid carries the link's
reference_id — which is the quote's receipt, QT-<8hex>, and the only field of
ours that survives the round trip — alongside order and payment entities. The
webhook looks the quote up by it, inserts a paid order with its own AP- receipt
and one line item whose product_id is NULL, and hands off to the same
notification path a checkout order takes. notifyPaid() is shared rather than
copied: a second copy is how one of them quietly stops invoicing.
The guards exist because this amount is typed by hand rather than computed from the catalogue, which is the one place in this codebase where that is true:
| guard | why |
|---|---|
| ₹1 floor, ₹5,00,000 ceiling, checked before Razorpay | catches an extra two zeros |
One live link per quote — handler and a UNIQUE column |
a double-clicked Send would leave two payable links for one job |
| Links expire, 7 days by default | a quoted price should not still be payable next season |
| The link is stored before the email is sent | mailing a link we have no record of is the one unrecoverable outcome |
paid cannot be set from the dashboard |
same rule as orders: never claim money moved when it did not |
A payment that matches no quote emails the owner, with the reference, link id, Razorpay order id, payment id and amount. Money arriving with nothing created for it is the one webhook outcome that must not be a log line nobody reads — and it doubles as the tripwire if Razorpay's payload shape ever moves.
A quote request never asks for a delivery address, so an order created this way starts without one and the dashboard flags it.
Three products ask the buyer for something the shop used to collect nowhere — a name on a keychain, the details on a business card, a colour on a stand — while checkout had one optional order-level Notes box. An order could be paid in full with no idea what to print.
products.personalise_label carries both the flag and the wording: empty means
the product does not ask, so the two cannot disagree. personalise_required is
separate, because the stand has a default colour and a blank is a real answer
there, while a keychain with no name is not a product.
The field appears on the cart line and in the checkout summary, from one
renderer — because two paths skip the cart entirely (Buy-now on a product page,
and Buy-now in the lightbox). priceCart() refuses the order regardless, which is
what actually holds: the API takes a cart from anywhere.
→ docs/listing-agent.md — setup, the exact curl calls,
and what the token cannot do.
Photos are pushed to this repo by a separate AI agent, and pricing and describing them
used to mean holding the owner session — which grants every product edit, every order,
refunds and coupons. AGENT_TOKEN is a second credential authorising exactly three
routes and nothing else:
| Route | What it does |
|---|---|
GET /api/admin/products/unlisted |
which pushed photos have no product row yet |
POST /api/admin/products/batch |
create listings — file, price_paise, category, description |
POST /api/admin/products/describe |
fill in a description that is missing |
Everything else under /api/admin/ returns 403, including PATCH /api/admin/products
— the bulk price editor over existing rows, which is the power being withheld.
The safety is not the allowlist. writeProductRows() is a single INSERT, and the
describe route's UPDATE carries AND (description IS NULL OR TRIM(description) = '')
— so an existing price, name or description cannot be changed through this token at
all, whatever reaches the handler. price_paise is not even in that statement.
Products can be edited in bulk: change any number of prices, visibility
toggles or descriptions, then "Save all changes" sends one
PATCH /api/admin/products instead of one request per row. The write is
all-or-nothing — every row is validated before anything is written, because a
partial write would leave you unable to tell which of them took. Per-row
Save is still there for a single tweak.
A search box filters the list by name, slug, description or category. It runs
against the catalogue already in the page — /api/admin/products returns every
row in one response, so there is nothing a round trip could add. A row hidden by
the search keeps any unsaved edit, and the bulk bar says how many of the changes
it is counting are currently out of view, because otherwise "3 unsaved changes"
above a single highlighted row reads as two of them having been lost.
Descriptions are editable here, in a panel that opens under the row. Before
this they could only be written by the listing agent or by hand in a migration,
which is why two rows sat blank for weeks; rows without one are now flagged in the
list, so the search finds them. updateProduct() already accepted the field — it
was the UI that never sent it. Widening the bulk route does not widen the
listing agent: AGENT_ROUTES omits PATCH /api/admin/products by exact match,
and test/agent.mjs asserts it.
/shop.html — orders (with status controls and refunds) and products (price,
visibility and description editing, with search). Sign-in goes through the central broker at
auth.aswincloud.com: it authenticates with Google/GitHub/Microsoft and relays
the verified email back, signed with this site's RELAY_SECRET. There are no
local user or session tables — the session is a signed token in a cookie, and
the email is re-checked against OWNER_EMAIL on every request, so removing
an address revokes access immediately rather than whenever the cookie lapses.
Two deliberate restrictions:
OWNER_EMAILmust be non-empty.@aswincloud/auth'sisOwner()treats an empty allowlist as "allow any authenticated user" — fine for a public site, catastrophic for a page showing customer addresses and payment ids.auth.jstherefore checks the allowlist is non-empty first and denies everyone if it isn't.test/admin.mjsasserts this in five ways.- The dashboard cannot mark an order paid. Only the Razorpay webhook does.
Similarly,
refundedis only reachable through the refund action, so the status can't claim money moved when it didn't.
Deleting a product that appears in any order hides it instead, because
order_items snapshots the name and price and order history must survive.
Want something printed? Head to the quote form on the site, or reach out directly:
- 🌐 aswincloud.com
- 📧 aswin@aswincloud.com
- 📸 @3dprinthub.offl
- 📍 Pondicherry, India
© 2026 Aswin Zayasankaran. All rights reserved.






