* fix(woocommerce): require both verified keys and browser confirmation to activate a connection
The wc-auth callback alone used to flip a connection to active. Until the
initiator's browser reached the return route the row was syncable, so an
approver who never came back (closed tab, skipped sign-in, or lured into
approving a connect someone else started) left their store's keys active
inside another company's books, reachable by manual sync within seconds.
Now the callback only stages the verified keys on the pending row, the
return leg records browser_confirmed_at after the initiator check, and one
conditional update flips the row to active exactly once when both signals
are present, in either arrival order. A DB CHECK (20260907100000) makes an
active row without both signals impossible; every consumer selects
status = 'active', so staged keys can never sync.
Also: 15-minute handshake TTL on both legs, duplicate callback refused,
stale pending rows swept (keys wiped) at the start of the nightly orders
cron, manual key entry records the confirmation itself, every path that
closes a pending row wipes staged keys, expired/conflict toasts in sv + en.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz
* fix(woocommerce): drop the callback-leg TTL and state the gate's real scope
Skeptic pass on the activation gate:
- WooCommerce answers any non-200 callback response by deleting the key it
just minted and showing a store-side error page, never redirecting back.
A 410 for a slow approval therefore stranded the merchant. The callback
now stages regardless of age; the session-bound return leg and the nightly
sweep enforce expiry, and a stale pending row cannot sync either way.
- The second activation signal comes from the initiating user, so the gate
does not stop a store admin from approving a link someone else generated
(wc-auth delivers keys server-to-server and identifies no approver). The
migration header, route comments, decision log and PR body now say so
instead of claiming otherwise. Proof of store control is a follow-up.
- Every path that parks a pending row also wipes the store metadata the
probe staged, so a refused handshake leaves nothing of the store behind.
- A duplicate callback POST is a 200 no-op instead of a 409, so the status
code no longer tells the state holder whether the merchant has approved.
- The expiry message tells the user to remove the unused key in the store.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz
* fix(woocommerce): carry the handshake TTL in the activation flip, wipe metadata on denial
Re-verification of the skeptic fixes found two gaps:
- The initiator can open the return URL early, so a row confirmed at
minute one still flipped when the keys landed hours later; the callback
no longer refuses stale rows, so nothing bounded that. The conditional
activation update now also requires created_at within the TTL. The
callback still answers 200 (no store-side wp_die); the flip matches zero
rows and the sweep parks the row. Covered by a pg-real case with both
signals present on a 20-minute-old row.
- The store-denied path parked the row without clearing the staged store
metadata. It now wipes the same five columns as every other parking path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz
* chore(migrations): move the WooCommerce activation gate to 20260907143000 after main took 20260907100000
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz
* fix(woocommerce): make browser_confirmed_at server-only, finish replayed callbacks, budget the cron sweep
Review round on PR #2375:
- Superagent P1: browser_confirmed_at was member-writable through the
row-scoped RLS policy, so any writer of the company could supply the
initiator's signal on a colleague's pending row. New migration
20260907150000 adds a trigger keyed on the JWT role claim (same pattern as
enforce_company_writer_role): end-user sessions cannot insert or update
the column, service role and migrations pass. Manual key entry now
inserts on the service client with company_id/user_id from the verified
context. pg-real covers refusal on insert and update plus the server path.
- CodeRabbit: a replayed callback for an already-keyed row now runs the
activation flip instead of returning early, so a callback cut off between
staging and activating is completed by its retry.
- CodeRabbit: the cron deadline is fixed before the stale-handshake sweep,
so the sweep counts against the route's maxDuration budget.
- CodeRabbit: the activation CHECK is added NOT VALID (rows were already
conformed by the backfill) and validated in 20260907150000 under the
weaker lock.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz
* fix(woocommerce): make activation itself server-only, install the trigger before validating the gate
Second review round on PR #2375:
- CodeRabbit: an authenticated company member could still UPDATE ... SET
status = 'active' on a fully staged row through PostgREST; the CHECK only
proves both signals exist, and the 15-minute TTL lives in the server's
conditional activation update. The server-only trigger now also refuses
any end-user transition into 'active' (insert or update). Leaving
'active' (disconnect, supersede, revoked-key marking) stays
member-writable. pg-real covers an expired, fully staged row: 42501 from
a user session, then a member disconnect after the server activates.
- Superagent P2: the VALIDATE CONSTRAINT now runs after the trigger is
installed, so the gate is never enforced while its signal is still
member-writable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>