All files / web/src/lib/abacus/print webhook.ts

100% Statements 65/65
66.66% Branches 4/6
100% Functions 3/3
100% Lines 65/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 662x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x  
/**
 * Doorbell webhook registration (Abacus Studio Phase 2a, #8.2/#8.4).
 *
 * After pairing, we register a per-connection ring URL with the print
 * service: `PUT /webhook {url, secret}` (full-replacement semantics on the
 * service side). The secret authenticates inbound rings at
 * `/api/abacus/print/ring/[connectionId]` and is sealed at rest.
 *
 * Registration is deliberately separable from pairing: pairing codes are
 * single-use, so a webhook failure must never lose a freshly minted token.
 * Callers persist the connection first, then attempt registration, and can
 * re-run it any time.
 */
import { randomBytes } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import type { PrintServiceConnection } from '@/db/schema'
import { seal } from '@/lib/secret-box'
import { ensureOk, printServiceFetch } from './print-service-fetch'
 
/**
 * Origin the print service should ring back to. `PRINT_RING_ORIGIN` overrides
 * for split-horizon setups; otherwise the public site origin (which
 * split-horizon DNS makes LAN-reachable in the home deployment).
 */
export function ringOrigin(): string {
  return process.env.PRINT_RING_ORIGIN || process.env.NEXT_PUBLIC_APP_URL || 'https://abaci.one'
}
 
export function ringUrlFor(connectionId: string): string {
  return `${ringOrigin().replace(/\/+$/, '')}/api/abacus/print/ring/${connectionId}`
}
 
/**
 * Mint a fresh ring secret, register the webhook upstream, and persist the
 * sealed secret + registration timestamp on the connection row.
 *
 * Throws {@link PrintServiceError} on upstream failure (notably
 * `409 not_pairable` for env-token services) — the caller decides whether
 * that's fatal (explicit re-register) or a degraded state (initial pairing).
 * Nothing is persisted unless the upstream PUT succeeded.
 */
export async function registerWebhook(connection: PrintServiceConnection): Promise<void> {
  const secret = randomBytes(32).toString('base64url')
 
  const res = await printServiceFetch(
    { origin: connection.origin, tokenSealed: connection.tokenSealed },
    '/webhook',
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: ringUrlFor(connection.id), secret }),
    }
  )
  await ensureOk(res)
 
  await db
    .update(schema.printServiceConnections)
    .set({
      ringSecretSealed: seal(secret),
      webhookRegisteredAt: new Date(),
      updatedAt: new Date(),
    })
    .where(eq(schema.printServiceConnections.id, connection.id))
}