Closes a bypass surfaced by the 2026-04 audit probe (axis-1 Q2): any authenticated user could POST /api/v1/subscriptions/subscribe on a paid plan and receive 201 active without the payment provider ever being invoked. The resulting row satisfied `checkEligibility()` in the distribution service via `can_sell_on_marketplace=true` on the Creator plan — effectively free access to /api/v1/distribution/submit, which dispatches to external partners. Fix is centralised in `GetUserSubscription` so there is no code path that can grant subscription-gated access without routing through the payment check. Effective-payment = free plan OR unexpired trial OR invoice with non-empty hyperswitch_payment_id. Migration 980 sweeps pre-existing fantôme rows into `expired`, preserving the tuple in a dated audit table for support outreach. Subscribe and subscribeToFreePlan treat the new ErrSubscriptionNoPayment as equivalent to ErrNoActiveSubscription so re-subscription works cleanly post-cleanup. GET /me/subscription surfaces needs_payment=true with a support-contact message rather than a misleading "you're on free" or an opaque 500. TODO(v1.0.7-item-G) annotation marks where the `if s.paymentProvider != nil` short-circuit needs to become a mandatory pending_payment state. Probe script `scripts/probes/subscription-unpaid-activation.sh` kept as a versioned regression test — dry-run by default, --destructive logs in and attempts the exploit against a live backend with automatic cleanup. 8-case unit test matrix covers the full hasEffectivePayment predicate. Smoke validated end-to-end against local v1.0.6.2: POST /subscribe returns 201 (by design — item G closes the creation path), but GET /me/subscription returns subscription=null + needs_payment=true, distribution eligibility returns false. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
60 lines
2.3 KiB
SQL
60 lines
2.3 KiB
SQL
-- v1.0.6.2: Void subscription rows that sit in active/trialing state without
|
|
-- any effective payment linkage. Introduced to compensate for a bypass where
|
|
-- POST /subscribe could create 'active' rows on paid plans without invoking
|
|
-- the payment provider (e.g., when HYPERSWITCH_ENABLED=false or provider
|
|
-- unset). The runtime filter in GetUserSubscription (service.go) closes the
|
|
-- feature bypass going forward; this migration cleans up the rows already
|
|
-- written to the database pre-v1.0.6.2.
|
|
--
|
|
-- Fantôme selection criteria:
|
|
-- 1. status IN ('active', 'trialing')
|
|
-- 2. plan is paid (subscription_plans.price_monthly_cents > 0)
|
|
-- 3. no invoice attached carries a hyperswitch_payment_id (= PSP never reached)
|
|
-- 4. not a currently-valid trial (trial_end > NOW())
|
|
--
|
|
-- Audit table is dated so a future rerun doesn't collide. Rows here can be
|
|
-- used to notify affected users (if any were honest-path).
|
|
|
|
CREATE TABLE IF NOT EXISTS voided_subscriptions_20260417 (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
subscription_id UUID NOT NULL,
|
|
user_id UUID NOT NULL,
|
|
plan_id UUID NOT NULL,
|
|
previous_status VARCHAR(30) NOT NULL,
|
|
voided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_voided_subscriptions_20260417_user
|
|
ON voided_subscriptions_20260417(user_id);
|
|
|
|
INSERT INTO voided_subscriptions_20260417 (subscription_id, user_id, plan_id, previous_status)
|
|
SELECT us.id, us.user_id, us.plan_id, us.status
|
|
FROM user_subscriptions us
|
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
|
WHERE us.status IN ('active', 'trialing')
|
|
AND sp.price_monthly_cents > 0
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM subscription_invoices si
|
|
WHERE si.subscription_id = us.id
|
|
AND si.hyperswitch_payment_id IS NOT NULL
|
|
AND si.hyperswitch_payment_id <> ''
|
|
)
|
|
AND NOT (
|
|
us.status = 'trialing'
|
|
AND us.trial_end IS NOT NULL
|
|
AND us.trial_end > NOW()
|
|
);
|
|
|
|
UPDATE user_subscriptions
|
|
SET status = 'expired',
|
|
canceled_at = COALESCE(canceled_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE id IN (SELECT subscription_id FROM voided_subscriptions_20260417);
|
|
|
|
DO $$
|
|
DECLARE v_count INTEGER;
|
|
BEGIN
|
|
SELECT COUNT(*) INTO v_count FROM voided_subscriptions_20260417;
|
|
RAISE NOTICE 'v1.0.6.2: voided % pre-existing unpaid subscription row(s)', v_count;
|
|
END $$;
|