feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
# Billing and redemption
This domain owns RevenueCat webhook persistence and public promotional-code
redemption. Neither route uses the hosted Supabase data plane.
- `POST /webhooks/revenuecat` requires an exact
`Authorization: Bearer <REVENUECAT_WEBHOOK_SECRET>` header. A missing server
secret disables the webhook with `503`; it never becomes unauthenticated.
- `POST /public/v1/redemptions` accepts `{ email, code }` for the public web
redemption form. Validation, remaining-use checks, redemption recording, and
the premium grant run in one database transaction.
Both public endpoints have request-body limits (1 MiB for provider webhooks and
16 KiB for redemption). Webhook authorization is checked before JSON parsing.
Because the API cannot independently authenticate a proxy-supplied client IP,
redemption also uses a database-backed global ceiling of 60 attempts per minute
across all replicas. The production reverse proxy must additionally apply a
per-client-IP limit; forwarded-IP headers must not be trusted from the public
internet unless the proxy strips and rewrites them.
Webhook event insertion, subscription upsert, premium recomputation, and any
premium-pack disabling are atomic. The unique event ID makes provider retries
idempotent. A known event for an unknown user rolls back the event record so a
later retry can succeed after identity reconciliation. Unknown and `TEST` events
are recorded once and acknowledged without changing subscription state.
Wire a `PostgresBillingRepository`, construct `BillingService` with the
server-only `REVENUECAT_WEBHOOK_SECRET`, call `registerBillingRoutes`, and close
the repository during graceful shutdown. The web app should receive the API base
URL via `NEXT_PUBLIC_API_URL`; no server secret belongs in a `NEXT_PUBLIC_*`
variable.
Rate-limit the public redemption endpoint by client IP at the trusted reverse
proxy. The API limits request size and atomically locks codes, but deliberately
does not trust arbitrary forwarded-IP headers on its own.
+5
View File
@@ -0,0 +1,5 @@
export * from './repository.js';
export * from './routes.js';
export * from './service.js';
export * from './types.js';
+212
View File
@@ -0,0 +1,212 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
interface ProfileRecord extends QueryResultRow {
id: string;
couple_id: string | null;
is_premium: boolean;
}
interface CodeRecord extends QueryResultRow {
id: string;
max_uses: number | null;
current_uses: number;
expires_at: Date | null;
is_active: boolean;
}
export interface WebhookResult {
duplicate: boolean;
handled: boolean;
}
export interface BillingRepository {
processRevenueCatEvent(payload: RevenueCatWebhook, status: SubscriptionStatus | null): Promise<WebhookResult>;
redeem(email: string, code: string): Promise<RedemptionResult>;
close(): Promise<void>;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
export class PostgresBillingRepository implements BillingRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async processRevenueCatEvent(
payload: RevenueCatWebhook,
status: SubscriptionStatus | null,
): Promise<WebhookResult> {
return transaction(this.pool, async (client) => {
const event = payload.event;
const inserted = await client.query(
`insert into revenuecat_webhook_events (event_id, event_type, app_user_id, payload)
values ($1, $2, $3, $4::jsonb)
on conflict (event_id) do nothing`,
[event.id, event.type, event.app_user_id, JSON.stringify(payload)],
);
if (inserted.rowCount === 0) return { duplicate: true, handled: status !== null };
if (status === null) return { duplicate: false, handled: false };
const profileResult = await client.query<ProfileRecord>(
'select id, couple_id, is_premium from profiles where id = $1 for update',
[event.app_user_id],
);
const profile = profileResult.rows[0];
if (!profile) throw new BillingError('user_not_found', 'User not found', 404);
await client.query(
`insert into subscriptions (
user_id, revenuecat_app_user_id, product_id, status, entitlement_ids,
purchased_at, expires_at, original_transaction_id, store, is_sandbox,
cancel_reason, grace_period_expires_at
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
on conflict (user_id, original_transaction_id) do update set
revenuecat_app_user_id = excluded.revenuecat_app_user_id,
product_id = excluded.product_id,
status = excluded.status,
entitlement_ids = excluded.entitlement_ids,
purchased_at = excluded.purchased_at,
expires_at = excluded.expires_at,
store = excluded.store,
is_sandbox = excluded.is_sandbox,
cancel_reason = excluded.cancel_reason,
grace_period_expires_at = excluded.grace_period_expires_at,
updated_at = now()`,
[
event.app_user_id,
event.app_user_id,
event.product_id,
status,
event.entitlement_ids,
new Date(event.purchased_at_ms),
event.expiration_at_ms === undefined ? null : new Date(event.expiration_at_ms),
event.original_transaction_id,
event.store,
event.environment !== 'PRODUCTION',
event.cancel_reason ?? null,
event.grace_period_expiration_at_ms === undefined ? null : new Date(event.grace_period_expiration_at_ms),
],
);
const premiumResult = await client.query<{ premium: boolean }>(
`select exists (
select 1 from subscriptions
where user_id = $1 and status = 'active'
and (expires_at is null or expires_at > now())
) as premium`,
[event.app_user_id],
);
const isPremium = premiumResult.rows[0]?.premium ?? false;
await client.query(
'update profiles set is_premium = $2, updated_at = now() where id = $1',
[event.app_user_id, isPremium],
);
if (profile.is_premium && !isPremium && profile.couple_id) {
const partnerPremium = await client.query<{ premium: boolean }>(
`select exists (
select 1 from profiles
where couple_id = $1 and id <> $2 and is_premium = true
) as premium`,
[profile.couple_id, event.app_user_id],
);
if (!partnerPremium.rows[0]?.premium) {
await client.query(
`update couple_packs cp set enabled = false
from question_packs qp
where cp.pack_id = qp.id and qp.is_premium = true
and cp.couple_id = $1 and cp.enabled = true`,
[profile.couple_id],
);
}
}
return { duplicate: false, handled: true };
});
}
async redeem(email: string, code: string): Promise<RedemptionResult> {
return transaction(this.pool, async (client) => {
const allowance = await client.query(
`insert into redemption_rate_limits (bucket, attempts)
values (date_trunc('minute', now()), 1)
on conflict (bucket) do update
set attempts = redemption_rate_limits.attempts + 1
where redemption_rate_limits.attempts < 60
returning attempts`,
);
if (allowance.rowCount === 0) {
throw new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429);
}
await client.query(
`delete from redemption_rate_limits where bucket < date_trunc('minute', now()) - interval '1 day'`,
);
const profileResult = await client.query<ProfileRecord>(
`select id, couple_id, is_premium from profiles
where lower(email) = lower($1)
order by created_at asc
limit 1
for update`,
[email],
);
const profile = profileResult.rows[0];
if (!profile) return { success: false, error: 'No account found with this email address' };
const codeResult = await client.query<CodeRecord>(
`select id, max_uses, current_uses, expires_at, is_active
from redemption_codes where upper(code) = upper($1) for update`,
[code],
);
const redemptionCode = codeResult.rows[0];
if (!redemptionCode) return { success: false, error: 'Invalid redemption code' };
if (!redemptionCode.is_active) return { success: false, error: 'This code is no longer active' };
if (redemptionCode.expires_at && redemptionCode.expires_at < new Date()) {
return { success: false, error: 'This code has expired' };
}
if (redemptionCode.max_uses !== null && redemptionCode.current_uses >= redemptionCode.max_uses) {
return { success: false, error: 'This code has reached its maximum number of uses' };
}
const existing = await client.query(
'select 1 from code_redemptions where code_id = $1 and user_id = $2',
[redemptionCode.id, profile.id],
);
if (existing.rowCount) return { success: false, error: 'You have already redeemed this code' };
await client.query(
'insert into code_redemptions (code_id, user_id) values ($1, $2)',
[redemptionCode.id, profile.id],
);
await client.query(
'update redemption_codes set current_uses = current_uses + 1, updated_at = now() where id = $1',
[redemptionCode.id],
);
await client.query(
'update profiles set is_premium = true, updated_at = now() where id = $1',
[profile.id],
);
return { success: true, message: 'Code redeemed successfully! You now have premium access.' };
});
}
async close(): Promise<void> {
await this.pool.end();
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import type { BillingService } from './service.js';
import { BillingError } from './types.js';
const redemptionSchema = z.object({
email: z.string().trim().min(1, 'Please enter your email address').max(320),
code: z.string().trim().min(1, 'Please enter a redemption code').max(255),
}).strict();
type ApiApp = Hono<{ Variables: { identity: AuthIdentity } }>;
export function registerBillingRoutes(app: ApiApp, service: BillingService): void {
app.use('/webhooks/revenuecat', bodyLimit({
maxSize: 1024 * 1024,
onError: (c) => c.json({ error: 'Webhook payload is too large' }, 413),
}));
app.post('/webhooks/revenuecat', async (c) => {
try {
service.assertWebhookAuthorization(c.req.header('authorization'));
const rawBody: unknown = await c.req.json().catch(() => null);
return c.json(await service.processWebhook(c.req.header('authorization'), rawBody));
} catch (cause) {
if (cause instanceof BillingError) return c.json({ error: cause.message }, cause.status);
throw cause;
}
});
app.use('/public/v1/redemptions', bodyLimit({
maxSize: 16 * 1024,
onError: (c) => c.json({ success: false, error: 'Request is too large' }, 413),
}));
app.post('/public/v1/redemptions', async (c) => {
try {
const rawBody: unknown = await c.req.json().catch(() => null);
const parsed = redemptionSchema.safeParse(rawBody);
if (!parsed.success) {
return c.json({ success: false, error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400);
}
const result = await service.redeem(parsed.data.email, parsed.data.code);
return c.json(result, result.success ? 200 : 400);
} catch (cause) {
if (cause instanceof BillingError) return c.json({ success: false, error: cause.message }, cause.status);
throw cause;
}
});
}
+61
View File
@@ -0,0 +1,61 @@
import { timingSafeEqual } from 'node:crypto';
import type { BillingRepository } from './repository.js';
import {
BillingError,
type BillingResult,
type RedemptionResult,
revenueCatWebhookSchema,
type SubscriptionStatus,
} from './types.js';
const statusByEvent: Record<string, SubscriptionStatus> = {
INITIAL_PURCHASE: 'active',
RENEWAL: 'active',
UNCANCELLATION: 'active',
PRODUCT_CHANGE: 'active',
CANCELLATION: 'cancelled',
EXPIRATION: 'expired',
BILLING_ISSUE: 'billing_issue',
SUBSCRIPTION_PAUSED: 'paused',
};
function secureEqual(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual);
const expectedBytes = Buffer.from(expected);
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
}
export class BillingService {
constructor(
private readonly repository: BillingRepository,
private readonly revenueCatWebhookSecret: string | undefined,
) {}
assertWebhookAuthorization(authorization: string | undefined): void {
if (!this.revenueCatWebhookSecret) {
throw new BillingError('webhook_unavailable', 'Webhook is not configured', 503);
}
if (!authorization || !secureEqual(authorization, `Bearer ${this.revenueCatWebhookSecret}`)) {
throw new BillingError('unauthorized', 'Unauthorized', 401);
}
}
async processWebhook(authorization: string | undefined, rawPayload: unknown): Promise<BillingResult> {
this.assertWebhookAuthorization(authorization);
const parsed = revenueCatWebhookSchema.safeParse(rawPayload);
if (!parsed.success) throw new BillingError('invalid_payload', 'Invalid webhook payload', 400);
const payload = parsed.data;
const status = statusByEvent[payload.event.type] ?? null;
const result = await this.repository.processRevenueCatEvent(payload, status);
if (result.duplicate) return { success: true, message: 'Duplicate event' };
if (payload.event.type === 'TEST') return { success: true, message: 'Test event received' };
if (!result.handled) return { success: true, message: 'Event type not handled' };
return { success: true };
}
redeem(email: string, code: string): Promise<RedemptionResult> {
return this.repository.redeem(email.trim(), code.trim());
}
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
export const revenueCatWebhookSchema = z.object({
api_version: z.string().min(1),
event: z.object({
type: z.string().min(1),
id: z.string().min(1).max(255),
app_user_id: z.string().uuid(),
original_app_user_id: z.string().min(1),
product_id: z.string().min(1),
entitlement_ids: z.array(z.string()),
purchased_at_ms: z.number().int().nonnegative(),
expiration_at_ms: z.number().int().nonnegative().optional(),
original_transaction_id: z.string().min(1),
store: z.string().min(1),
environment: z.string().min(1),
cancel_reason: z.string().optional(),
grace_period_expiration_at_ms: z.number().int().nonnegative().optional(),
}).passthrough(),
}).passthrough();
export type RevenueCatWebhook = z.infer<typeof revenueCatWebhookSchema>;
export type SubscriptionStatus = 'active' | 'cancelled' | 'expired' | 'billing_issue' | 'paused';
export interface BillingResult {
success: true;
message?: string;
}
export type RedemptionResult =
| { success: true; message: string }
| { success: false; error: string };
export class BillingError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 401 | 404 | 429 | 503,
) {
super(message);
this.name = 'BillingError';
}
}