Skip to content

[Plugin] Reject invalid product-data input at content:beforeSave, not silently after the save - #102

Closed
vedanshujain wants to merge 4 commits into
mainfrom
fix/price-input-validation
Closed

[Plugin] Reject invalid product-data input at content:beforeSave, not silently after the save#102
vedanshujain wants to merge 4 commits into
mainfrom
fix/price-input-validation

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

What

From the 2026-07-26 E2E QA (P1): typing a decimal like 24.99 into the minor-units price field showed a "Saved" toast and persisted in the CMS while the commerce service silently kept the old price — the admin UI and the live storefront disagreed with no error anywhere.

  • New content:beforeSave handler (plugin gains content:write — ADR-0012): invalid commerce input can never reach the CMS write. The strip is the load-bearing invariant (per-key column writes keep stored data at last-good, so silent divergence is structurally impossible); a sanitized sentinel key surfaces a red "Failed to save: Urumi — Not saved. Price "24.99" must be whole minor units (2499 = $24.99)…" toast as best-effort UX.
  • commerceSaveBlockers blocks only present-and-wrong values (decimal/negative/NaN price, whitespace-only or malformed SKU, placeholder currency/kind with price present); absent/cleared fields are clean no-ops — unpriced products stay saveable and clearing a price is the escape hatch. Price 0 is valid.
  • An always-on scrubber guarantees a stale sentinel can never block a future save; revision-path safety (no sentinel can persist into draft JSON) is test-pinned.
  • ADR-0012 states the real trusted-mode surface content:write grants, the CI-uncoverable capability trap (our sandbox has no capability gate — comments at both wiring sites), and the degradation modes.

