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.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -0,0 +1,242 @@
// Pure decision rules for the Payple webhook edge function. No IO lives here:
// index.ts owns the Supabase/Payple calls and asks these functions what a
// reconciled webhook means for the user's entitlement, so the rules (which
// order may revoke a subscription, which amount is acceptable, ...) are
// unit-testable without a network or a database.
import {
calcSubscriptionPeriod,
parsePaypleTimestamp,
payplePaymentEventId,
PaypleVerificationError,
type PayplePaymentLookupResult,
} from '../_shared/payple.ts'
export interface PaypleWebhookPayload {
PCD_PAY_RST?: unknown
PCD_PAY_CODE?: unknown
PCD_PAY_MSG?: unknown
PCD_PAY_TYPE?: unknown
PCD_PAY_OID?: unknown
PCD_PAY_TOTAL?: unknown
PCD_PAYER_ID?: unknown
PCD_PAYER_NO?: unknown
PCD_PAY_TIME?: unknown
PCD_PAY_WORK?: unknown
PCD_PAYCANCEL_FLAG?: unknown
PCD_PAY_CARDTRADENUM?: unknown
}
export type WebhookKind = 'payment' | 'cancellation' | 'billing_key_revoked' | 'unsupported'
/** The webhook kinds that are reconciled against Payple and may change entitlement. */
export type ReconciledWebhookKind = 'payment' | 'cancellation'
export type PaypleTier = 'pro' | 'pro_plus'
export interface CorrelatedPayment {
user_id: string
tier: PaypleTier
operation_id: string | null
payer_id: string
/**
* subscriptions.payple_pay_oid at correlation time: the order that funds the
* current paid period. null when the user has no Payple order on record.
*/
current_order_id: string | null
}
export function stringValue(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
export function normalizeTier(value: unknown): PaypleTier | null {
if (value === 'pro') return 'pro'
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
return null
}
export function classifyPaypleWebhook(payload: PaypleWebhookPayload): WebhookKind {
if (payload.PCD_PAY_WORK === 'PUSERDEL') return 'billing_key_revoked'
if (
payload.PCD_PAYCANCEL_FLAG === 'Y'
|| (typeof payload.PCD_PAY_CODE === 'string' && payload.PCD_PAY_CODE.startsWith('PAYC'))
) {
return 'cancellation'
}
if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) return 'payment'
return 'unsupported'
}
export function validateReconciledPaypleEvent(
payload: PaypleWebhookPayload,
lookup: PayplePaymentLookupResult,
): void {
const orderId = stringValue(payload.PCD_PAY_OID)
const payType = stringValue(payload.PCD_PAY_TYPE)
const payerId = stringValue(payload.PCD_PAYER_ID)
if (!orderId || lookup.PCD_PAY_OID !== orderId || lookup.PCD_PAY_RST !== 'success') {
throw new PaypleVerificationError('payple_webhook_order_mismatch')
}
if (payType && lookup.PCD_PAY_TYPE !== payType) {
throw new PaypleVerificationError('payple_webhook_type_mismatch')
}
if (payerId && lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== payerId) {
throw new PaypleVerificationError('payple_webhook_payer_mismatch')
}
const payloadTotal = stringValue(payload.PCD_PAY_TOTAL)
if (payloadTotal && lookup.PCD_PAY_TOTAL && Number(payloadTotal) !== Number(lookup.PCD_PAY_TOTAL)) {
throw new PaypleVerificationError('payple_webhook_amount_mismatch')
}
}
/** Payple's PayChkAct state is the only authority that an order was canceled. */
export function isAuthoritativeCancellation(lookup: PayplePaymentLookupResult): boolean {
return lookup.PCD_PAY_STATE === '승인취소완료' || lookup.PCD_PAY_STATE === 'canceled'
}
/**
* apply_payment_provider_event arguments, minus p_payload_digest (the digest
* is an async hash computed by the IO layer from the event it forwards).
*/
export type PaypleProviderEventArgs = {
p_user_id: string
p_provider: 'payple'
p_event_id: string
p_event_created_at: string
p_event_type: 'payment.completed' | 'webhook.payment_canceled'
p_provider_resource_id: string
p_tier: 'free' | PaypleTier
p_status: 'active' | 'canceled'
p_entitled: boolean
p_current_period_start: string | null
p_current_period_end: string
p_cancel_at: string | null
p_auto_renewing: boolean
p_provider_customer_id: string
p_provider_order_id: string
p_store_product_id: null
p_store_purchase_id: null
p_operation_id: string | null
}
export interface WebhookTransitionInput {
kind: ReconciledWebhookKind
orderId: string
lookup: PayplePaymentLookupResult
correlated: CorrelatedPayment
/** Payple price of the correlated tier; undefined when the tier has no price. */
expectedAmount: number | undefined
now: Date
}
export type IgnoredWebhookReason = 'canceled_order_not_current'
export type WebhookTransition =
| { kind: 'reject'; status: 401 | 409 | 422; error: string }
| {
kind: 'ignore'
reason: IgnoredWebhookReason
eventId: string
eventCreatedAt: string
eventType: 'webhook.payment_canceled'
providerResourceId: string
}
| { kind: 'apply'; amount: number; args: PaypleProviderEventArgs }
function cancellationEventId(orderId: string, lookup: PayplePaymentLookupResult): string {
return `cancel:${orderId}:${lookup.PCD_PAY_STATE ?? 'confirmed'}`
}
/**
* Decides what a reconciled Payple webhook does to the subscription.
*
* - The Payple lookup's payer must be the correlated payer (401).
* - A payment must match the correlated tier's price exactly (422).
* - A cancellation must be confirmed by Payple's own lookup state (409).
* - A confirmed cancellation revokes entitlement only when the canceled order
* is the one that funds the current period (subscriptions.payple_pay_oid).
* A refund of an older order (last month's checkout, a duplicate charge) —
* or of any order while no current order is on record — is ignored, because
* every order on the same billing key shares the payer id and would
* otherwise pass the database's resource-ownership check.
*/
export function decideWebhookTransition(input: WebhookTransitionInput): WebhookTransition {
const { kind, orderId, lookup, correlated, now } = input
if (lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== correlated.payer_id) {
return { kind: 'reject', status: 401, error: 'payment_owner_mismatch' }
}
const amount = Number(lookup.PCD_PAY_TOTAL)
if (kind === 'payment' && (!Number.isFinite(amount) || amount !== input.expectedAmount)) {
return { kind: 'reject', status: 422, error: 'payment_amount_mismatch' }
}
const paymentTime = lookup.PCD_PAY_TIME ? parsePaypleTimestamp(lookup.PCD_PAY_TIME) : now
if (kind === 'cancellation') {
if (!isAuthoritativeCancellation(lookup)) {
return { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' }
}
const eventId = cancellationEventId(orderId, lookup)
const eventCreatedAt = now.toISOString()
if (correlated.current_order_id !== orderId) {
return {
kind: 'ignore',
reason: 'canceled_order_not_current',
eventId,
eventCreatedAt,
eventType: 'webhook.payment_canceled',
providerResourceId: correlated.payer_id,
}
}
return {
kind: 'apply',
amount,
args: {
...baseArgs(correlated, orderId),
p_event_id: eventId,
p_event_created_at: eventCreatedAt,
p_event_type: 'webhook.payment_canceled',
p_tier: 'free',
p_status: 'canceled',
p_entitled: false,
p_current_period_start: null,
p_current_period_end: eventCreatedAt,
p_cancel_at: eventCreatedAt,
p_auto_renewing: false,
},
}
}
const { start, end } = calcSubscriptionPeriod(paymentTime)
return {
kind: 'apply',
amount,
args: {
...baseArgs(correlated, orderId),
p_event_id: payplePaymentEventId(orderId),
p_event_created_at: paymentTime.toISOString(),
p_event_type: 'payment.completed',
p_tier: correlated.tier,
p_status: 'active',
p_entitled: true,
p_current_period_start: start,
p_current_period_end: end,
p_cancel_at: null,
p_auto_renewing: true,
},
}
}
function baseArgs(correlated: CorrelatedPayment, orderId: string) {
return {
p_user_id: correlated.user_id,
p_provider: 'payple' as const,
p_provider_resource_id: correlated.payer_id,
p_provider_customer_id: correlated.payer_id,
p_provider_order_id: orderId,
p_store_product_id: null,
p_store_purchase_id: null,
p_operation_id: correlated.operation_id,
}
}