Files
accounted/extensions/general/whatsapp-inbox/lib/linking.ts
T
MattssonandClaude Fable 5.1 1a725c121a fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel (#2349)
* fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel

Sends now say HOW they failed (failure: http_rejected | transport_error),
and the company question falls back to numbered text only on an HTTP
rejection. A timeout means Meta may already have delivered the interactive
question, so that case rolls the question back instead of putting a second
copy on the phone; the next receipt re-asks.

Both drains (the company answer and the single-live-company path) share
one helper that stamps rows older than Meta's ~30-day media retention as
company_choice_expired instead of re-opening them into the MAX_ATTEMPTS
error path, and tell the sender once (M20) how many receipts could not be
recovered. STAGED_MEDIA_MAX_AGE_MS moved to conversation.ts so the sweep
and the drains read one definition.

POST /link/default-company checks LIVE membership with the same
companies!inner(archived_at) filter intake uses, so a default pointing at
an archived company is refused (403) instead of saved and then silently
ignored; a failed membership read is a 500, not a 403.

consumeLinkCode is tri-state: a failed lookup or claim returns
'transient_error' and the webhook answers with a neutral retry (M21)
instead of M2 "the code is wrong" to a user holding a valid code. In
degraded mode (quota RPC down too) M21 sits behind the same fail-closed
throttle as M2.

Refs #2062

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

* fix(whatsapp): drain failures are reported and swept, M21 shares the M2 throttle

Review pass on #2349 (CodeRabbit, four findings, one commit):

- drainParkedRows reads the error of both UPDATEs, logs each, and returns
  failed: true. The answer stays applied (pin set, options claimed) and the
  single-company path still clears the dead question: both turn the rows
  that are still parked into orphans, and a new sweep pass re-opens parked
  rows whose conversation has no open company question (older than two
  minutes, so a row parked just before its question is armed is left
  alone) through the same drain and processes them.
- badCodeThrottled counts M21 alongside M2, so a code-shaped flood during a
  lookup outage cannot earn one M21 per message in degraded mode.
- The M20 expiry notice logs a failed send. It stays best-effort like M17,
  M18 and M19: the extension has no durable outbound retry, and the rows
  the notice describes are already terminal.

Refs #2062

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

* fix(whatsapp): type the orphan scan rows and the cron summary fixture

reopenedOrphans joined SweepSummary in the previous commit; the cron route
test's fixture and the PostgREST embed cast in the orphan pass had not
followed (check:types caught both).

Refs #2062

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:14:06 +02:00

238 lines
8.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 a hash. That hash is
* HMAC-peppered, NOT a plain sha256: invite tokens are 256-bit random, but a
* link code is one of 30^6 values behind a fixed 'AC-' prefix, and enumerating
* that space offline takes about a second (the exact reason phone-crypto.ts
* peppers the phone hash).
*
* 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, hashSecret, 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
/** Codes a single account may mint inside the TTL window before it must wait.
* The panel mints one per visit; anything above this is a script. */
export const MAX_CODES_PER_TTL_WINDOW = 5
export function hashLinkCode(code: string): string {
return hashSecret(code)
}
/** Thrown by mintLinkCode when the caller is minting far too fast. */
export class LinkCodeRateLimitError extends Error {
readonly name = 'LinkCodeRateLimitError'
constructor() {
super('Too many link codes requested')
}
}
/**
* 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.
*
* Exactly one code per account is live at a time: minting burns the caller's
* earlier unused codes, so the panel showing a code is the only code that
* works. Minting is also capped per TTL window, because the route is an
* authenticated unbounded INSERT otherwise.
*/
export async function mintLinkCode(
serviceClient: SupabaseClient,
userId: string,
): Promise<MintedCode> {
const windowStart = new Date(Date.now() - CODE_TTL_MS).toISOString()
const { count } = await serviceClient
.from('whatsapp_link_codes')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.gte('created_at', windowStart)
if ((count ?? 0) >= MAX_CODES_PER_TTL_WINDOW) throw new LinkCodeRateLimitError()
await serviceClient
.from('whatsapp_link_codes')
.update({ used_at: new Date().toISOString() })
.eq('user_id', userId)
.is('used_at', null)
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 }
}
/**
* Outcome of consuming a code:
* - { userId }: verified and claimed, single-use.
* - null: unknown, expired, already used, or lost the claim race. A verdict
* about the CODE, so the caller may say "bad code" (M2).
* - 'transient_error': the lookup or the claim could not be executed (DB
* error, statement timeout). No verdict at all: the code is untouched and
* still valid, so the caller must not answer M2 (#2062). Same tri-state
* idiom as resolveCompanyTarget in process-inbound.ts.
*/
export type LinkCodeConsumption = { userId: string } | null | 'transient_error'
/**
* Verify + consume a code from an inbound chat message. 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<LinkCodeConsumption> {
const code = normalizeLinkCode(rawText)
if (!code) return null
const { data: row, error: lookupError } = await serviceClient
.from('whatsapp_link_codes')
.select('id, user_id, expires_at, used_at')
.eq('code_hash', hashLinkCode(code))
.maybeSingle()
if (lookupError) return 'transient_error'
if (!row || row.used_at) return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
const { data: claimed, error: claimError } = await serviceClient
.from('whatsapp_link_codes')
.update({ used_at: new Date().toISOString() })
.eq('id', row.id)
.is('used_at', null)
.select('id')
.maybeSingle()
if (claimError) return 'transient_error'
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
}