sync/hooks.ts and parse-commerce-fields.ts untouched (concurrent-lane isolation, since landed as #101's surface).

Verification

  • format / lint / typecheck clean; full suite 1395 passed; with Postgres the plugin project runs 397/397 (all 6 capability-pin guard tests green — one was pg-gated and caught in review)
  • 34 tests: predicate table (incl. whitespace-SKU, nested-data adversarial bag, oversized/newline SKU), sentinel scrub ×multi-key, isNew create path, ×2 real-workerd boots
  • Plan (3 revisions) and diff each approved by two independent reviewers
  • Staging Playwright DoD follows as a PR comment (draft/autosave revision path + "no sentinel in draft JSON" + corrupted-product recovery) — merge gate

Changeset

@urumi/plugin minor.

🤖 Generated with Claude Code

https://claude.ai/code/session_013Fkmqxb8d6hh4ETNNz43eZ

vedanshujain and others added 3 commits July 26, 2026 10:28
…nput before the CMS save

Records the trade behind the incoming `content:beforeSave` price guard: em-dash
requires `content:write` to REGISTER that hook at all (and silently skips it
otherwise), but the capability is far broader than what we use — in trusted mode
it is hook-free transactional write access to every collection.

States the surface honestly, plus the bounds (compile-time `PluginContext` with
no `content` member, the depcruise sandbox-clean rule, and the
`collection !== "products"` early return), the CI-uncoverable capability trap,
the strip-not-sentinel invariant, the accepted silence degradation, and the
publish-brick vector with its mandatory staging assertion.

0010 and 0011 are reserved by the in-flight service-hardening and entitlements
lanes, so this record takes 0012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Fkmqxb8d6hh4ETNNz43eZ
… silently after the save

A decimal ("24.99") or negative price typed into the "Product data" widget's
minor-units Price field used to save cleanly into the CMS while the commerce
service kept the old amount — green "Saved" toast, no error anywhere. The
validation was correct but lived strictly DOWNSTREAM of the CMS write, in a
fire-and-forget `content:afterSave` hook with no channel back to the editor.

Adds a rejection point upstream of the write:

- `product-commerce/commerce-save-blockers.ts` — a NEW, deliberately narrow
  predicate that blocks only PRESENT-AND-WRONG values. Absent / cleared / ""
  are always clean, so unpriced products stay saveable and clearing the price
  is always a way back. `parseCommerceFields` (a derive guard that legitimately
  errors on absence) is neither reused nor modified.
- `product-commerce/commerce-rejection-message.ts` — the sanitized sentinel key
  that carries the merchant-facing message, plus the always-on
  `scrubStaleSentinels` that guarantees a stale sentinel can never block a save.
- `sync/before-save.ts` — the handler. Rejection is RETURN-based, never a throw:
  em-dash's sandboxed dispatcher swallows throws and proceeds with the bad
  payload. The load-bearing invariant is the STRIP — a blocked payload carries
  no `commerce` key, so the last-good bag survives on both write paths. The
  `collection !== "products"` early return is a security control (ADR-0012).

Wiring: `content:beforeSave` in plugin.ts + types.ts, `content:write` in
manifest.ts (mandatory — em-dash silently skips the hook without it), with the
CI-uncoverable capability-trap comment at both sites. The Price label now says
"whole number, no decimals" explicitly, since BlockKitFieldWidget drops any
min/step an element declares.

34 tests: 16 pure unit (predicate + message hygiene), 15 workerd sandbox
(repro, invariant, scrub, create path, non-products, ctx shape), 2 widget /
capability guards, 1 staging site-config. `sync/hooks.ts` and
`parse-commerce-fields.ts` are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Fkmqxb8d6hh4ETNNz43eZ
…e 3-item list

`test/account-routes.sandbox.test.ts:142` still pinned the old two-capability
array. It sits behind `describe.skipIf(PG_CONNECTION_STRING === undefined)`, so
a non-PG run skipped it entirely and the manifest widening in the previous
commit went unnoticed there — it fails against a real Postgres.

Now asserts ["content:read", "content:write", "network:request"] with the same
ADR-0012 rationale comment carried by the other capability guards. Verified with
PG_CONNECTION_STRING set: the whole plugin project is 397/397, 0 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Fkmqxb8d6hh4ETNNz43eZ
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vedanshujain

Copy link
Copy Markdown
Contributor Author

Staging Playwright DoD — PR #102 (plan §6.7 a–d)

Own instance from the worktree (urumi-wt-price-input-validation, port 4321), COMMERCE_SERVICE_URL=http://127.0.0.1:3000 (shared, read-only GETs only). Shared staging :4322 and shared :3000 left untouched. Chromium via playwright-core (headless, ~/.cache/ms-playwright). Local product: qa-pi102-item / SKU QA-PI102 / cms id 01KYF4DW9XDE4P6ZAD5X0S38AH.

Confirmed the hook actually registers: this is the first end-to-end proof that content:beforeSave fires with the new content:write capability declared by sites/staging/src/urumi-plugin-descriptor.ts (spreads URUMI_PLUGIN_CAPABILITIES, which now includes content:write). All rejections below produced the sentinel-keyed red toast, so the hook is live in trusted-mode staging.

Results

# Check Result
1 Create qa-pi102-item, SKU QA-PI102, USD, physical, stock 5, price 1500; save + publish; PDP shows $15.00 PASS
2 THE REPRO — edit price to 24.99, save → blocked with red toast; reload edit page → CMS field still 1500; GET /products/<id>/commerce still amount:1500 PASS
3 -5 price blocked; whitespace-only SKU (" ") blocked; valid 2499 saves cleanly → PDP $24.99 after Publish changes PASS
4a Clear price entirely → saves fine (unpriced) PASS
4b Currency selected, no price typed → saves fine PASS
4c After a blocked save, correcting the value → saves fine, no stuck state PASS (see note)
5 Draft/autosave revision path on a published product: invalid price + autosave fire → no Urumi — sentinel key anywhere in stored draft JSON PASS
6 Console hygiene / toast-repetition PASS (see note)

Key observations — Step 2 (the core repro)

  • CMS field value after the blocked save, on reload: 1500 (the last-good value), not 24.99.
  • Service amount after the blocked save: curl http://127.0.0.1:3000/products/01KYF4DW9XDE4P6ZAD5X0S38AH/commerce"price":{"amount":1500,"currency":"USD"} — unchanged.
  • Toast text matched the spec almost verbatim: "Urumi — Not saved. Price "24.99" must be whole minor units (2499 = $24.99): unknown field on collection 'products'" (red "Failed to save" + a second "Autosave failed" toast — em-dash fires both the manual-save and autosave paths).

Step 5 — draft/autosave sentinel check (how it was verified)

Typed an invalid decimal (56.78) directly into the Price field on the already-published product and waited for the autosave debounce without clicking Save or Publish — confirmed no publish-family button was clicked by dumping all visible button labels at that moment. The red "Autosave failed" toast fired (hook is live on the autosave path too).

Verified via the local D1 sqlite file in the worktree (sites/staging/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/*.sqlite, opened directly with the sqlite3 CLI — local only, never --remote, never port 5432):

SELECT count(*) FROM revisions WHERE entry_id='01KYF4DW9XDE4P6ZAD5X0S38AH' AND data LIKE '%Urumi —%';        -- 0
SELECT count(*) FROM ec_products WHERE id='01KYF4DW9XDE4P6ZAD5X0S38AH' AND commerce LIKE '%Urumi —%';        -- 0

Both zero. Stronger than "sentinel present but scrubbed on next read": the blocked autosave request never produced a new revisions row at all — revisions count for the entry stayed at 4, unchanged by the invalid-price autosave attempt, and the existing draft revision kept its prior (valid) price:1234. The invalid bag never reached storage in any form.

Defects / notes

  • No functional defects found. The hook fires, blocks correctly, and the escape hatches all work.
  • Toast-repetition during typing is expected and documented (ADR-0012 §"Autosave will toast repeatedly (~every 2s)"; changeset repeats the same note) — observed directly: a stale "Autosave failed" toast from a prior invalid keystroke can still be on-screen for ~1–2s after a subsequent valid save succeeds (step 4c screenshot). Confirmed this is only a stale toast, not a stuck save: reloading the edit page showed the corrected value (1234) persisted and GET /products/<id>/commerce matched.
  • One pre-existing, unrelated 404 in the console on first admin-bypass navigation only (not present on subsequent page loads) — looks like a benign static-asset 404 (e.g. favicon), also seen in prior unrelated QA passes on this staging site; not caused by this PR.
  • The intentional 400 Bad Request console entries during blocked saves are the expected rejection responses, not a defect.

Screenshots (stored on VM, not committed — paths under the QA work dir)

/tmp/claude-1000/-home-azureuser-urumi/4fe2d82e-abb3-432e-8cc1-10d2eac7147f/scratchpad/qa-pr102/:
step1-04-pdp.png ($15.00 PDP), step2-02-after-save-toast.png (the core repro toast), step2-03-after-reload.png (CMS field reverted to 1500), step3-01-negative-price-toast.png, step3-02-whitespace-sku-toast.png, step3-05-after-publish-changes.png (PDP $24.99 confirmed via curl), step4-01/02/04 (escape hatches), step5-01-after-autosave-invalid.png (draft/autosave block, no publish clicked).

No credential/token values appear in any screenshot or in this comment.

Instance stopped after the run (astro dev stop in the worktree's sites/staging); shared :4322 and :3000 were not touched beyond read-only GETs.

@vedanshujain

Copy link
Copy Markdown
Contributor Author

CI evidence (GitHub Actions did not dispatch a run for this branch)

GitHub created zero workflow runs for fix/price-input-validation across PR-open, close/reopen, and a synchronize push — while every sibling branch in this campaign got runs from the same unfiltered pull_request trigger. Actions is enabled (allowed_actions: all); this is a dispatcher-side anomaly, not a config problem. Possible org Actions quota/spending-limit stop — worth checking, as it affects future PRs too.

The CI job was therefore reproduced locally on this exact head commit (6a98f1c), matching the workflow's steps:

Step Result
pnpm lint (oxlint + dependency-cruiser domain-purity gate) pass — no dependency violations (1068 modules)
pnpm typecheck (tsc -b) pass
pnpm test with Postgres (mirrors CI's postgres:16 service) 165 files / 2054 tests passed, exit 0

Combined with the two independent code approvals and the staging DoD above (all steps PASS, sentinel-in-storage check verified by direct local-D1 SQL), this PR meets the merge gate.

🤖 Generated with Claude Code

@vedanshujain

Copy link
Copy Markdown
Contributor Author

Closing: superseded by work order 02 (plans/work-order-02-fold-service-into-plugin.md). Once the plugin owns commerce truth in-process the CMS-saved-price-vs-service divergence this PR guards against disappears structurally, and the sandboxed beforeSave hook cannot veto a save in any case. The branch also conflicts with main, its ADR number collides with the accepted ADR-0012, and it adds a content:write capability the descriptor is pinned to exclude.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant