Skip to content
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
- Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`).
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).

Expand Down
53 changes: 53 additions & 0 deletions .claude/skills/verify-debatelab/recipes/billing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,56 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets
await admin.from("promo_codes").delete().eq("id", promo.id);
}
});

test("bill-discount: a discount code is claimed once, stays in the dialog, and goes to Checkout", async ({ signedIn: page, user, evidence }) => {
const userId = await user.userId();
// An ended trial, so Settings and the dialog both offer "Upgrade to Pro".
await admin.from("user_settings")
.update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null })
.eq("user_id", userId);
const code = `VERIFYD${randomUUID().slice(0, 8).toUpperCase()}`;
const { data: promo, error } = await admin.from("promo_codes")
.insert({ code, type: "discount", config: { stripe_promotion_code: "promo_VerifyRecipe" }, max_redemptions: 1 })
.select("id").single();
if (error) throw error;
const claims = async () => {
const [{ data: row }, { count }] = await Promise.all([
admin.from("promo_codes").select("redemptions_count").eq("id", promo.id).single(),
admin.from("user_promos").select("id", { count: "exact", head: true }).eq("promo_code_id", promo.id).eq("user_id", userId),
]);
return { redemptions: row?.redemptions_count, held: count };
};
// Stripe Checkout is out of reach locally; record what the dialog sends instead.
const checkoutBodies: unknown[] = [];
await page.route("**/functions/v1/create-checkout", async (route) => {
checkoutBodies.push(route.request().postDataJSON());
await route.fulfill({ status: 400, json: { error: "Invalid or expired promo code", code: "promo_invalid" } });
});
try {
await page.goto("/app/settings");
await page.getByRole("button", { name: "Upgrade to Pro" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Have a promo code?" }).click();
await dialog.getByPlaceholder("Enter promo code").fill(code);
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.getByText("Discount code saved! It will be applied when you upgrade.").first()).toBeVisible();
await expect(dialog.getByPlaceholder("Enter promo code")).toHaveValue(code);
await evidence.shot("01-discount-saved");
expect(await claims()).toEqual({ redemptions: 1, held: 1 });

// Applying again is not a second claim.
const again = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo"));
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
expect(await (await again).json()).toMatchObject({ success: true, code: "promo_discount_noted" });
expect(await claims()).toEqual({ redemptions: 1, held: 1 });

await dialog.getByRole("button", { name: "Upgrade to Pro" }).click();
await expect.poll(() => checkoutBodies.length).toBe(1);
expect(checkoutBodies[0]).toMatchObject({ promoCode: code });
await evidence.shot("02-checkout-sent-code");
} finally {
await page.unroute("**/functions/v1/create-checkout");
await admin.from("user_promos").delete().eq("promo_code_id", promo.id);
await admin.from("promo_codes").delete().eq("id", promo.id);
}
});
4 changes: 4 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ Stop when the project target is ambiguous, rollback is unproven, or the operatio

Destructive statements such as `DROP` or `TRUNCATE`, secret rotation, project deletion, and broad unrelated function deployment always need separate explicit approval.

### Keepalive monitor

`keepalive_events` and `keepalive_heartbeat` belong to the external Supabase keepalive monitor (`Programcode/supabase-keepalive-monitor`), not the app. Since `20261024120000_keepalive_server_only.sql` only the service role can read or write them. The monitor's primary run, a systemd user timer on Adam's VPS, uses the service-role key. Any other copy of the monitor (the Pi's GitHub Actions backup) must also use the service-role key for the DebateLab projects; with the publishable key its REST and RPC steps get 401 or 403.

## Edge Function deployment boundaries

[`supabase/config.toml`](../supabase/config.toml) disables gateway JWT verification for functions that authenticate or validate requests in their own handlers. Deployment must preserve those handler checks.
Expand Down
2 changes: 1 addition & 1 deletion docs/standards/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard bound
- A suite that writes a real session into a browser restricts its base URL to loopback ([#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896484019)).
- Documentation names the secret, never the account's email or password ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4095363422)).
- One secret, one purpose. Do not derive signing keys from the service-role key; rotating it would break every outstanding token ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649683)).
- An Edge Function that fetches a user-supplied URL connects only to an address it has just checked against the IANA special-purpose ranges, never to the hostname: `fetch` resolves the name again, so a DNS answer that changes in between (rebinding) would reach a blocked range. `import-source` dials the checked address with [`pinned-fetch.ts`](../../supabase/functions/import-source/pinned-fetch.ts), on every redirect hop. Citation verification does not fetch at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closed finding [7] of the 2026-08-19 review.

## Recorded exceptions

Expand All @@ -25,7 +26,6 @@ These were raised in review, judged valid, and accepted on purpose. Changing the
- The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090664336)).
- Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4093482054)).
- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#opt-in-previews) describes the current setup. This one has no recorded decision yet.
- `import-source` checks every resolved A and AAAA address against the IANA special-purpose ranges before each fetch, but the Edge runtime cannot pin the connection to the checked address. A hostname whose DNS answer changes between the check and the connection can still reach a blocked range. Citation verification avoids the same gap by not fetching at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closing it needs an egress proxy outside the runtime. Raised as finding [7] of the 2026-08-19 security review; this needs Adam's decision.

## Telemetry and error reports

Expand Down
3 changes: 3 additions & 0 deletions src/components/features/subscription/PricingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod
description: result.message,
variant: result.success ? 'default' : 'destructive',
});
// A discount changes nothing until Checkout: the code stays in the
// field so the upgrade sends it, and Checkout applies the discount.
if (result.success && result.code === 'promo_discount_noted') return;
if (result.success) {
setPromoCode('');
setShowPromo(false);
Expand Down
1 change: 1 addition & 0 deletions src/lib/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const SERVER_ERROR_KEYS: Record<string, string> = {
usage_check_failed: 'serverErrors.usageCheckFailed',
access_check_failed: 'serverErrors.accessCheckFailed',
no_active_subscription: 'settings.noSubscriptionDesc',
portal_failed: 'serverErrors.portalFailed',
already_subscribed: 'serverErrors.alreadySubscribed',
promo_expired: 'serverErrors.promoExpired',
promo_exhausted: 'serverErrors.promoExhausted',
Expand Down
9 changes: 6 additions & 3 deletions src/lib/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readEdgeFunctionError } from '@/lib/supabase/edge-function-error';
import { getWebAppRedirectUrl, openExternalUrl } from '@/lib/native';
import i18n from '@/i18n';
import { serverErrorMessage } from '@/lib/server-errors';
import { promoResultCode } from '../../supabase/functions/_shared/promo-result';
import { type PromoResultCode, promoResultCode } from '../../supabase/functions/_shared/promo-result';

/** A billing call that failed, with its message in the interface language and the server's code. */
export class BillingError extends Error {
Expand Down Expand Up @@ -81,8 +81,10 @@ export async function redirectToPortal(): Promise<void> {
/**
* Redeem a promo code for the current user. The message is in the interface
* language when the answer is one the browser knows, else the server's text.
* A discount answers `promo_discount_noted`: it is claimed, and Checkout
* applies it when the same code is sent with the upgrade.
*/
export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string }> {
export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string; code?: PromoResultCode }> {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
if (!accessToken) throw new BillingError(i18n.t('serverErrors.signInRequired'), 'unauthenticated');
Expand All @@ -100,5 +102,6 @@ export async function redeemPromoCode(code: string): Promise<{ success: boolean;
return { success: false, message: serverErrorMessage(promoResultCode(body), fallback) };
}
const result = data as { success: boolean; message: string; code?: string };
return { success: result.success, message: serverErrorMessage(promoResultCode(result), result.message) };
const resultCode = promoResultCode(result);
return { success: result.success, message: serverErrorMessage(resultCode, result.message), code: resultCode };
}
4 changes: 3 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,8 @@
"days": "Days",
"dailyLimit": "Daily Limit",
"maxRedemptions": "Max Redemptions (blank = unlimited)",
"stripePromotionCode": "Stripe promotion code ID",
"stripePromotionCodeHint": "The promo_… ID from the Stripe dashboard. Checkout applies exactly this discount.",
"created": "Promo code \"{{code}}\" created",
"createFailed": "Failed to create code",
"createAction": "Create Code",
Expand Down Expand Up @@ -2677,7 +2679,7 @@
"promoExpired": "This promo code has expired",
"promoExhausted": "This promo code has reached its usage limit",
"promoApplied": "Promo code applied!",
"promoDiscountNoted": "Discount code noted! Apply it at checkout for a discount.",
"promoDiscountNoted": "Discount code saved! It will be applied when you upgrade.",
"promoInvalid": "Invalid or expired promo code",
"promoAlreadyRedeemed": "You've already redeemed this code",
"promoTooManyAttempts": "Too many promo code attempts. Try again in 15 minutes.",
Expand Down
4 changes: 3 additions & 1 deletion src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,8 @@
"days": "Días",
"dailyLimit": "Límite diario",
"maxRedemptions": "Canjes máximos (vacío = ilimitado)",
"stripePromotionCode": "ID del código promocional de Stripe",
"stripePromotionCodeHint": "El ID promo_… del panel de Stripe. El pago aplica exactamente este descuento.",
"created": "Código promocional \"{{code}}\" creado",
"createFailed": "No se pudo crear el código",
"createAction": "Crear código",
Expand Down Expand Up @@ -2677,7 +2679,7 @@
"promoExpired": "Este código promocional ha caducado",
"promoExhausted": "Este código promocional ha alcanzado su límite de usos",
"promoApplied": "¡Código promocional aplicado!",
"promoDiscountNoted": "¡Código de descuento guardado! Aplícalo al pagar para obtener el descuento.",
"promoDiscountNoted": "¡Código de descuento guardado! Se aplicará cuando mejores tu plan.",
"promoInvalid": "Código promocional no válido o caducado",
"promoAlreadyRedeemed": "Ya has canjeado este código",
"promoTooManyAttempts": "Demasiados intentos con códigos promocionales. Vuelve a intentarlo en 15 minutos.",
Expand Down
4 changes: 3 additions & 1 deletion src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,8 @@
"days": "Napok",
"dailyLimit": "Napi korlát",
"maxRedemptions": "Beváltások maximális száma (üres = korlátlan)",
"stripePromotionCode": "Stripe promóciós kód azonosító",
"stripePromotionCodeHint": "A promo_… azonosító a Stripe irányítópultjáról. A fizetés pontosan ezt a kedvezményt alkalmazza.",
"created": "A(z) \"{{code}}\" promóciós kód létrehozva",
"createFailed": "Nem sikerült létrehozni a kódot",
"createAction": "Kód létrehozása",
Expand Down Expand Up @@ -2677,7 +2679,7 @@
"promoExpired": "Ez a promóciós kód lejárt",
"promoExhausted": "Ez a promóciós kód elérte a felhasználási korlátját",
"promoApplied": "Promóciós kód beváltva!",
"promoDiscountNoted": "Kedvezménykód rögzítve! A kedvezményhez add meg fizetéskor.",
"promoDiscountNoted": "Kedvezménykód rögzítve! Előfizetéskor automatikusan érvényesül.",
"promoInvalid": "Érvénytelen vagy lejárt promóciós kód",
"promoAlreadyRedeemed": "Ezt a kódot már beváltottad",
"promoTooManyAttempts": "Túl sok próbálkozás promóciós kóddal. Próbáld újra 15 perc múlva.",
Expand Down
Loading
Loading