import type { SupabaseClient } from '@supabase/supabase-js' import type { WooCredentials } from './api-client' import { decryptCredential } from './credentials' import type { WooCommerceConnection } from '../types' /** * WooCommerce "Auth Endpoint" handshake helpers. * * The merchant's browser is sent to {store}/wc-auth/v1/authorize; after they * approve, WooCommerce POSTs the generated consumer key/secret server-to- * server to our callback_url and redirects the browser to return_url. Our * oauth_state UUID rides in the handshake's user_id parameter and comes back * in both places, tying callback and return to the pending connection row. * * There is no signature on the callback POST, so possession of the * single-use state is the CSRF defense, and authenticity is proven by * probing the STORED store_url with the received keys before activation: a * forged POST would need working read credentials for the exact store the * user asked to connect. * * Activation itself needs TWO signals on the pending row: the verified keys * (callback leg, no browser session) and browser_confirmed_at (return leg, * bound to the initiator's session). Either leg may land first; both run * activateIfComplete() after writing their own signal, and the row flips to * active exactly once, under the DB CHECK that forbids an active row missing * either signal (migration 20260907143000). Every credential consumer selects * status = 'active', so staged keys on a pending row can never sync. * * Scope of the guarantee: a connection can never go live headless (callback * alone) or under a session other than the initiator's, and abandoned * handshakes are parked with their keys wiped. It is NOT a defense against a * store admin approving a link the initiator generated for them: wc-auth * cannot tell us who approved, and the initiator legitimately supplies the * second signal. The confirmation column is member-writable through RLS * like every other column on this row-scoped table; it records that the * handshake completed under a session, it is not a trust boundary. */ const APP_NAME = 'Accounted' /** * @param appOrigin The trusted application origin the merchant started the * connect on (canonical app URL or a registered white-label brand domain, * already validated by resolveRequestAppOrigin). The BROWSER leg returns * there: sessions are per domain, so a brand-domain user sent back to the * canonical host would hit the initiator check with no session and land * on a foreign-branded login. The server-to-server callback stays on the * canonical host: no session is involved and the store must reach a * stable URL. */ export function buildAuthorizeUrl(storeUrl: string, state: string, appOrigin: string): string { const baseUrl = process.env.NEXT_PUBLIC_APP_URL if (!baseUrl) throw new Error('NEXT_PUBLIC_APP_URL is not configured') const params = new URLSearchParams({ app_name: APP_NAME, // Read-only: the feed never writes to the store. scope: 'read', user_id: state, return_url: `${appOrigin}/api/extensions/woocommerce/return`, callback_url: `${baseUrl}/api/extensions/woocommerce/callback`, }) return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}` } /** * A pending handshake older than this is dead: the return leg refuses it, * activateIfComplete() will not flip it, and the nightly cron parks it with * its staged keys wiped. The callback leg does NOT refuse it: WooCommerce * turns any non-200 into a store-side error page and deletes the freshly * minted key, which would strand a slow but legitimate approval; staging on * a stale row is harmless because a pending row never syncs and the flip * itself carries the TTL. */ export const HANDSHAKE_TTL_MS = 15 * 60_000 export const HANDSHAKE_EXPIRED_MESSAGE = 'Anslutningen gick ut innan den slutfördes. Starta om anslutningen och ta bort den oanvända API-nyckeln i butikens WooCommerce-inställningar.' export function isHandshakeExpired(createdAt: string, now = Date.now()): boolean { return now - new Date(createdAt).getTime() > HANDSHAKE_TTL_MS } export interface ActivatedConnection { id: string company_id: string user_id: string store_url: string } export type ActivationResult = | { outcome: 'activated'; connection: ActivatedConnection } /** The other signal has not landed yet (or the row is no longer pending). */ | { outcome: 'incomplete' } /** 23505: the store is already actively connected (this or another company). */ | { outcome: 'conflict'; error: { code?: string; message: string } } | { outcome: 'failed'; error: { code?: string; message: string } } /** * Flip a pending row to active if, and only if, both signals are present. * * Both handshake legs call this after writing their own signal. Postgres * serializes the two UPDATEs on the row, and the WHERE is re-evaluated * against the latest committed version, so whichever leg runs last sees both * signals and activates; the other matches zero rows and reports incomplete. * The status = 'pending' scope makes it idempotent and blocks replay: an * active row can never be activated again. * * The TTL is part of the predicate, not only of the return leg: the * initiator can confirm early (the return URL is theirs to open), so * without it a row confirmed at minute 1 would still flip when the keys * land hours later. Past the TTL the flip matches zero rows and the sweep * parks the row; the callback still answers 200 so the store does not * wp_die on the merchant. */ export async function activateIfComplete( supabase: SupabaseClient, connectionId: string, now = Date.now(), ): Promise { const { data, error } = await supabase .from('woocommerce_connections') .update({ status: 'active', connected_at: new Date().toISOString(), error_message: null, // The state has done its job once both legs have found the row. oauth_state: null, // Feed-only product: connecting the store means fetching its orders, so // the nightly feed starts on by default; the panel toggle is the opt-out. transaction_sync_enabled: true, }) .eq('id', connectionId) .eq('status', 'pending') .not('consumer_key_encrypted', 'is', null) .not('consumer_secret_encrypted', 'is', null) .not('browser_confirmed_at', 'is', null) .gte('created_at', new Date(now - HANDSHAKE_TTL_MS).toISOString()) .select('id, company_id, user_id, store_url') .maybeSingle() if (error) { const err = { code: error.code, message: error.message } return error.code === '23505' ? { outcome: 'conflict', error: err } : { outcome: 'failed', error: err } } if (!data) return { outcome: 'incomplete' } return { outcome: 'activated', connection: data as ActivatedConnection } } /** * Park every pending handshake older than the TTL: status error, staged keys * wiped, state consumed. Nothing on a pending row can sync, so this is * hygiene for encrypted-at-rest secrets rather than a security boundary; * running it once a night (from the orders cron) is enough. */ export async function expireStaleHandshakes( supabase: SupabaseClient, now = Date.now(), ): Promise<{ expired: number; error: { code?: string; message: string } | null }> { const { data, error } = await supabase .from('woocommerce_connections') .update({ status: 'error', error_message: HANDSHAKE_EXPIRED_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('status', 'pending') .lt('created_at', new Date(now - HANDSHAKE_TTL_MS).toISOString()) .select('id') if (error) return { expired: 0, error: { code: error.code, message: error.message } } return { expired: data?.length ?? 0, error: null } } /** Decrypted API credentials for an active connection. */ export function credentialsOf( connection: Pick< WooCommerceConnection, 'store_url' | 'consumer_key_encrypted' | 'consumer_secret_encrypted' >, ): WooCredentials { if (!connection.consumer_key_encrypted || !connection.consumer_secret_encrypted) { throw new Error('Connection has no stored credentials') } return { storeUrl: connection.store_url, consumerKey: decryptCredential(connection.consumer_key_encrypted), consumerSecret: decryptCredential(connection.consumer_secret_encrypted), } }