d3ro-voice/server/supabase/functions/payple-webhook/index.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

279 lines
9.7 KiB
TypeScript

import { createServiceRoleClient } from '../_shared/quota.ts'
import {
getPaypleConfig,
paypleAuth,
paypleLookupPayment,
payplePaymentEventDigest,
PaypleConfigurationError,
PaypleVerificationError,
resolvePaypleOrderDate,
sha256Text,
TIER_PRICE,
type PayplePaymentLookupResult,
} from '../_shared/payple.ts'
import {
classifyPaypleWebhook,
type CorrelatedPayment,
decideWebhookTransition,
normalizeTier,
type PaypleProviderEventArgs,
type PaypleWebhookPayload,
stringValue,
validateReconciledPaypleEvent,
} from './webhook-policy.ts'
// Re-exported so existing importers of the handler module keep working; the
// rules themselves live in webhook-policy.ts.
export { classifyPaypleWebhook, validateReconciledPaypleEvent }
export interface ProviderApplyResult {
applied?: boolean
duplicate?: boolean
reason?: string
}
export interface IgnoredProviderEvent {
userId: string
eventId: string
eventCreatedAt: string
eventType: string
payloadDigest: string
providerResourceId: string
}
/**
* IO port of the webhook handler. The handler orchestrates; adapters talk to
* Supabase and Payple. Tests substitute an in-memory implementation.
*/
export interface PaypleWebhookPorts {
correlatePayment(orderId: string, payerId: string | null): Promise<CorrelatedPayment | null>
lookupPayment(params: {
orderId: string
payType: 'card' | 'transfer'
payTime: string | null
}): Promise<PayplePaymentLookupResult>
applyProviderEvent(
args: PaypleProviderEventArgs & { p_payload_digest: string },
): Promise<ProviderApplyResult | null>
recordIgnoredEvent(event: IgnoredProviderEvent): Promise<void>
now(): Date
}
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
type ServiceClient = ReturnType<typeof createServiceRoleClient>
async function readCurrentPaypleOrder(
serviceClient: ServiceClient,
userId: string,
): Promise<string | null> {
const { data, error } = await serviceClient
.from('subscriptions')
.select('payple_pay_oid')
.eq('user_id', userId)
.maybeSingle()
if (error) throw new Error('subscription_lookup_failed')
return stringValue(data?.payple_pay_oid)
}
async function correlatePayment(
serviceClient: ServiceClient,
orderId: string,
payerId: string | null,
): Promise<CorrelatedPayment | null> {
const { data: operation, error: operationError } = await serviceClient
.from('payment_provider_operations')
.select('id, user_id, requested_tier, provider_resource_id')
.eq('provider', 'payple')
.eq('provider_order_id', orderId)
.maybeSingle()
if (operationError) throw new Error('payment_operation_lookup_failed')
const operationTier = normalizeTier(operation?.requested_tier)
const operationPayerId = stringValue(operation?.provider_resource_id) ?? payerId
if (operation?.user_id && operationTier && operationPayerId) {
const userId = operation.user_id as string
return {
user_id: userId,
tier: operationTier,
operation_id: operation.id as string,
payer_id: operationPayerId,
current_order_id: await readCurrentPaypleOrder(serviceClient, userId),
}
}
const query = serviceClient
.from('subscriptions')
.select('user_id, tier, payple_payer_id')
.eq('payple_pay_oid', orderId)
const { data: subscription, error: subscriptionError } = await query.maybeSingle()
if (subscriptionError) throw new Error('subscription_lookup_failed')
const subscriptionTier = normalizeTier(subscription?.tier)
const subscriptionPayerId = stringValue(subscription?.payple_payer_id) ?? payerId
if (!subscription?.user_id || !subscriptionTier || !subscriptionPayerId) return null
return {
user_id: subscription.user_id as string,
tier: subscriptionTier,
operation_id: null,
payer_id: subscriptionPayerId,
// Matched through payple_pay_oid, so this order is the current one.
current_order_id: orderId,
}
}
export function createSupabasePaypleWebhookPorts(): PaypleWebhookPorts {
const serviceClient = createServiceRoleClient()
return {
correlatePayment: (orderId, payerId) => correlatePayment(serviceClient, orderId, payerId),
async lookupPayment({ orderId, payType, payTime }) {
const config = getPaypleConfig()
const payDate = resolvePaypleOrderDate(orderId, payTime ?? undefined)
const auth = await paypleAuth(config, { payCheckFlag: true })
return await paypleLookupPayment(config, auth, { orderId, payType, payDate })
},
async applyProviderEvent(args) {
const { data, error } = await serviceClient.rpc('apply_payment_provider_event', args)
if (error) throw new Error('payple_entitlement_apply_failed')
return data as ProviderApplyResult | null
},
async recordIgnoredEvent(event) {
// Replay-protected audit record that neither touches entitlement nor
// advances the provider ordering cursor.
const { error } = await serviceClient.rpc('record_payment_provider_observation', {
p_user_id: event.userId,
p_provider: 'payple',
p_event_id: event.eventId,
p_event_created_at: event.eventCreatedAt,
p_event_type: event.eventType,
p_payload_digest: event.payloadDigest,
p_provider_resource_id: event.providerResourceId,
})
if (error) throw new Error('payple_event_record_failed')
},
now: () => new Date(),
}
}
export function createPaypleWebhookHandler(
makePorts: () => PaypleWebhookPorts,
): (req: Request) => Promise<Response> {
return async (req) => {
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
if (!(req.headers.get('content-type') ?? '').toLowerCase().includes('application/json')) {
return jsonResponse({ error: 'unsupported_content_type' }, 415)
}
const rawPayload = await req.text()
if (!rawPayload || rawPayload.length > 64 * 1024) {
return jsonResponse({ error: 'invalid_payload' }, 400)
}
let payload: PaypleWebhookPayload
try {
payload = JSON.parse(rawPayload) as PaypleWebhookPayload
} catch {
return jsonResponse({ error: 'invalid_json' }, 400)
}
const kind = classifyPaypleWebhook(payload)
if (kind === 'unsupported') return jsonResponse({ received: true, ignored: 'unsupported_event' })
if (kind === 'billing_key_revoked') {
// Payple's documented PUSERDEL webhook has no signature and no transaction
// identifier that can be reconciled through PayChkAct. It is therefore not
// authorized to mutate entitlement; payple-manage applies the verified
// result of the server-originated PUSERDEL API call instead.
return jsonResponse({ received: true, ignored: 'non_authoritative_billing_key_event' })
}
const orderId = stringValue(payload.PCD_PAY_OID)
const payType = stringValue(payload.PCD_PAY_TYPE)
if (
!orderId
|| !/^[A-Za-z0-9._-]{8,64}$/.test(orderId)
|| (payType !== 'card' && payType !== 'transfer')
) {
return jsonResponse({ error: 'invalid_payload' }, 400)
}
try {
const ports = makePorts()
// Reject unknown order IDs before consuming Payple's authenticated lookup
// rate limit. Every accepted order must have originated in our operation
// ledger or be the current order on an existing Payple subscription.
const correlated = await ports.correlatePayment(orderId, stringValue(payload.PCD_PAYER_ID))
if (!correlated) return jsonResponse({ error: 'payment_not_registered' }, 422)
const lookup = await ports.lookupPayment({
orderId,
payType,
payTime: stringValue(payload.PCD_PAY_TIME),
})
validateReconciledPaypleEvent(payload, lookup)
const transition = decideWebhookTransition({
kind,
orderId,
lookup,
correlated,
expectedAmount: TIER_PRICE[correlated.tier],
now: ports.now(),
})
if (transition.kind === 'reject') {
return jsonResponse({ error: transition.error }, transition.status)
}
if (transition.kind === 'ignore') {
await ports.recordIgnoredEvent({
userId: correlated.user_id,
eventId: transition.eventId,
eventCreatedAt: transition.eventCreatedAt,
eventType: transition.eventType,
payloadDigest: await sha256Text(JSON.stringify({ payload, lookup })),
providerResourceId: transition.providerResourceId,
})
return jsonResponse({
received: true,
applied: false,
duplicate: false,
ignored: transition.reason,
reason: transition.reason,
})
}
const payloadDigest = kind === 'cancellation'
? await sha256Text(JSON.stringify({ payload, lookup }))
: await payplePaymentEventDigest({
orderId,
payerId: correlated.payer_id,
payType: lookup.PCD_PAY_TYPE,
amount: transition.amount,
})
const result = await ports.applyProviderEvent({
...transition.args,
p_payload_digest: payloadDigest,
})
return jsonResponse({
received: true,
applied: result?.applied ?? false,
duplicate: result?.duplicate ?? false,
reason: result?.reason,
})
} catch (error) {
if (error instanceof PaypleConfigurationError) {
return jsonResponse({ error: error.code }, 503)
}
if (error instanceof PaypleVerificationError) {
return jsonResponse({ error: error.code }, 401)
}
return jsonResponse({ error: 'payple_webhook_processing_failed' }, 500)
}
}
}
export const paypleWebhookHandler = createPaypleWebhookHandler(createSupabasePaypleWebhookPorts)
if (import.meta.main) Deno.serve(paypleWebhookHandler)