CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Claude/add ai components m8 xs6 #4099

Merged⚡ AI-generatedXLccantynz wants to mergeclaude/add-ai-components-m8Xs6claude/build-email-service-3R7eoopened Apr 18, 20260/15 tasks
27 changed files+713−94
Modified.env.example+3−2View fileUnifiedSplit
9090# -- Payment / Billing (optional) -----------------------------------------
9191# STRIPE_SECRET_KEY= # sk_test_... or sk_live_...
9292# STRIPE_WEBHOOK_SECRET= # whsec_...
93# STRIPE_PRICE_STARTER=price_starter # Stripe Price ID for Starter plan
94# STRIPE_PRICE_PROFESSIONAL=price_professional # Stripe Price ID for Professional plan
93# STRIPE_PRICE_PERSONAL=price_personal # Stripe Price ID for Personal plan
94# STRIPE_PRICE_PRO=price_pro # Stripe Price ID for Pro plan
95# STRIPE_PRICE_TEAM=price_team # Stripe Price ID for Team plan
9596# STRIPE_PRICE_ENTERPRISE=price_enterprise # Stripe Price ID for Enterprise plan
9697
9798# -- Docker Compose --------------------------------------------------------
Modified.env.production+3−2View fileUnifiedSplit
8585STRIPE_SECRET_KEY=sk_live_YOUR_STRIPE_KEY
8686STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
8787# Create products/prices in Stripe Dashboard, then paste IDs here:
88STRIPE_PRICE_STARTER=price_YOUR_STARTER_PRICE_ID
89STRIPE_PRICE_PROFESSIONAL=price_YOUR_PROFESSIONAL_PRICE_ID
88STRIPE_PRICE_PERSONAL=price_YOUR_PERSONAL_PRICE_ID
89STRIPE_PRICE_PRO=price_YOUR_PRO_PRICE_ID
90STRIPE_PRICE_TEAM=price_YOUR_TEAM_PRICE_ID
9091STRIPE_PRICE_ENTERPRISE=price_YOUR_ENTERPRISE_PRICE_ID
9192
9293# ─── Monitoring (OpenTelemetry) ───────────────────────────────────────────────
Modifiedapps/api/src/lib/billing.ts+259−51View fileUnifiedSplit
66 */
77
88import Stripe from "stripe";
9import { eq, sql } from "drizzle-orm";
10import { getDatabase, accounts } from "@alecrae/db";
9import { and, eq, isNotNull, sql } from "drizzle-orm";
10import { getDatabase, accounts, stripeEvents } from "@alecrae/db";
1111
1212// ─── Stripe client ────────────────────────────────────────────────────────
1313
3030 return stripeInstance;
3131}
3232
33// ─── Price ID resolution ──────────────────────────────────────────────────
34//
35// In production we refuse to boot with placeholder price IDs — silently
36// shipping a `price_starter` literal to Stripe is how real customers end up
37// hitting "No such price" at checkout. In dev we keep fallbacks so the app
38// still runs without a Stripe account, but we log a loud WARN.
39
40const REQUIRED_PRICE_ENVS = [
41 "STRIPE_PRICE_PERSONAL",
42 "STRIPE_PRICE_PRO",
43 "STRIPE_PRICE_TEAM",
44] as const;
45
46const IS_PRODUCTION = process.env["NODE_ENV"] === "production";
47
48if (IS_PRODUCTION) {
49 const missing = REQUIRED_PRICE_ENVS.filter((key) => !process.env[key]);
50 if (missing.length > 0) {
51 throw new Error(
52 `FATAL: Stripe price IDs missing: ${missing.join(", ")}`,
53 );
54 }
55}
56
57function resolvePriceId(
58 envKey: (typeof REQUIRED_PRICE_ENVS)[number] | "STRIPE_PRICE_ENTERPRISE",
59 placeholder: string,
60): string {
61 const value = process.env[envKey];
62 if (value) return value;
63
64 // IS_PRODUCTION=true + required env missing throws above, so any path
65 // reaching the fallback here is either dev, test, or the optional
66 // enterprise tier (priced manually, webhook-only).
67 if (!IS_PRODUCTION) {
68 console.warn(
69 `[billing] WARN: Using placeholder price ID for ${envKey}. ` +
70 "Checkout will fail against real Stripe.",
71 );
72 }
73 return placeholder;
74}
75
3376// ─── Plan definitions ─────────────────────────────────────────────────────
3477
35export type PlanId = "free" | "starter" | "professional" | "enterprise";
78export type PlanId = "free" | "personal" | "pro" | "team" | "enterprise";
3679
3780export interface PlanDefinition {
3881 priceId: string | null;
4386
4487export const PLANS: Record<PlanId, PlanDefinition> = {
4588 free: { priceId: null, emailsPerMonth: 1_000, domains: 1, webhooks: 2 },
46 starter: {
47 priceId: process.env["STRIPE_PRICE_STARTER"] ?? "price_starter",
89 personal: {
90 priceId: resolvePriceId("STRIPE_PRICE_PERSONAL", "price_personal"),
4891 emailsPerMonth: 10_000,
4992 domains: 5,
5093 webhooks: 10,
5194 },
52 professional: {
53 priceId:
54 process.env["STRIPE_PRICE_PROFESSIONAL"] ?? "price_professional",
95 pro: {
96 priceId: resolvePriceId("STRIPE_PRICE_PRO", "price_pro"),
5597 emailsPerMonth: 100_000,
5698 domains: 25,
5799 webhooks: 50,
58100 },
101 team: {
102 priceId: resolvePriceId("STRIPE_PRICE_TEAM", "price_team"),
103 emailsPerMonth: 250_000,
104 domains: 50,
105 webhooks: 100,
106 },
59107 enterprise: {
60 priceId: process.env["STRIPE_PRICE_ENTERPRISE"] ?? "price_enterprise",
108 priceId: resolvePriceId("STRIPE_PRICE_ENTERPRISE", "price_enterprise"),
61109 emailsPerMonth: 1_000_000,
62110 domains: 100,
63111 webhooks: 200,
80128 return null;
81129}
82130
131/**
132 * Resolve a Stripe customer id off an invoice or subscription payload.
133 */
134function extractCustomerId(
135 customer: string | Stripe.Customer | Stripe.DeletedCustomer | null,
136): string | null {
137 if (!customer) return null;
138 if (typeof customer === "string") return customer;
139 return customer.id;
140}
141
83142// ─── Customer management ──────────────────────────────────────────────────
84143
85144/**
201260
202261// ─── Webhook event processing ─────────────────────────────────────────────
203262
263/**
264 * Apply a Stripe Subscription payload to the matching account row. Used by
265 * both `customer.subscription.created` (plan selected in Stripe dashboard /
266 * API) and `customer.subscription.updated` (plan change, renewal) — they
267 * carry the same shape, so we don't branch.
268 */
269async function applySubscriptionUpdate(
270 subscription: Stripe.Subscription,
271): Promise<{ handled: boolean; action?: string }> {
272 const db = getDatabase();
273 const accountId = subscription.metadata?.["accountId"];
274
275 if (!accountId) return { handled: false };
276
277 const priceId = subscription.items.data[0]?.price?.id;
278 const newPlan = priceId ? planFromPriceId(priceId) : null;
279
280 const updates: Record<string, unknown> = {
281 stripeSubscriptionId: subscription.id,
282 updatedAt: new Date(),
283 };
284
285 if (newPlan) {
286 updates["planTier"] = newPlan;
287 }
288
289 // If the subscription just renewed (current_period_start changed),
290 // reset usage counters.
291 if (subscription.current_period_start) {
292 const periodStart = new Date(subscription.current_period_start * 1000);
293 updates["periodStartedAt"] = periodStart;
294 updates["emailsSentThisPeriod"] = 0;
295 }
296
297 await db.update(accounts).set(updates).where(eq(accounts.id, accountId));
298
299 return { handled: true, action: "subscription_updated" };
300}
301
204302/**
205303 * Process a verified Stripe webhook event and update the database
206304 * accordingly.
305 *
306 * Idempotency: callers should gate invocation on the `stripe_events` table
307 * via `wasEventProcessed` / `recordEventReceived` / `markEventProcessed` —
308 * Stripe redelivers the same event on transient failures and retries after
309 * 5xx, so this function itself must only run once per event id.
207310 */
208311export async function handleWebhookEvent(
209312 event: Stripe.Event,
239342 return { handled: true, action: `upgraded_to_${planId}` };
240343 }
241344
242 // ── Subscription updated (plan change, renewal) ─────────────────
345 // ── Subscription created (dashboard- or API-created subs) ───────
346 // Shares logic with subscription.updated — both carry the full
347 // Subscription payload and either may be the first signal we see
348 // depending on whether the checkout flow or dashboard path was used.
349 case "customer.subscription.created":
243350 case "customer.subscription.updated": {
244351 const subscription = event.data.object as Stripe.Subscription;
245 const accountId = subscription.metadata?.["accountId"];
246
247 if (!accountId) return { handled: false };
248
249 const priceId = subscription.items.data[0]?.price?.id;
250 const newPlan = priceId ? planFromPriceId(priceId) : null;
251
252 const updates: Record<string, unknown> = {
253 stripeSubscriptionId: subscription.id,
254 updatedAt: new Date(),
255 };
256
257 if (newPlan) {
258 updates["planTier"] = newPlan;
259 }
260
261 // If the subscription just renewed (current_period_start changed),
262 // reset usage counters.
263 if (subscription.current_period_start) {
264 const periodStart = new Date(
265 subscription.current_period_start * 1000,
266 );
267 updates["periodStartedAt"] = periodStart;
268 updates["emailsSentThisPeriod"] = 0;
269 }
270
271 await db
272 .update(accounts)
273 .set(updates)
274 .where(eq(accounts.id, accountId));
275
276 return { handled: true, action: "subscription_updated" };
352 return applySubscriptionUpdate(subscription);
277353 }
278354
279355 // ── Subscription deleted (cancelled / expired) ──────────────────
295371 return { handled: true, action: "downgraded_to_free" };
296372 }
297373
298 // ── Payment failed ──────────────────────────────────────────────
374 // ── Invoice paid — authoritative renewal signal ─────────────────
375 // Stripe fires `invoice.paid` whenever a subscription invoice is
376 // finalised and paid (initial and renewal). If we previously marked
377 // the account past_due, this clears that flag. Usage reset is handled
378 // by `customer.subscription.updated` (which carries the new
379 // current_period_start) so we only need to clear dunning state here.
380 case "invoice.paid": {
381 const invoice = event.data.object as Stripe.Invoice;
382 const customerId = extractCustomerId(invoice.customer);
383
384 if (!customerId) return { handled: false };
385
386 const [account] = await db
387 .select({ id: accounts.id, billingStatus: accounts.billingStatus })
388 .from(accounts)
389 .where(eq(accounts.stripeCustomerId, customerId))
390 .limit(1);
391
392 if (!account) {
393 console.log(
394 `[billing] invoice.paid for unknown Stripe customer ${customerId}`,
395 );
396 return { handled: false };
397 }
398
399 // Clear past_due / downgraded_unpaid if the payment catches them up.
400 if (account.billingStatus !== "active") {
401 await db
402 .update(accounts)
403 .set({
404 billingStatus: "active",
405 pastDueSince: null,
406 updatedAt: new Date(),
407 })
408 .where(eq(accounts.id, account.id));
409 }
410
411 console.log(`[billing] invoice.paid for account ${account.id}`);
412 return { handled: true, action: "invoice_paid" };
413 }
414
415 // ── Payment failed — enter dunning ──────────────────────────────
416 // Flip the account to past_due and stamp the clock. The dunning
417 // worker (apps/api/src/workers/dunning.ts) sweeps past_due rows
418 // older than 7 days and downgrades them to free.
299419 case "invoice.payment_failed": {
300420 const invoice = event.data.object as Stripe.Invoice;
301 const customerId =
302 typeof invoice.customer === "string"
303 ? invoice.customer
304 : (invoice.customer as Stripe.Customer | null)?.id;
421 const customerId = extractCustomerId(invoice.customer);
305422
306423 if (!customerId) return { handled: false };
307424
308 // Log for now — in production this would trigger a dunning flow
425 const [account] = await db
426 .select({ id: accounts.id, billingStatus: accounts.billingStatus })
427 .from(accounts)
428 .where(eq(accounts.stripeCustomerId, customerId))
429 .limit(1);
430
431 if (!account) {
432 console.warn(
433 `[billing] payment_failed for unknown Stripe customer ${customerId}`,
434 );
435 return { handled: false };
436 }
437
438 // Only stamp pastDueSince on the first failure — subsequent retries
439 // shouldn't reset the 7-day grace clock.
440 const updates: Record<string, unknown> = {
441 billingStatus: "past_due",
442 updatedAt: new Date(),
443 };
444 if (account.billingStatus !== "past_due") {
445 updates["pastDueSince"] = new Date();
446 }
447
448 await db
449 .update(accounts)
450 .set(updates)
451 .where(eq(accounts.id, account.id));
452
309453 console.warn(
310 `[billing] Payment failed for Stripe customer ${customerId}`,
454 `[billing] payment_failed: account ${account.id} marked past_due`,
311455 );
312
313 return { handled: true, action: "payment_failed_logged" };
456 // TODO: fire "payment failed" email via transactional email queue
457 return { handled: true, action: "payment_failed_past_due" };
314458 }
315459
316460 default:
318462 }
319463}
320464
465// ─── Webhook idempotency helpers ──────────────────────────────────────────
466//
467// Stripe guarantees at-least-once delivery. The route handler must run
468// these in order:
469// 1. wasEventProcessed(id) — short-circuit if we already handled it
470// 2. recordEventReceived(id, type) — INSERT before running logic
471// 3. handleWebhookEvent(event)
472// 4. markEventProcessed(id) — only after success; a throw leaves
473// processedAt NULL so Stripe retries work
474
475export interface StripeEventRecord {
476 id: string;
477 processedAt: Date | null;
478}
479
480export async function wasEventProcessed(
481 eventId: string,
482): Promise<StripeEventRecord | null> {
483 const db = getDatabase();
484 const [row] = await db
485 .select({
486 id: stripeEvents.id,
487 processedAt: stripeEvents.processedAt,
488 })
489 .from(stripeEvents)
490 .where(eq(stripeEvents.id, eventId))
491 .limit(1);
492 return row ?? null;
493}
494
495export async function recordEventReceived(
496 eventId: string,
497 eventType: string,
498): Promise<void> {
499 const db = getDatabase();
500 // ON CONFLICT DO NOTHING handles the race where two webhook deliveries
501 // are processed near-simultaneously — whichever INSERT loses still sees
502 // the row afterwards and carries on.
503 await db
504 .insert(stripeEvents)
505 .values({ id: eventId, type: eventType })
506 .onConflictDoNothing({ target: stripeEvents.id });
507}
508
509export async function markEventProcessed(eventId: string): Promise<void> {
510 const db = getDatabase();
511 await db
512 .update(stripeEvents)
513 .set({ processedAt: new Date() })
514 .where(eq(stripeEvents.id, eventId));
515}
516
321517// ─── Usage tracking & enforcement ─────────────────────────────────────────
322518
323519export interface UsageInfo {
446642 return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
447643}
448644
645// ─── Dunning query (exposed for the worker) ───────────────────────────────
646
647/**
648 * Predicate clause: accounts whose dunning grace period has elapsed.
649 * Exposed for the worker at apps/api/src/workers/dunning.ts.
650 */
651export const pastDueBeyondGraceClause = and(
652 eq(accounts.billingStatus, "past_due"),
653 isNotNull(accounts.pastDueSince),
654 sql`${accounts.pastDueSince} < NOW() - INTERVAL '7 days'`,
655);
656
449657export { getStripe, isPlanId };
Modifiedapps/api/src/lib/jwt.ts+1−1View fileUnifiedSplit
235235 }
236236
237237 // Look up account tier
238 let tier = "starter";
238 let tier = "personal";
239239 try {
240240 const [account] = await db
241241 .select({ planTier: accounts.planTier })
Modifiedapps/api/src/middleware/auth.ts+53−6View fileUnifiedSplit
2323 keyId: string;
2424 tier: PlanTier;
2525 scopes: string[];
26 /** Whether the authed account has ops/admin privileges on /v1/admin/*. */
27 isAdmin: boolean;
2628}
2729
2830declare module "hono" {
8385 switch (dbTier) {
8486 case "free":
8587 return "free";
88 // Legacy enum values kept for read-compat with pre-rebrand rows.
8689 case "starter":
87 return "starter";
90 case "personal":
91 return "personal";
8892 case "professional":
8993 case "pro":
9094 return "pro";
95 case "team":
96 return "team";
9197 case "enterprise":
9298 return "enterprise";
9399 default:
94 return "starter";
100 return "personal";
95101 }
96102}
97103
142148 )
143149 : [];
144150
145 // Look up account to get the real plan tier
146 let tier: PlanTier = "starter";
151 // Look up account to get the real plan tier + admin flag
152 let tier: PlanTier = "personal";
153 let isAdmin = false;
147154 try {
148155 const [account] = await db
149 .select({ planTier: accounts.planTier })
156 .select({ planTier: accounts.planTier, isAdmin: accounts.isAdmin })
150157 .from(accounts)
151158 .where(eq(accounts.id, record.accountId))
152159 .limit(1);
153160 if (account) {
154161 tier = normaliseTier(account.planTier);
162 isAdmin = account.isAdmin === true;
155163 }
156164 } catch {
157165 // Fall back to a safe default if the account lookup fails
158 tier = normaliseTier(record.environment === "test" ? "starter" : "pro");
166 tier = normaliseTier(record.environment === "test" ? "personal" : "pro");
159167 }
160168
161169 return {
163171 keyId: record.id,
164172 tier,
165173 scopes,
174 isAdmin,
166175 };
167176 } catch (error) {
168177 console.error("[auth] Database lookup failed:", error);
189198 "webhooks:manage",
190199 "analytics:read",
191200 ],
201 isAdmin: false,
192202 };
193203 }
194204
210220 keyId: (payload.jti as string) ?? `oauth_${Date.now()}`,
211221 tier: normaliseTier(payload.tier as string),
212222 scopes: (payload.scope as string)?.split(" ") ?? [],
223 isAdmin: payload["isAdmin"] === true,
213224 };
214225 } catch {
215226 // Fallback: try raw decode for legacy tokens (unsigned / HS256 dev tokens)
228239 keyId: (payload.jti as string) ?? `oauth_${Date.now()}`,
229240 tier: normaliseTier(payload.tier as string),
230241 scopes: (payload.scope as string)?.split(" ") ?? [],
242 isAdmin: payload.isAdmin === true,
231243 };
232244 } catch {
233245 return null;
268280
269281// ─── Scope enforcement middleware ───────────────────────────────────────────
270282
283/**
284 * Gate that only admin accounts may pass. Must run AFTER `authMiddleware`
285 * so that `c.get("auth")` is populated. Enforces the isAdmin flag that is
286 * loaded from the accounts table for API keys, or from the JWT payload for
287 * bearer tokens.
288 */
289export const requireAdmin = createMiddleware(async (c, next) => {
290 const auth = c.get("auth");
291 if (!auth || auth.isAdmin !== true) {
292 return c.json({ error: "admin_required" }, 403);
293 }
294 await next();
295 return;
296});
297
271298export function requireScope(...requiredScopes: string[]) {
272299 return createMiddleware(async (c, next) => {
273300 const auth = c.get("auth");
307334
308335// ─── Main auth middleware ───────────────────────────────────────────────────
309336
337// Hard-fail at module load in production when DATABASE_URL is missing.
338// The dev fallback (resolveApiKeyDev) would happily accept any well-formed
339// key without a DB lookup — shipping that behaviour to production is an
340// authentication bypass, so refuse to start instead.
341if (
342 process.env["NODE_ENV"] === "production" &&
343 !process.env["DATABASE_URL"]
344) {
345 throw new Error("FATAL: DATABASE_URL required in production");
346}
347
310348const useDatabase = !!process.env["DATABASE_URL"];
311349
312350export const authMiddleware = createMiddleware(async (c, next) => {
351 // Defence-in-depth: even if this module was loaded before env was set
352 // (tests, boot races), never accept unauthenticated requests in prod.
353 if (
354 process.env["NODE_ENV"] === "production" &&
355 !process.env["DATABASE_URL"]
356 ) {
357 throw new Error("FATAL: DATABASE_URL required in production");
358 }
359
313360 const credential = extractCredential(c);
314361
315362 if (!credential) {
Modifiedapps/api/src/middleware/usage.ts+3−2View fileUnifiedSplit
3535 const currentPlan = usage.planTier as PlanId;
3636 const planOrder: PlanId[] = [
3737 "free",
38 "starter",
39 "professional",
38 "personal",
39 "pro",
40 "team",
4041 "enterprise",
4142 ];
4243 const currentIndex = planOrder.indexOf(currentPlan);
Modifiedapps/api/src/routes/billing.ts+43−1View fileUnifiedSplit
2222 getUsage,
2323 constructWebhookEvent,
2424 handleWebhookEvent,
25 markEventProcessed,
26 recordEventReceived,
27 wasEventProcessed,
2528 PLANS,
2629 isPlanId,
2730} from "../lib/billing.js";
3235// ─── Schemas ──────────────────────────────────────────────────────────────
3336
3437const CheckoutSchema = z.object({
35 planId: z.enum(["starter", "professional", "enterprise"]),
38 planId: z.enum(["personal", "pro", "team", "enterprise"]),
3639 successUrl: z.string().url(),
3740 cancelUrl: z.string().url(),
3841});
228231 );
229232 }
230233
234 // ─── Idempotency guard ───────────────────────────────────────────────
235 //
236 // Stripe redelivers the same event.id on transient failures, scheduled
237 // retries after 5xx, and when our response is slow. Without this guard,
238 // `invoice.payment_failed` could flip an account to past_due twice and
239 // re-stamp pastDueSince, resetting the 7-day grace clock on every
240 // retry. We check-then-insert under the event id's unique PK; if we've
241 // already stamped processedAt the handler is skipped with 200 OK.
242 try {
243 const existing = await wasEventProcessed(event.id);
244 if (existing?.processedAt) {
245 console.log(
246 `[billing/webhook] Duplicate ${event.type} (${event.id}) — already processed`,
247 );
248 return c.json({ ok: true, duplicate: true });
249 }
250 if (!existing) {
251 await recordEventReceived(event.id, event.type);
252 }
253 } catch (err) {
254 // If the idempotency table is unavailable we'd rather fail loud than
255 // silently risk double-processing. Return 500 so Stripe retries.
256 console.error("[billing/webhook] Idempotency check failed:", err);
257 return c.json(
258 {
259 error: {
260 type: "server_error",
261 message: "Failed to record webhook event",
262 code: "webhook_idempotency_failed",
263 },
264 },
265 500,
266 );
267 }
268
231269 try {
232270 const result = await handleWebhookEvent(event);
233271
241279 );
242280 }
243281
282 // Only stamp processedAt on success. If the handler threw above,
283 // processedAt stays NULL so the next Stripe retry is allowed to run.
284 await markEventProcessed(event.id);
285
244286 return c.json({ received: true });
245287 } catch (err) {
246288 console.error("[billing/webhook] Error processing event:", err);
Modifiedapps/api/src/routes/connect.ts+90−6View fileUnifiedSplit
1313
1414import { Hono } from "hono";
1515import { z } from "zod";
16import { createHmac, timingSafeEqual } from "node:crypto";
1617import { requireScope } from "../middleware/auth.js";
1718import { validateBody, getValidatedBody } from "../middleware/validator.js";
1819import {
3132 return crypto.randomUUID().replace(/-/g, "");
3233}
3334
35// ─── OAuth state signing (HMAC-SHA256) ────────────────────────────────────────
36//
37// The OAuth `state` parameter carries the initiating user's accountId across
38// the Google/Microsoft redirect. Without a signature the callback has no way
39// to prove the state wasn't forged by an attacker — a crafted URL could bind
40// the attacker's tokens to the victim's accountId (account takeover).
41//
42// We therefore sign the base64url(JSON) payload with HMAC-SHA256 using
43// OAUTH_STATE_SECRET and verify it in the callback before trusting `userId`.
44
45function getOauthStateSecret(): string {
46 const secret = process.env["OAUTH_STATE_SECRET"];
47 if (!secret) {
48 // Fail loudly instead of silently using a dev default — an unsigned
49 // state in production is an account-takeover vector.
50 throw new Error("OAUTH_STATE_SECRET is not set");
51 }
52 return secret;
53}
54
55function toBase64Url(input: Buffer): string {
56 return input.toString("base64url");
57}
58
59function signOauthState(payload: Record<string, unknown>): string {
60 const payloadB64 = toBase64Url(Buffer.from(JSON.stringify(payload)));
61 const sig = createHmac("sha256", getOauthStateSecret())
62 .update(payloadB64)
63 .digest();
64 return `${payloadB64}.${toBase64Url(sig)}`;
65}
66
67interface VerifiedState {
68 userId: string;
69 provider?: string;
70 ts?: number;
71}
72
73function verifyOauthState(signed: string): VerifiedState | null {
74 const dot = signed.indexOf(".");
75 if (dot <= 0 || dot === signed.length - 1) return null;
76 const payloadB64 = signed.slice(0, dot);
77 const sigB64 = signed.slice(dot + 1);
78
79 const expectedSig = createHmac("sha256", getOauthStateSecret())
80 .update(payloadB64)
81 .digest();
82 let providedSig: Buffer;
83 try {
84 providedSig = Buffer.from(sigB64, "base64url");
85 } catch {
86 return null;
87 }
88 if (providedSig.length !== expectedSig.length) return null;
89 if (!timingSafeEqual(providedSig, expectedSig)) return null;
90
91 try {
92 const json = Buffer.from(payloadB64, "base64url").toString("utf8");
93 const parsed = JSON.parse(json) as {
94 userId?: unknown;
95 provider?: unknown;
96 ts?: unknown;
97 };
98 if (typeof parsed.userId !== "string" || parsed.userId.length === 0) {
99 return null;
100 }
101 const result: VerifiedState = { userId: parsed.userId };
102 if (typeof parsed.provider === "string") result.provider = parsed.provider;
103 if (typeof parsed.ts === "number") result.ts = parsed.ts;
104 return result;
105 } catch {
106 return null;
107 }
108}
109
34110const ImapConnectSchema = z.object({
35111 email: z.string().email(),
36112 displayName: z.string().optional(),
54130 requireScope("accounts:write"),
55131 (c) => {
56132 const auth = c.get("auth");
57 const state = Buffer.from(JSON.stringify({
133 const state = signOauthState({
58134 userId: auth.accountId,
59135 provider: "gmail",
60136 ts: Date.now(),
61 })).toString("base64url");
137 });
62138
63139 return c.redirect(getGoogleAuthUrl(state));
64140 },
70146 requireScope("accounts:write"),
71147 (c) => {
72148 const auth = c.get("auth");
73 const state = Buffer.from(JSON.stringify({
149 const state = signOauthState({
74150 userId: auth.accountId,
75151 provider: "outlook",
76152 ts: Date.now(),
77 })).toString("base64url");
153 });
78154
79155 return c.redirect(getMicrosoftAuthUrl(state));
80156 },
91167 return c.json({ error: { message: "Missing code or state" } }, 400);
92168 }
93169
170 const state = verifyOauthState(stateParam);
171 if (!state) {
172 return c.json({ error: { message: "invalid oauth state" } }, 400);
173 }
174
94175 try {
95 const state = JSON.parse(Buffer.from(stateParam, "base64url").toString()) as { userId: string };
96176 const tokens = await exchangeGoogleCode(code);
97177
98178 const account: EmailAccount = {
137217 return c.json({ error: { message: "Missing code or state" } }, 400);
138218 }
139219
220 const state = verifyOauthState(stateParam);
221 if (!state) {
222 return c.json({ error: { message: "invalid oauth state" } }, 400);
223 }
224
140225 try {
141 const state = JSON.parse(Buffer.from(stateParam, "base64url").toString()) as { userId: string };
142226 const tokens = await exchangeMicrosoftCode(code);
143227
144228 const account: EmailAccount = {
Modifiedapps/api/src/routes/fbl.ts+13−0View fileUnifiedSplit
209209 * - JSON body with fields: originalMailFrom, originalRcptTo, feedbackType, sourceIp
210210 */
211211fbl.post("/report", async (c) => {
212 // Shared-secret auth — ISPs post FBL reports from known IPs with the
213 // X-FBL-Secret header we provision out of band. Unauthenticated acceptance
214 // would let anyone poison the suppression list for any sender.
215 const expectedSecret = process.env["FBL_REPORT_SECRET"];
216 if (!expectedSecret) {
217 return c.json({ error: "fbl_report_disabled" }, 503);
218 }
219
220 const providedSecret = c.req.header("x-fbl-secret");
221 if (!providedSecret || providedSecret !== expectedSecret) {
222 return c.json({ error: "unauthorized" }, 401);
223 }
224
212225 const contentType = c.req.header("content-type") ?? "";
213226 let report: ParsedArfReport | null = null;
214227
Modifiedapps/api/src/server.ts+3−3View fileUnifiedSplit
1616import { timing } from "hono/timing";
1717import { secureHeaders } from "hono/secure-headers";
1818
19import { authMiddleware } from "./middleware/auth.js";
19import { authMiddleware, requireAdmin } from "./middleware/auth.js";
2020import {
2121 globalIpRateLimit,
2222 authRateLimit,
382382app.use("/v1/fbl/*", webhookRateLimit);
383383app.route("/v1/fbl", fbl);
384384
385// Admin dashboard: requires admin API key auth (applied via authMiddleware above)
386app.use("/v1/admin/*", authMiddleware, readRateLimit);
385// Admin dashboard: requires admin API key auth + isAdmin flag on the account.
386app.use("/v1/admin/*", authMiddleware, requireAdmin, readRateLimit);
387387app.route("/v1/admin", admin);
388388
389389// ─── 404 handler ────────────────────────────────────────────────────────────
Modifiedapps/api/src/types.ts+3−2View fileUnifiedSplit
200200}
201201
202202// --- API Key / Auth ---
203export type PlanTier = "free" | "starter" | "pro" | "enterprise";
203export type PlanTier = "free" | "personal" | "pro" | "team" | "enterprise";
204204
205205export interface ApiKeyRecord {
206206 id: string;
226226
227227export const RATE_LIMITS: Record<PlanTier, { requestsPerSecond: number; burstSize: number }> = {
228228 free: { requestsPerSecond: 2, burstSize: 5 },
229 starter: { requestsPerSecond: 10, burstSize: 30 },
229 personal: { requestsPerSecond: 10, burstSize: 30 },
230230 pro: { requestsPerSecond: 50, burstSize: 150 },
231 team: { requestsPerSecond: 100, burstSize: 300 },
231232 enterprise: { requestsPerSecond: 200, burstSize: 500 },
232233};
Addedapps/api/src/workers/dunning.ts+50−0View fileUnifiedSplit
1/**
2 * Dunning Worker — MVP
3 *
4 * Sweeps accounts that have been `past_due` for longer than the grace
5 * window and downgrades them to `free` with `billingStatus =
6 * 'downgraded_unpaid'`. Keeps an audit trail via the distinct status so
7 * support can identify involuntary downgrades vs user-initiated
8 * cancellations.
9 *
10 * Pure function, no scheduling — this file intentionally does NOT import
11 * any cron or BullMQ runtime. Wire it up from whichever scheduler owns
12 * background jobs (expected: BullMQ in a future change) by calling
13 * `processDunning()` on a cadence. Running it more than once per minute
14 * is safe; the WHERE clause scopes to the 7-day window.
15 *
16 * When a card actually succeeds after downgrade, `invoice.paid` in
17 * billing.ts resets `billingStatus` back to `active` — but that handler
18 * does NOT restore the previous plan tier. The user goes through
19 * checkout again, which is the correct behavior for an involuntarily
20 * cancelled subscription.
21 */
22
23import { getDatabase, accounts } from "@alecrae/db";
24import { pastDueBeyondGraceClause } from "../lib/billing.js";
25
26export interface DunningResult {
27 downgraded: number;
28}
29
30export async function processDunning(): Promise<DunningResult> {
31 const db = getDatabase();
32
33 const downgraded = await db
34 .update(accounts)
35 .set({
36 planTier: "free",
37 billingStatus: "downgraded_unpaid",
38 updatedAt: new Date(),
39 })
40 .where(pastDueBeyondGraceClause)
41 .returning({ id: accounts.id });
42
43 if (downgraded.length > 0) {
44 console.warn(
45 `[dunning] downgraded ${downgraded.length} account(s) to free after 7-day past_due`,
46 );
47 }
48
49 return { downgraded: downgraded.length };
50}
Modifiedapps/api/tests/fbl.test.ts+9−5View fileUnifiedSplit
104104
105105describe("POST /v1/fbl/report", () => {
106106 let app: Hono;
107 const FBL_SECRET = "test-fbl-secret";
107108
108109 beforeEach(async () => {
109110 vi.clearAllMocks();
111112 queryResults = [];
112113 mockDb = createMockDb();
113114
115 // Endpoint now requires a shared-secret header; provision it for tests.
116 process.env["FBL_REPORT_SECRET"] = FBL_SECRET;
117
114118 const { fbl } = await import("../src/routes/fbl.js");
115119 app = new Hono();
116120 app.route("/v1/fbl", fbl);
132136
133137 const res = await app.request("/v1/fbl/report", {
134138 method: "POST",
135 headers: { "Content-Type": "application/json" },
139 headers: { "Content-Type": "application/json", "X-FBL-Secret": FBL_SECRET },
136140 body: JSON.stringify({
137141 originalMailFrom: "sender@example.com",
138142 originalRcptTo: "recipient@gmail.com",
152156 it("should return 400 for an invalid/empty report", async () => {
153157 const res = await app.request("/v1/fbl/report", {
154158 method: "POST",
155 headers: { "Content-Type": "application/json" },
159 headers: { "Content-Type": "application/json", "X-FBL-Secret": FBL_SECRET },
156160 body: JSON.stringify({ invalid: true }),
157161 });
158162
168172
169173 const res = await app.request("/v1/fbl/report", {
170174 method: "POST",
171 headers: { "Content-Type": "application/json" },
175 headers: { "Content-Type": "application/json", "X-FBL-Secret": FBL_SECRET },
172176 body: JSON.stringify({
173177 originalMailFrom: "sender@unknown-domain.com",
174178 originalRcptTo: "recipient@gmail.com",
202206
203207 const res = await app.request("/v1/fbl/report", {
204208 method: "POST",
205 headers: { "Content-Type": "text/plain" },
209 headers: { "Content-Type": "text/plain", "X-FBL-Secret": FBL_SECRET },
206210 body: arfBody,
207211 });
208212
222226
223227 const res = await app.request("/v1/fbl/report", {
224228 method: "POST",
225 headers: { "Content-Type": "application/json" },
229 headers: { "Content-Type": "application/json", "X-FBL-Secret": FBL_SECRET },
226230 body: JSON.stringify({
227231 originalMailFrom: "sender@example.com",
228232 originalRcptTo: "unhappy@gmail.com",
Modifieddocs/infra/.env.production.template+19−0View fileUnifiedSplit
9797STRIPE_WEBHOOK_SECRET=<whsec_xxxxx>
9898NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<pk_live_xxxxx>
9999
100# Stripe Price IDs — one per paid plan (free plan has no priceId)
101STRIPE_PRICE_PERSONAL=<price_xxxxx>
102STRIPE_PRICE_PRO=<price_xxxxx>
103STRIPE_PRICE_TEAM=<price_xxxxx>
104STRIPE_PRICE_ENTERPRISE=<price_xxxxx>
105
106
107# ------------------------------------------------------------
108# OAuth - Shared state signing
109# ------------------------------------------------------------
110# REQUIRED - HMAC secret for signing the OAuth `state` param.
111# Generate with: openssl rand -base64 48
112OAUTH_STATE_SECRET=<random_48_byte_base64>
113
100114
101115# ------------------------------------------------------------
102116# OAuth - Google (Gmail sync + SSO)
126140ABUSE_EMAIL=abuse@alecrae.com
127141DMARC_EMAIL=dmarc@alecrae.com
128142
143# Shared secret required on X-FBL-Secret header for POST /v1/fbl/report.
144# Without this the FBL ingestion endpoint is disabled (returns 503).
145# Generate with: openssl rand -base64 32
146FBL_REPORT_SECRET=<random_32_byte_base64>
147
129148
130149# ------------------------------------------------------------
131150# Search - Meilisearch
Modifieddocs/infra/env-audit.md+6−0View fileUnifiedSplit
6969| `STRIPE_SECRET_KEY` | Server-side Stripe calls | dashboard.stripe.com → Developers → API keys | `sk_live_...` | api |
7070| `STRIPE_WEBHOOK_SECRET` | Verify webhook signatures | dashboard.stripe.com → Developers → Webhooks → signing secret | `whsec_...` | api |
7171| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe.js client-side | dashboard.stripe.com → Developers → API keys | `pk_live_...` | web |
72| `STRIPE_PRICE_PERSONAL` | Stripe Price ID for the Personal plan | dashboard.stripe.com → Products | `price_...` | api |
73| `STRIPE_PRICE_PRO` | Stripe Price ID for the Pro plan | dashboard.stripe.com → Products | `price_...` | api |
74| `STRIPE_PRICE_TEAM` | Stripe Price ID for the Team plan | dashboard.stripe.com → Products | `price_...` | api |
75| `STRIPE_PRICE_ENTERPRISE` | Stripe Price ID for the Enterprise plan | dashboard.stripe.com → Products | `price_...` | api |
7276
7377## OAuth Providers
7478
7579| Var | Purpose | Where to get | Example | Services |
7680|---|---|---|---|---|
81| `OAUTH_STATE_SECRET` | HMAC-SHA256 secret for signing the OAuth `state` param (account-takeover mitigation) | `openssl rand -base64 48` | `<48-byte-base64>` | api |
7782| `GOOGLE_CLIENT_ID` | Gmail OAuth + SSO | console.cloud.google.com → Credentials | `...apps.googleusercontent.com` | api |
7883| `GOOGLE_CLIENT_SECRET` | Gmail OAuth secret | Same | `GOCSPX-...` | api |
7984| `MICROSOFT_CLIENT_ID` | Outlook/Graph OAuth | portal.azure.com → App registrations | UUID | api |
99104| `POSTMASTER_EMAIL` | RFC 5321 required postmaster contact | Literal | `postmaster@alecrae.com` | mta |
100105| `ABUSE_EMAIL` | Abuse contact (RFC 2142) | Literal | `abuse@alecrae.com` | mta |
101106| `DMARC_EMAIL` | DMARC aggregate report receiver | Literal | `dmarc@alecrae.com` | mta |
107| `FBL_REPORT_SECRET` | Shared secret for `X-FBL-Secret` header on `POST /v1/fbl/report`. If unset the endpoint returns 503 and refuses reports. | `openssl rand -base64 32` | `<32-byte-base64>` | api |
102108
103109## Storage — Cloudflare R2
104110
Modifiedinfrastructure/cloudflare/neon-setup.sql+3−1View fileUnifiedSplit
1313
1414-- ─── Enums ───────────────────────────────────────────────────────────────────
1515
16-- Plan tier enum. `starter` and `professional` are legacy values kept for
17-- backwards-compat with pre-2026-04-18 rows. New writes use personal/pro/team.
1618DO $$ BEGIN
17 CREATE TYPE plan_tier AS ENUM ('free', 'starter', 'professional', 'enterprise');
19 CREATE TYPE plan_tier AS ENUM ('free', 'starter', 'professional', 'personal', 'pro', 'team', 'enterprise');
1820EXCEPTION WHEN duplicate_object THEN NULL;
1921END $$;
2022
Modifiedpackages/db/src/index.ts+6−0View fileUnifiedSplit
301301 refreshTokensRelations,
302302} from "./schema/refresh-tokens.js";
303303
304// Schema - Stripe Events (webhook idempotency guard)
305export { stripeEvents } from "./schema/billing.js";
306
304307// ---------------------------------------------------------------------------
305308// Inferred types from schemas
306309// ---------------------------------------------------------------------------
355358 voiceTrainingSamples,
356359} from "./schema/voice-clone.js";
357360import type { refreshTokens } from "./schema/refresh-tokens.js";
361import type { stripeEvents } from "./schema/billing.js";
358362
359363// Select types (what you get back from queries)
360364export type Account = InferSelectModel<typeof accounts>;
451455export type NewScriptRun = InferInsertModel<typeof scriptRuns>;
452456export type RefreshToken = InferSelectModel<typeof refreshTokens>;
453457export type NewRefreshToken = InferInsertModel<typeof refreshTokens>;
458export type StripeEvent = InferSelectModel<typeof stripeEvents>;
459export type NewStripeEvent = InferInsertModel<typeof stripeEvents>;
Addedpackages/db/src/migrations/0012_add_is_admin_to_accounts.sql+6−0View fileUnifiedSplit
1-- 0012_add_is_admin_to_accounts.sql
2-- Adds an `is_admin` flag to the accounts table so /v1/admin/* can enforce
3-- an explicit admin check at the middleware layer.
4
5ALTER TABLE accounts
6 ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
Addedpackages/db/src/migrations/0013_rename_plan_tiers.sql+15−0View fileUnifiedSplit
1-- 0013_rename_plan_tiers.sql
2-- Align plan_tier enum with the CLAUDE.md pricing bible:
3-- free / personal / pro / team / enterprise
4--
5-- Postgres cannot DROP an enum value inside a transaction, and cannot do so at
6-- all while any row still references it. We therefore only ADD the new names
7-- here; legacy values (`starter`, `professional`) stay orphaned until a future
8-- migration renames existing rows + drops the unused values.
9--
10-- Each ADD VALUE runs in its own implicit statement and is idempotent thanks to
11-- IF NOT EXISTS.
12
13ALTER TYPE plan_tier ADD VALUE IF NOT EXISTS 'personal';
14ALTER TYPE plan_tier ADD VALUE IF NOT EXISTS 'pro';
15ALTER TYPE plan_tier ADD VALUE IF NOT EXISTS 'team';
Addedpackages/db/src/migrations/0014_stripe_events.sql+40−0View fileUnifiedSplit
1-- 0014_stripe_events.sql
2-- Adds infrastructure to close the remaining Stripe webhook gaps for launch:
3--
4-- 1. `stripe_events` — idempotency guard. Stripe can redeliver the same
5-- event many times (on transient network errors, scheduled retries after
6-- 5xx, etc.). We record every incoming event.id BEFORE the handler runs
7-- and only stamp processed_at once it succeeds. A second delivery of the
8-- same id hits the processed row and short-circuits with 200 OK. If a
9-- handler throws, processed_at stays NULL so the next Stripe retry is
10-- allowed to run the logic again.
11--
12-- 2. `accounts.billing_status` / `accounts.past_due_since` — MVP dunning.
13-- `invoice.payment_failed` flips the account to `past_due` and stamps
14-- `past_due_since`. A worker sweeps rows older than 7 days and
15-- downgrades them to `free` with status `downgraded_unpaid`.
16
17CREATE TABLE IF NOT EXISTS stripe_events (
18 id TEXT PRIMARY KEY,
19 type TEXT NOT NULL,
20 received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
21 processed_at TIMESTAMPTZ
22);
23
24CREATE INDEX IF NOT EXISTS stripe_events_type_idx
25 ON stripe_events (type);
26
27CREATE INDEX IF NOT EXISTS stripe_events_processed_at_idx
28 ON stripe_events (processed_at);
29
30-- Dunning fields on accounts. Default `active` keeps every existing row
31-- untouched. `past_due_since` is only set when a payment actually fails.
32ALTER TABLE accounts
33 ADD COLUMN IF NOT EXISTS billing_status TEXT NOT NULL DEFAULT 'active';
34
35ALTER TABLE accounts
36 ADD COLUMN IF NOT EXISTS past_due_since TIMESTAMPTZ;
37
38CREATE INDEX IF NOT EXISTS accounts_billing_status_idx
39 ON accounts (billing_status)
40 WHERE billing_status <> 'active';
Addedpackages/db/src/schema/billing.ts+40−0View fileUnifiedSplit
1import {
2 pgTable,
3 text,
4 timestamp,
5 index,
6} from "drizzle-orm/pg-core";
7
8// ---------------------------------------------------------------------------
9// Stripe Events — webhook idempotency guard
10// ---------------------------------------------------------------------------
11//
12// Stripe can and will redeliver the same webhook event multiple times (on
13// transient network errors, scheduled retries after 5xx, etc.). Handlers must
14// therefore be idempotent. We enforce that at the DB layer: every incoming
15// `event.id` is recorded here BEFORE the handler runs, and only marked
16// `processedAt` once it succeeds. A second delivery of the same id hits the
17// processed row and short-circuits with a 200 OK.
18//
19// If a handler throws, `processedAt` stays NULL so the next Stripe retry is
20// allowed to run the logic again.
21
22export const stripeEvents = pgTable(
23 "stripe_events",
24 {
25 /** The Stripe event id (e.g. `evt_1Abc...`). */
26 id: text("id").primaryKey(),
27 /** The Stripe event type (e.g. `invoice.paid`). */
28 type: text("type").notNull(),
29 /** When we first received this event. */
30 receivedAt: timestamp("received_at", { withTimezone: true })
31 .notNull()
32 .defaultNow(),
33 /** When the handler completed successfully. NULL = not yet processed. */
34 processedAt: timestamp("processed_at", { withTimezone: true }),
35 },
36 (table) => [
37 index("stripe_events_type_idx").on(table.type),
38 index("stripe_events_processed_at_idx").on(table.processedAt),
39 ],
40);
Modifiedpackages/db/src/schema/users.ts+18−0View fileUnifiedSplit
1616// Enums
1717// ---------------------------------------------------------------------------
1818
19// Note: `starter` and `professional` are legacy values kept in the Postgres
20// enum for backwards compatibility with rows created before the 2026-04-18
21// rebrand. New writes should use `personal`, `pro`, `team`, or `enterprise`.
22// A future migration will rename existing rows and drop the legacy values.
1923export const planTierEnum = pgEnum("plan_tier", [
2024 "free",
2125 "starter",
2226 "professional",
27 "personal",
28 "pro",
29 "team",
2330 "enterprise",
2431]);
2532
4653 id: text("id").primaryKey(), // CUID or ULID
4754 name: text("name").notNull(),
4855 planTier: planTierEnum("plan_tier").notNull().default("free"),
56 /** Grants access to /v1/admin/* endpoints. Set manually for ops users. */
57 isAdmin: boolean("is_admin").notNull().default(false),
4958 emailsSentThisPeriod: integer("emails_sent_this_period")
5059 .notNull()
5160 .default(0),
5766 storageUsedBytes: bigint("storage_used_bytes", { mode: "number" }).notNull().default(0),
5867 stripeCustomerId: text("stripe_customer_id"),
5968 stripeSubscriptionId: text("stripe_subscription_id"),
69 /**
70 * Dunning state derived from Stripe webhooks.
71 * `active` — payments up to date (default)
72 * `past_due` — invoice.payment_failed fired; grace period
73 * `downgraded_unpaid` — dunning worker downgraded to free after 7d
74 */
75 billingStatus: text("billing_status").notNull().default("active"),
76 /** Stamped when billingStatus flips to past_due. NULL otherwise. */
77 pastDueSince: timestamp("past_due_since", { withTimezone: true }),
6078 status: accountStatusEnum("status").notNull().default("active"),
6179 scheduledDeletionAt: timestamp("scheduled_deletion_at", {
6280 withTimezone: true,
Modifiedpackages/db/src/seed.ts+1−1View fileUnifiedSplit
5656 .values({
5757 id: accountId,
5858 name: "Test Organization",
59 planTier: "professional",
59 planTier: "pro",
6060 billingEmail: "billing@test.alecrae.dev",
6161 })
6262 .onConflictDoNothing();
Modifiedpackages/shared/src/constants/limits.ts+23−8View fileUnifiedSplit
2121 dedicatedIp: false,
2222 prioritySupport: false,
2323 },
24 starter: {
25 tier: "starter",
26 name: "Starter",
24 personal: {
25 tier: "personal",
26 name: "Personal",
2727 monthlyEmailLimit: 50_000,
2828 rateLimit: 10,
2929 maxAttachmentSize: 10 * MB,
3535 dedicatedIp: false,
3636 prioritySupport: false,
3737 },
38 professional: {
39 tier: "professional",
40 name: "Professional",
38 pro: {
39 tier: "pro",
40 name: "Pro",
4141 monthlyEmailLimit: 500_000,
4242 rateLimit: 50,
4343 maxAttachmentSize: 25 * MB,
4949 dedicatedIp: true,
5050 prioritySupport: true,
5151 },
52 team: {
53 tier: "team",
54 name: "Team",
55 monthlyEmailLimit: 1_000_000,
56 rateLimit: 100,
57 maxAttachmentSize: 50 * MB,
58 maxEmailSize: 75 * MB,
59 maxDomains: 50,
60 maxApiKeys: 50,
61 maxWebhooks: 30,
62 retentionDays: 60,
63 dedicatedIp: true,
64 prioritySupport: true,
65 },
5266 enterprise: {
5367 tier: "enterprise",
5468 name: "Enterprise",
6882/** Storage limits per plan tier in bytes. */
6983export const STORAGE_LIMITS: Readonly<Record<PlanTier, number>> = {
7084 free: 1 * GB,
71 starter: 10 * GB,
72 professional: 100 * GB,
85 personal: 10 * GB,
86 pro: 100 * GB,
87 team: 500 * GB,
7388 enterprise: 1024 * GB, // 1 TB
7489} as const;
7590
Modifiedpackages/shared/src/types/user.d.ts+1−1View fileUnifiedSplit
11/** Plan tiers available on the platform. */
2export type PlanTier = "free" | "starter" | "professional" | "enterprise";
2export type PlanTier = "free" | "personal" | "pro" | "team" | "enterprise";
33/** Subscription plan with limits and features. */
44export interface Plan {
55 readonly tier: PlanTier;
Modifiedpackages/shared/src/types/user.ts+1−1View fileUnifiedSplit
11/** Plan tiers available on the platform. */
2export type PlanTier = "free" | "starter" | "professional" | "enterprise";
2export type PlanTier = "free" | "personal" | "pro" | "team" | "enterprise";
33
44/** Subscription plan with limits and features. */
55export interface Plan {
Modifiedservices/support/src/types.ts+1−1View fileUnifiedSplit
102102
103103export interface AccountSettings {
104104 accountId: string;
105 plan: "free" | "starter" | "professional" | "enterprise";
105 plan: "free" | "personal" | "pro" | "team" | "enterprise";
106106 domains: string[];
107107 sendingLimits: {
108108 perHour: number;
109109
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts