* 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>
257 lines
9.8 KiB
TypeScript
257 lines
9.8 KiB
TypeScript
import { UUID_RE } from '@/lib/invariants/uuid'
|
|
import { NextResponse } from 'next/server'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { loadExtensions } from '@/lib/extensions/loader'
|
|
import { extensionRegistry } from '@/lib/extensions/registry'
|
|
import { createLogger } from '@/lib/logger'
|
|
import {
|
|
encryptCredential,
|
|
isWooCommerceConfigured,
|
|
} from '@/extensions/general/woocommerce/lib/credentials'
|
|
import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client'
|
|
import { activateIfComplete } from '@/extensions/general/woocommerce/lib/connect'
|
|
|
|
// This route emits woocommerce.connected (audit trail). ensureInitialized()
|
|
// must run at module load so the event_log handler has subscribed before the
|
|
// first emit on a cold instance.
|
|
ensureInitialized()
|
|
|
|
const log = createLogger('woocommerce/callback')
|
|
|
|
// The credential probe talks to an arbitrary (often slow) WooCommerce host.
|
|
export const maxDuration = 60
|
|
|
|
/**
|
|
* POST /api/extensions/woocommerce/callback
|
|
*
|
|
* Server-to-server delivery of the wc-auth handshake result: WooCommerce
|
|
* POSTs { key_id, user_id, consumer_key, consumer_secret, key_permissions }
|
|
* here after the merchant approves. Must be a real Next.js route (not an
|
|
* extension dispatcher handler) because the store calls it directly,
|
|
* unauthenticated: the single-use oauth_state riding in user_id locates the
|
|
* pending row, and the received keys are verified against that row's stored
|
|
* store_url before anything is persisted.
|
|
*
|
|
* This leg has no browser session, so it can only STAGE the verified keys on
|
|
* the pending row. The row becomes active when the initiator's session has
|
|
* confirmed on the return leg as well (activateIfComplete, either order).
|
|
* A pending row never syncs, so keys staged for a handshake nobody confirms
|
|
* are inert until the nightly sweep wipes them.
|
|
*
|
|
* No TTL refusal here on purpose: WooCommerce treats any non-200 as a
|
|
* failed handshake (deletes the key it just minted and shows the merchant an
|
|
* error page on the store, no redirect back), so refusing a slow approval
|
|
* here would strand a legitimate merchant. The TTL lives in the activation
|
|
* predicate and on the session-bound return leg, and the nightly sweep parks
|
|
* what is left; a stale pending row cannot flip and never syncs.
|
|
*/
|
|
export async function POST(request: Request) {
|
|
loadExtensions()
|
|
if (!extensionRegistry.get('woocommerce')) {
|
|
return NextResponse.json(
|
|
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
// The registry does not check manifest requiredEnvVars, so this route can be
|
|
// live without the encryption key; without this guard encryptCredential()
|
|
// would throw AFTER the probe, escaping the markError path entirely.
|
|
if (!isWooCommerceConfigured()) {
|
|
return NextResponse.json(
|
|
{ error: 'WooCommerce integration is not configured', code: 'NOT_CONFIGURED' },
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
|
|
let body: {
|
|
user_id?: unknown
|
|
consumer_key?: unknown
|
|
consumer_secret?: unknown
|
|
key_permissions?: unknown
|
|
}
|
|
try {
|
|
body = await request.json()
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
}
|
|
|
|
const state = typeof body.user_id === 'string' ? body.user_id : null
|
|
const consumerKey = typeof body.consumer_key === 'string' ? body.consumer_key : null
|
|
const consumerSecret = typeof body.consumer_secret === 'string' ? body.consumer_secret : null
|
|
const keyPermissions =
|
|
typeof body.key_permissions === 'string' ? body.key_permissions : null
|
|
// The state is a UUID we generated; reject anything else before it reaches
|
|
// the DB (the column is typed uuid and would error opaquely).
|
|
const isUuid =
|
|
state !== null &&
|
|
UUID_RE.test(state)
|
|
if (!isUuid || !consumerKey || !consumerSecret) {
|
|
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 })
|
|
}
|
|
|
|
const supabase = await createServiceClient()
|
|
|
|
const { data: pending, error: findError } = await supabase
|
|
.from('woocommerce_connections')
|
|
.select('id, company_id, user_id, store_url, consumer_key_encrypted')
|
|
.eq('oauth_state', state)
|
|
.eq('status', 'pending')
|
|
.single()
|
|
|
|
if (findError || !pending) {
|
|
log.warn('no pending connection for handshake state', {
|
|
hasRow: Boolean(pending),
|
|
code: findError?.code,
|
|
})
|
|
return NextResponse.json({ error: 'Unknown or expired state' }, { status: 404 })
|
|
}
|
|
|
|
const markError = (message: string) =>
|
|
supabase
|
|
.from('woocommerce_connections')
|
|
.update({
|
|
status: 'error',
|
|
error_message: message,
|
|
oauth_state: null,
|
|
consumer_key_encrypted: null,
|
|
consumer_secret_encrypted: null,
|
|
store_name: null,
|
|
currency: null,
|
|
prices_include_tax: null,
|
|
wc_version: null,
|
|
key_permissions: null,
|
|
})
|
|
.eq('id', pending.id)
|
|
.eq('status', 'pending')
|
|
|
|
// WooCommerce posts once per approval. A second POST for the same state is
|
|
// a replay of something we already hold: never re-probe or overwrite staged
|
|
// keys on its say-so. It still runs the activation flip, so a callback that
|
|
// was cut off between staging and activating is completed by its retry, and
|
|
// it answers 200 either way so the status code does not double as an "has
|
|
// the merchant approved yet" oracle for whoever holds the state.
|
|
if (pending.consumer_key_encrypted) {
|
|
log.warn('duplicate handshake callback for a state that already holds keys', {
|
|
connectionId: pending.id,
|
|
})
|
|
return finishActivation(supabase, pending.id, markError)
|
|
}
|
|
|
|
// Authenticity check: the keys must actually work against the store URL the
|
|
// user asked to connect. A forged callback with someone else's (or made-up)
|
|
// keys fails here and never gets stored.
|
|
let storeInfo
|
|
try {
|
|
storeInfo = await testConnectionAndFetchStoreInfo({
|
|
storeUrl: pending.store_url,
|
|
consumerKey,
|
|
consumerSecret,
|
|
})
|
|
} catch (probeError) {
|
|
log.error('credential probe failed during handshake', {
|
|
connectionId: pending.id,
|
|
message: probeError instanceof Error ? probeError.message : String(probeError),
|
|
})
|
|
await markError('Nycklarna kunde inte verifieras mot butiken.')
|
|
return NextResponse.json({ error: 'Credential verification failed' }, { status: 502 })
|
|
}
|
|
|
|
// Stage the verified keys. Status stays pending: this POST has no browser
|
|
// session, so the initiator binding happens on the return leg, and the row
|
|
// only flips to active once that leg has confirmed too. oauth_state stays
|
|
// until activation so the return leg can still find the row.
|
|
const { data: staged, error: stageError } = await supabase
|
|
.from('woocommerce_connections')
|
|
.update({
|
|
consumer_key_encrypted: encryptCredential(consumerKey),
|
|
consumer_secret_encrypted: encryptCredential(consumerSecret),
|
|
key_permissions: keyPermissions,
|
|
store_name: storeInfo.name,
|
|
currency: storeInfo.currency,
|
|
prices_include_tax: storeInfo.prices_include_tax,
|
|
wc_version: storeInfo.wc_version,
|
|
error_message: null,
|
|
})
|
|
.eq('id', pending.id)
|
|
.eq('status', 'pending')
|
|
.select('id')
|
|
.maybeSingle()
|
|
|
|
if (stageError || !staged) {
|
|
// The row left 'pending' between lookup and here (superseded, denied on
|
|
// the return leg, or expired by the sweep): nothing to stage against.
|
|
log.error('failed to stage verified credentials', {
|
|
connectionId: pending.id,
|
|
code: stageError?.code,
|
|
message: stageError?.message,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: stageError ? 'Staging failed' : 'Unknown or expired state' },
|
|
{ status: stageError ? 500 : 404 },
|
|
)
|
|
}
|
|
|
|
return finishActivation(supabase, pending.id, markError)
|
|
}
|
|
|
|
/**
|
|
* Run the conditional flip for a row whose keys are staged and answer
|
|
* WooCommerce. Shared by the first callback and by a replayed one.
|
|
*/
|
|
async function finishActivation(
|
|
supabase: Awaited<ReturnType<typeof createServiceClient>>,
|
|
connectionId: string,
|
|
markError: (message: string) => PromiseLike<unknown>,
|
|
): Promise<Response> {
|
|
const activation = await activateIfComplete(supabase, connectionId)
|
|
|
|
if (activation.outcome === 'incomplete') {
|
|
// Browser has not confirmed yet (the usual order: WooCommerce posts here
|
|
// before it redirects the merchant). The return leg finishes the job.
|
|
return NextResponse.json({ success: true, activated: false })
|
|
}
|
|
|
|
if (activation.outcome !== 'activated') {
|
|
// 23505 = a partial unique index: the store is already actively connected
|
|
// (to this or another company), or the company connected in a parallel tab.
|
|
const isConflict = activation.outcome === 'conflict'
|
|
log.error('failed to activate connection', {
|
|
connectionId,
|
|
code: activation.error.code,
|
|
message: activation.error.message,
|
|
})
|
|
await markError(
|
|
isConflict
|
|
? 'Butiken är redan ansluten till ett företag.'
|
|
: 'Anslutningen kunde inte slutföras.',
|
|
)
|
|
return NextResponse.json(
|
|
{ error: isConflict ? 'Store already connected' : 'Activation failed' },
|
|
{ status: isConflict ? 409 : 500 },
|
|
)
|
|
}
|
|
|
|
const activated = activation.connection
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'woocommerce.connected',
|
|
payload: {
|
|
connectionId: activated.id,
|
|
storeUrl: activated.store_url,
|
|
userId: activated.user_id,
|
|
companyId: activated.company_id,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
// Non-fatal: the DB state (source of truth) is already committed.
|
|
log.error('failed to emit woocommerce.connected', {
|
|
connectionId: activated.id,
|
|
message: emitError instanceof Error ? emitError.message : String(emitError),
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ success: true, activated: true })
|
|
}
|