* 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>
238 lines
8.3 KiB
TypeScript
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
|
|
}
|