Files
accounted/extensions/general/whatsapp-inbox/lib/linking.ts
T
398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
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>
2026-08-05 14:47:08 +02:00

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
}