Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token compare); POST verifies X-Hub-Signature-256 over the RAW body before any parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid = dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers media processing via the after() idiom. Rejected and rate-limited content always acks 200 and lands as skipped/error rows, never a retryable status. Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256- GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp; unknown senders get one throttled M1 greeting (1/h, 3/day) behind the sender-quota RPC, with no media download and no content persistence. Intake worker: atomic claim on the message row (the durable job record), company resolution (default -> sole membership -> M6 fallback, no item), per-company inbox quota (ack-and-drop, M17 once per 10 min per sender), MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate check, then the shared uploadAndExtract funnel (source 'whatsapp', channel_context caption, whatsapp_message_id) and the M4 ack with extracted merchant/total/date. Failures wrap to 'error' + error_message + one M18. uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId; email/upload paths behaviorally unchanged. Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice (M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM call, sweep cron, retention cron. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
190 lines
6.3 KiB
TypeScript
190 lines
6.3 KiB
TypeScript
/**
|
|
* One-time link codes + phone-link lifecycle.
|
|
*
|
|
* The code proves control of an Accounted account (minted in an authenticated
|
|
* settings panel) and sending it proves possession of the phone; the webhook
|
|
* binds the two. Invite-token pattern (lib/auth/invite-tokens.ts): the raw
|
|
* code exists only in the user's chat, the DB stores its sha256.
|
|
*
|
|
* All functions here take a SERVICE-ROLE client: whatsapp_link_codes has RLS
|
|
* enabled with no policies, and link INSERTs are service-role only by design
|
|
* (see migration 20260802090000).
|
|
*/
|
|
|
|
import crypto from 'crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { WhatsAppPhoneLink } from '@/types'
|
|
import { encryptPhone, hashPhone, maskPhone } from './phone-crypto'
|
|
|
|
/** Uppercased twin of generate_inbox_local_part's ambiguity-free alphabet
|
|
* (no I/L/O/U, no 0/1): codes survive being read aloud or retyped. */
|
|
export const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'
|
|
export const CODE_PREFIX = 'AC-'
|
|
export const CODE_LENGTH = 6
|
|
export const CODE_TTL_MS = 10 * 60 * 1000
|
|
|
|
export function hashLinkCode(code: string): string {
|
|
return crypto.createHash('sha256').update(code).digest('hex')
|
|
}
|
|
|
|
/**
|
|
* Normalize a chat message into a canonical code ('AC-7KP4QF') or null when
|
|
* the text does not look like a code at all. Forgiving on formatting (trims,
|
|
* uppercases, tolerates a missing hyphen), but a BARE 6-char body without the
|
|
* AC prefix must contain at least one digit: otherwise ordinary greetings
|
|
* built from alphabet letters ('hej hej' -> HEJHEJ) would read as codes and
|
|
* earn a confusing M2 instead of the M1 greeting. The panel and the wa.me
|
|
* prefill always carry the prefix, so prefixed codes are never rejected.
|
|
*/
|
|
export function normalizeLinkCode(text: string | null | undefined): string | null {
|
|
if (!text) return null
|
|
const compact = text.trim().toUpperCase().replace(/\s+/g, '')
|
|
const match = compact.match(/^(AC-?)?([A-Z2-9]{6})$/)
|
|
if (!match) return null
|
|
const hasPrefix = match[1] != null
|
|
const body = match[2]
|
|
for (const ch of body) {
|
|
if (!CODE_ALPHABET.includes(ch)) return null
|
|
}
|
|
if (!hasPrefix && !/[2-9]/.test(body)) return null
|
|
return `${CODE_PREFIX}${body}`
|
|
}
|
|
|
|
export function looksLikeLinkCode(text: string | null | undefined): boolean {
|
|
return normalizeLinkCode(text) !== null
|
|
}
|
|
|
|
export interface MintedCode {
|
|
code: string
|
|
expiresAt: string
|
|
}
|
|
|
|
/** Mint a fresh code for the settings panel. Earlier unused codes stay valid
|
|
* until their own 10-minute expiry; the webhook consumes whichever arrives. */
|
|
export async function mintLinkCode(
|
|
serviceClient: SupabaseClient,
|
|
userId: string,
|
|
): Promise<MintedCode> {
|
|
let body = ''
|
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
body += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]
|
|
}
|
|
const code = `${CODE_PREFIX}${body}`
|
|
const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString()
|
|
|
|
const { error } = await serviceClient.from('whatsapp_link_codes').insert({
|
|
user_id: userId,
|
|
code_hash: hashLinkCode(code),
|
|
expires_at: expiresAt,
|
|
})
|
|
if (error) throw new Error(`Failed to mint link code: ${error.message}`)
|
|
|
|
return { code, expiresAt }
|
|
}
|
|
|
|
/**
|
|
* Verify + consume a code from an inbound chat message. Returns the owning
|
|
* user id, or null for unknown/expired/already-used codes. Single-use is
|
|
* enforced by the guarded UPDATE (used_at IS NULL): a concurrent redelivery
|
|
* loses the race and gets null.
|
|
*/
|
|
export async function consumeLinkCode(
|
|
serviceClient: SupabaseClient,
|
|
rawText: string,
|
|
): Promise<{ userId: string } | null> {
|
|
const code = normalizeLinkCode(rawText)
|
|
if (!code) return null
|
|
|
|
const { data: row } = await serviceClient
|
|
.from('whatsapp_link_codes')
|
|
.select('id, user_id, expires_at, used_at')
|
|
.eq('code_hash', hashLinkCode(code))
|
|
.maybeSingle()
|
|
|
|
if (!row || row.used_at) return null
|
|
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
|
|
|
const { data: claimed } = await serviceClient
|
|
.from('whatsapp_link_codes')
|
|
.update({ used_at: new Date().toISOString() })
|
|
.eq('id', row.id)
|
|
.is('used_at', null)
|
|
.select('id')
|
|
.maybeSingle()
|
|
|
|
if (!claimed) return null
|
|
return { userId: row.user_id }
|
|
}
|
|
|
|
export interface CreatedPhoneLink {
|
|
link: WhatsAppPhoneLink
|
|
conversationId: string | null
|
|
}
|
|
|
|
/**
|
|
* Bind a verified phone to a user: revoke whatever active links stand in the
|
|
* way of the two partial-unique indexes (same phone bound elsewhere, or the
|
|
* user re-linking from a new phone), then insert the link + its conversation
|
|
* row. Revocation-not-deletion keeps the trail auditable.
|
|
*/
|
|
export async function createPhoneLink(
|
|
serviceClient: SupabaseClient,
|
|
args: { userId: string; phone: string; profileName?: string | null },
|
|
): Promise<CreatedPhoneLink> {
|
|
const phoneHash = hashPhone(args.phone)
|
|
const now = new Date().toISOString()
|
|
|
|
await serviceClient
|
|
.from('whatsapp_phone_links')
|
|
.update({ revoked_at: now })
|
|
.eq('phone_hash', phoneHash)
|
|
.is('revoked_at', null)
|
|
await serviceClient
|
|
.from('whatsapp_phone_links')
|
|
.update({ revoked_at: now })
|
|
.eq('user_id', args.userId)
|
|
.is('revoked_at', null)
|
|
|
|
const { data: link, error } = await serviceClient
|
|
.from('whatsapp_phone_links')
|
|
.insert({
|
|
user_id: args.userId,
|
|
phone_hash: phoneHash,
|
|
phone_enc: encryptPhone(args.phone),
|
|
phone_masked: maskPhone(args.phone),
|
|
wa_profile_name: args.profileName?.slice(0, 200) ?? null,
|
|
last_message_at: now,
|
|
})
|
|
.select('*')
|
|
.single()
|
|
if (error || !link) {
|
|
throw new Error(`Failed to create phone link: ${error?.message ?? 'no row returned'}`)
|
|
}
|
|
|
|
const { data: conversation } = await serviceClient
|
|
.from('whatsapp_conversations')
|
|
.insert({
|
|
phone_link_id: link.id,
|
|
last_inbound_at: now,
|
|
service_window_expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
})
|
|
.select('id')
|
|
.maybeSingle()
|
|
|
|
return { link: link as WhatsAppPhoneLink, conversationId: conversation?.id ?? null }
|
|
}
|
|
|
|
/** Active (non-revoked) link for a phone hash, or null. */
|
|
export async function lookupActiveLink(
|
|
serviceClient: SupabaseClient,
|
|
phoneHash: string,
|
|
): Promise<WhatsAppPhoneLink | null> {
|
|
const { data } = await serviceClient
|
|
.from('whatsapp_phone_links')
|
|
.select('*')
|
|
.eq('phone_hash', phoneHash)
|
|
.is('revoked_at', null)
|
|
.maybeSingle()
|
|
return (data as WhatsAppPhoneLink | null) ?? null
|
|
}
|