Files
accounted/lib/init.ts
T
Jakob Wennberg 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

103 lines
4.1 KiB
TypeScript

import { loadExtensions } from '@/lib/extensions/loader'
import { setContextFactory } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler'
import { registerWebhookHandler } from '@/lib/webhooks/handler'
import { registerObservabilitySink } from '@/lib/observability'
import { postHogSink } from '@/lib/analytics/posthog-observability'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import { createLogger } from '@/lib/logger'
const log = createLogger('init')
let initialized = false
const REQUIRED_CORE_VARS = [
'NEXT_PUBLIC_SUPABASE_URL',
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
'SUPABASE_SERVICE_ROLE_KEY',
'NEXT_PUBLIC_APP_URL',
'CRON_SECRET',
] as const
// Each entry is one logical requirement; if multiple names are listed, the
// requirement is satisfied when ANY of them is set. Mirrors the runtime
// fallback in extensions/general/enable-banking/lib/jwt.ts (_PRODUCTION ||
// base) so Vercel prod (which only sets the _PRODUCTION variants) doesn't
// warn on every cold start.
// AI features run Claude via AWS Bedrock (see lib/agent/composer/client.ts and
// extensions/general/invoice-inbox/lib/extract-invoice-fields.ts), so the
// static AWS keys are what actually gates them. The assistant's client can
// fall back to the AWS credential provider chain (instance profile, IRSA),
// but document extraction requires both static keys, so this log-only warning
// stays useful even on AWS infrastructure.
const REQUIRED_EXTENSION_VARS: ReadonlyArray<readonly string[]> = [
['ENABLE_BANKING_APP_ID_PRODUCTION', 'ENABLE_BANKING_APP_ID'],
['ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_PRIVATE_KEY'],
['AWS_ACCESS_KEY_ID'],
['AWS_SECRET_ACCESS_KEY'],
// whatsapp-inbox extension (Meta Cloud API + phone PII at rest)
['WHATSAPP_ACCESS_TOKEN'],
['WHATSAPP_PHONE_NUMBER_ID'],
['WHATSAPP_APP_SECRET'],
['WHATSAPP_VERIFY_TOKEN'],
['WHATSAPP_PHONE_HASH_KEY'],
['WHATSAPP_PHONE_ENCRYPTION_KEY'],
] as const
function validateEnvironment(): void {
// During builds (CI, Docker, Vercel), env vars may be absent or set to
// placeholder sentinels. Skip validation so Next.js page collection
// doesn't fail: real validation happens at runtime.
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
if (!supabaseUrl || supabaseUrl.startsWith('__')) return
const missing: string[] = []
for (const v of REQUIRED_CORE_VARS) {
if (!process.env[v]) missing.push(v)
}
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`)
}
const missingExt: string[] = []
for (const aliases of REQUIRED_EXTENSION_VARS) {
if (!aliases.some((v) => !!process.env[v])) {
missingExt.push(aliases.join(' or '))
}
}
if (missingExt.length > 0) {
log.warn(`Missing extension environment variables (extensions needing them may not work): ${missingExt.join(', ')}`)
}
}
/**
* Ensure the system is initialized (extensions loaded, context factory wired,
* core event handlers registered).
* Called from API routes that emit events.
* Idempotent: safe to call multiple times.
*/
export function ensureInitialized(): void {
if (initialized) return
validateEnvironment()
setContextFactory(createExtensionContext)
// Turns lib/observability from a no-op into PostHog Error Tracking. Gated,
// so with no token (core, CI, self-hosted) the sink stays the no-op and
// PostHog is never constructed and never contacted. Note the SDK is still
// BUNDLED in those builds: the imports are static, so the bytes ship even
// though nothing initialises. Making that a true zero would mean dynamic
// imports at every posthog call site, which is a deliberate non-goal here.
if (isAnalyticsEnabled()) registerObservabilitySink(postHogSink)
registerSupplierInvoiceHandler()
registerEventLogHandler()
registerWebhookHandler()
loadExtensions()
initialized = true
}