mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './repository.js';
|
||||
export * from './routes.js';
|
||||
export * from './service.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user