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,380 @@
-- ============================================================================
-- Payple: a refund of an older order must not revoke the current period
--
-- apply_payment_provider_event checked a non-entitled (revoking) event only
-- against subscriptions.provider and provider_resource_id. For Payple that is
-- the billing-key payer id, shared by every order charged on the key, and
-- p_provider_order_id was ignored for revocations. A confirmed cancellation
-- webhook for last month's order (a console refund of a duplicate or courtesy
-- charge) therefore reset the whole subscription to free, dropping the period
-- paid by the current order and stopping renewals.
--
-- The payple-webhook edge function now ignores such cancellations itself
-- (webhook-policy.ts, reason 'canceled_order_not_current'). This migration
-- closes the remaining window where a renewal changes payple_pay_oid between
-- the edge function's read and this function's per-user advisory lock.
--
-- Only the new order-scope guard is added; the body is otherwise identical to
-- 20260821000003_payment_provider_serialization.sql. CREATE OR REPLACE keeps
-- the existing owner and grants (service_role only).
-- ============================================================================
BEGIN;
-- Apply one authoritative provider event. Provider ownership is strict: an
-- event can never overwrite another provider. A cancellation can affect only
-- the exact provider resource currently owning the entitlement.
CREATE OR REPLACE FUNCTION public.apply_payment_provider_event(
p_user_id uuid,
p_provider text,
p_event_id text,
p_event_created_at timestamptz,
p_event_type text,
p_payload_digest text,
p_provider_resource_id text,
p_tier text,
p_status text,
p_entitled boolean,
p_current_period_start timestamptz DEFAULT NULL,
p_current_period_end timestamptz DEFAULT NULL,
p_cancel_at timestamptz DEFAULT NULL,
p_auto_renewing boolean DEFAULT NULL,
p_provider_customer_id text DEFAULT NULL,
p_provider_order_id text DEFAULT NULL,
p_store_product_id text DEFAULT NULL,
p_store_purchase_id uuid DEFAULT NULL,
p_operation_id uuid DEFAULT NULL
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_event public.payment_provider_events%ROWTYPE;
v_cursor public.payment_provider_cursors%ROWTYPE;
v_subscription public.subscriptions%ROWTYPE;
v_result jsonb;
BEGIN
IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
RAISE EXCEPTION 'unknown_user';
END IF;
IF p_provider NOT IN ('stripe', 'payple', 'google_play', 'app_store', 'admin') THEN
RAISE EXCEPTION 'invalid_provider';
END IF;
IF p_event_id IS NULL OR length(trim(p_event_id)) NOT BETWEEN 3 AND 255 THEN
RAISE EXCEPTION 'invalid_event_id';
END IF;
IF p_event_created_at IS NULL OR p_event_created_at > now() + interval '10 minutes' THEN
RAISE EXCEPTION 'invalid_event_created_at';
END IF;
IF p_event_type IS NULL OR length(trim(p_event_type)) NOT BETWEEN 1 AND 100 THEN
RAISE EXCEPTION 'invalid_event_type';
END IF;
IF p_payload_digest IS NULL OR p_payload_digest !~ '^[0-9a-f]{64}$' THEN
RAISE EXCEPTION 'invalid_payload_digest';
END IF;
IF p_provider_resource_id IS NULL
OR length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN
RAISE EXCEPTION 'invalid_provider_resource_id';
END IF;
IF p_tier NOT IN ('free', 'pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_tier';
END IF;
IF p_entitled AND p_tier = 'free' THEN
RAISE EXCEPTION 'entitled_tier_must_be_paid';
END IF;
IF p_status IS NULL OR p_status NOT IN (
'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete',
'incomplete_expired', 'paused', 'on_hold', 'expired', 'refunded', 'pending'
) THEN
RAISE EXCEPTION 'invalid_status';
END IF;
IF p_current_period_start IS NOT NULL
AND p_current_period_end IS NOT NULL
AND p_current_period_end < p_current_period_start THEN
RAISE EXCEPTION 'invalid_subscription_period';
END IF;
IF p_provider_customer_id IS NOT NULL AND length(p_provider_customer_id) > 255 THEN
RAISE EXCEPTION 'invalid_provider_customer_id';
END IF;
IF p_provider_order_id IS NOT NULL AND length(p_provider_order_id) > 255 THEN
RAISE EXCEPTION 'invalid_provider_order_id';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
INSERT INTO public.payment_provider_events (
provider,
event_id,
user_id,
provider_resource_id,
event_type,
event_created_at,
payload_digest
) VALUES (
p_provider,
trim(p_event_id),
p_user_id,
trim(p_provider_resource_id),
trim(p_event_type),
p_event_created_at,
p_payload_digest
)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING * INTO v_event;
IF v_event.id IS NULL THEN
SELECT *
INTO v_event
FROM public.payment_provider_events
WHERE provider = p_provider
AND event_id = trim(p_event_id)
FOR UPDATE;
IF v_event.user_id <> p_user_id
OR v_event.provider_resource_id <> trim(p_provider_resource_id)
OR v_event.event_type <> trim(p_event_type)
OR v_event.payload_digest <> p_payload_digest THEN
RAISE EXCEPTION 'provider_event_payload_mismatch';
END IF;
RETURN coalesce(
v_event.result,
jsonb_build_object(
'applied', false,
'duplicate', true,
'reason', 'event_processing_in_progress'
)
) || jsonb_build_object('duplicate', true);
END IF;
SELECT *
INTO v_cursor
FROM public.payment_provider_cursors
WHERE user_id = p_user_id
AND provider = p_provider
FOR UPDATE;
IF v_cursor.user_id IS NOT NULL AND (
v_cursor.last_event_created_at > p_event_created_at
OR (
v_cursor.last_event_created_at = p_event_created_at
AND v_cursor.last_event_id >= trim(p_event_id)
)
) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'stale_provider_event'
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
-- A Payple revocation is order-scoped. Every order charged on one billing
-- key shares provider_resource_id (the payer id), so the resource-ownership
-- check below cannot tell last month's order from the current one. Only a
-- cancellation of the order that funds the current period
-- (subscriptions.payple_pay_oid) may revoke it. This runs before the cursor
-- advances so an ignored refund of an older order cannot make a later
-- legitimate event look stale. Callers that revoke without naming an order
-- (payple-renew scheduled expiry, payple-manage) are unaffected.
IF NOT p_entitled
AND p_provider = 'payple'
AND nullif(trim(p_provider_order_id), '') IS NOT NULL
AND EXISTS (
SELECT 1
FROM public.subscriptions
WHERE user_id = p_user_id
AND provider = 'payple'
AND payple_pay_oid IS DISTINCT FROM trim(p_provider_order_id)
) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'canceled_order_not_current'
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
INSERT INTO public.payment_provider_cursors (
user_id, provider, last_event_created_at, last_event_id
) VALUES (
p_user_id, p_provider, p_event_created_at, trim(p_event_id)
)
ON CONFLICT (user_id, provider) DO UPDATE
SET last_event_created_at = EXCLUDED.last_event_created_at,
last_event_id = EXCLUDED.last_event_id,
updated_at = now();
SELECT *
INTO v_subscription
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
IF v_subscription.id IS NULL THEN
INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider)
VALUES (p_user_id, 'free', 'active', 'none', 'none')
RETURNING * INTO v_subscription;
END IF;
IF p_operation_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.payment_provider_operations
WHERE id = p_operation_id
AND user_id = p_user_id
AND provider = p_provider
) THEN
RAISE EXCEPTION 'invalid_payment_operation';
END IF;
IF p_entitled AND EXISTS (
SELECT 1
FROM public.payment_provider_operations
WHERE user_id = p_user_id
AND provider <> p_provider
AND state IN ('reserved', 'external_created', 'charged')
AND expires_at > now()
) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'other_provider_operation_in_progress'
);
UPDATE public.payment_provider_events
SET disposition = 'rejected', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF p_entitled AND v_subscription.provider NOT IN ('none', p_provider) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'active_subscription_other_provider',
'owner_provider', v_subscription.provider
);
UPDATE public.payment_provider_events
SET disposition = 'rejected', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF NOT p_entitled AND v_subscription.provider <> p_provider THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'provider_not_owner',
'owner_provider', v_subscription.provider
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF NOT p_entitled
AND v_subscription.provider_resource_id IS DISTINCT FROM trim(p_provider_resource_id) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'provider_resource_not_owner'
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF p_entitled THEN
UPDATE public.subscriptions
SET tier = p_tier,
status = p_status,
current_period_start = p_current_period_start,
current_period_end = p_current_period_end,
cancel_at = p_cancel_at,
provider = p_provider,
provider_resource_id = trim(p_provider_resource_id),
provider_event_id = trim(p_event_id),
provider_event_created_at = p_event_created_at,
auto_renewing = p_auto_renewing,
stripe_customer_id = CASE
WHEN p_provider = 'stripe' THEN coalesce(nullif(trim(p_provider_customer_id), ''), stripe_customer_id)
ELSE stripe_customer_id
END,
stripe_subscription_id = CASE
WHEN p_provider = 'stripe' THEN trim(p_provider_resource_id)
ELSE stripe_subscription_id
END,
payple_payer_id = CASE
WHEN p_provider = 'payple' AND p_provider_customer_id = '' THEN NULL
WHEN p_provider = 'payple' AND p_provider_customer_id IS NOT NULL
THEN trim(p_provider_customer_id)
ELSE payple_payer_id
END,
payple_pay_oid = CASE
WHEN p_provider = 'payple' THEN coalesce(nullif(trim(p_provider_order_id), ''), payple_pay_oid)
ELSE payple_pay_oid
END,
store_product_id = CASE
WHEN p_provider IN ('google_play', 'app_store') THEN p_store_product_id
ELSE NULL
END,
store_purchase_id = CASE
WHEN p_provider IN ('google_play', 'app_store') THEN p_store_purchase_id
ELSE NULL
END,
renewal_failures = CASE WHEN p_provider = 'payple' THEN 0 ELSE renewal_failures END,
updated_at = now()
WHERE user_id = p_user_id;
ELSE
UPDATE public.subscriptions
SET tier = 'free',
status = p_status,
current_period_start = coalesce(p_current_period_start, current_period_start),
current_period_end = coalesce(p_current_period_end, current_period_end),
cancel_at = coalesce(p_cancel_at, p_current_period_end, now()),
provider = 'none',
provider_resource_id = NULL,
provider_event_id = trim(p_event_id),
provider_event_created_at = p_event_created_at,
auto_renewing = false,
store_product_id = NULL,
store_purchase_id = NULL,
updated_at = now()
WHERE user_id = p_user_id;
END IF;
IF p_operation_id IS NOT NULL THEN
UPDATE public.payment_provider_operations
SET state = 'applied',
external_reference = coalesce(
nullif(trim(p_provider_order_id), ''),
nullif(trim(p_provider_resource_id), ''),
external_reference
),
error_code = NULL,
updated_at = now()
WHERE id = p_operation_id;
END IF;
v_result := jsonb_build_object(
'applied', true,
'duplicate', false,
'provider', CASE WHEN p_entitled THEN p_provider ELSE 'none' END,
'tier', CASE WHEN p_entitled THEN p_tier ELSE 'free' END,
'status', p_status,
'entitled', p_entitled
);
UPDATE public.payment_provider_events
SET disposition = 'applied', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END;
$$;
COMMIT;