anhedral 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2337 @@
1
+ import path from 'node:path';
2
+ import { writeFile, exec } from '../util.js';
3
+ import { getBackendInstallCommands } from '../commands.js';
4
+ export async function scaffoldBackend(root, { projectName, displayName, frontendUrl }) {
5
+ const dir = path.join(root, 'backend');
6
+ // writeFile creates parent dirs automatically via mkdirSync
7
+ writePackageJson(dir, projectName);
8
+ writeTsConfig(dir);
9
+ writeEslintConfig(dir);
10
+ writeGitignore(dir);
11
+ writeEnvExample(dir, frontendUrl);
12
+ writeEnvFile(dir, frontendUrl);
13
+ writeDrizzleConfig(dir);
14
+ writeVitestConfig(dir);
15
+ writeVercelJson(dir);
16
+ writeTypes(dir);
17
+ writeDbFiles(dir);
18
+ writeErrorFiles(dir);
19
+ writeLibFiles(dir);
20
+ writePlugins(dir);
21
+ writeConfig(dir);
22
+ writeRepositories(dir);
23
+ writeRoutes(dir, displayName);
24
+ writeAppAndIndex(dir, displayName);
25
+ writeVercelEntry(dir);
26
+ writeTestFiles(dir);
27
+ for (const command of getBackendInstallCommands()) {
28
+ exec(command.cmd, dir);
29
+ }
30
+ }
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ // Config files
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ function writePackageJson(dir, projectName) {
35
+ writeFile(path.join(dir, 'package.json'), JSON.stringify({
36
+ name: projectName + '-backend',
37
+ version: '1.0.0',
38
+ description: `${projectName} Backend`,
39
+ type: 'module',
40
+ scripts: {
41
+ dev: 'tsx --env-file=.env --watch src/index.ts',
42
+ build: 'pnpm typecheck',
43
+ typecheck: 'tsc --noEmit',
44
+ 'db:generate': 'drizzle-kit generate',
45
+ 'db:migrate': 'pnpm tsx --env-file=.env src/db/migrate.ts',
46
+ 'db:studio': 'drizzle-kit studio',
47
+ 'db:push': 'drizzle-kit push',
48
+ lint: 'eslint . --ext .js,.ts',
49
+ test: 'vitest run',
50
+ 'test:watch': 'vitest',
51
+ },
52
+ keywords: [],
53
+ license: 'MIT',
54
+ }, null, 2) + '\n');
55
+ }
56
+ function writeTsConfig(dir) {
57
+ writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({
58
+ compilerOptions: {
59
+ target: 'ESNext',
60
+ module: 'NodeNext',
61
+ moduleResolution: 'NodeNext',
62
+ skipLibCheck: true,
63
+ rootDir: '.',
64
+ outDir: 'dist',
65
+ lib: ['ESNext', 'DOM', 'DOM.Iterable'],
66
+ types: ['node'],
67
+ sourceMap: true,
68
+ declaration: true,
69
+ declarationMap: true,
70
+ strict: true,
71
+ noUncheckedIndexedAccess: true,
72
+ verbatimModuleSyntax: true,
73
+ isolatedModules: true,
74
+ esModuleInterop: true,
75
+ resolveJsonModule: true,
76
+ },
77
+ include: ['src/**/*', 'api/**/*'],
78
+ exclude: ['node_modules'],
79
+ }, null, 2) + '\n');
80
+ }
81
+ function writeEslintConfig(dir) {
82
+ writeFile(path.join(dir, 'eslint.config.mjs'), `import globals from 'globals';
83
+ import tseslint from 'typescript-eslint';
84
+
85
+ export default tseslint.config(
86
+ { ignores: ['dist/', 'node_modules/'] },
87
+ { languageOptions: { globals: globals.node } },
88
+ ...tseslint.configs.recommended,
89
+ );
90
+ `);
91
+ }
92
+ function writeGitignore(dir) {
93
+ writeFile(path.join(dir, '.gitignore'), `node_modules/
94
+ dist/
95
+ .env
96
+ .env.*
97
+ !.env.example
98
+ *.log
99
+ *.tsbuildinfo
100
+ `);
101
+ }
102
+ function writeEnvExample(dir, frontendUrl = 'http://localhost:8081') {
103
+ writeFile(path.join(dir, '.env.example'), `# Server
104
+ PORT=8787
105
+ NODE_ENV=development
106
+ LOG_LEVEL=info
107
+
108
+ # Database (NeonDB)
109
+ DATABASE_URL="postgresql://neondb_owner:***@***.neon.tech/neondb?sslmode=require"
110
+
111
+ # Clerk
112
+ CLERK_PUBLISHABLE_KEY="pk_test_***"
113
+ CLERK_SECRET_KEY="sk_test_***"
114
+
115
+ # CORS
116
+ FRONTEND_URL=${frontendUrl}
117
+ EXTENSION_ORIGINS=
118
+
119
+ # Cloudflare R2
120
+ R2_ACCOUNT_ID=
121
+ R2_ACCESS_KEY_ID=
122
+ R2_SECRET_ACCESS_KEY=
123
+ R2_BUCKET=
124
+
125
+ # RevenueCat
126
+ RC_SECRET_API_KEY=
127
+ RC_WEBHOOK_SECRET=
128
+ RC_ENTITLEMENT_ID=pro
129
+ RC_OFFERING_ID=default
130
+ `);
131
+ }
132
+ function writeEnvFile(dir, frontendUrl = 'http://localhost:8081') {
133
+ writeFile(path.join(dir, '.env'), `# Server
134
+ PORT=8787
135
+ NODE_ENV=development
136
+ LOG_LEVEL=info
137
+
138
+ # Database (NeonDB)
139
+ DATABASE_URL="postgresql://user:pass@localhost:5432/app?sslmode=disable"
140
+
141
+ # Clerk
142
+ CLERK_PUBLISHABLE_KEY=""
143
+ CLERK_SECRET_KEY=""
144
+
145
+ # CORS
146
+ FRONTEND_URL=${frontendUrl}
147
+ EXTENSION_ORIGINS=
148
+
149
+ # Cloudflare R2
150
+ R2_ACCOUNT_ID=
151
+ R2_ACCESS_KEY_ID=
152
+ R2_SECRET_ACCESS_KEY=
153
+ R2_BUCKET=
154
+
155
+ # RevenueCat
156
+ RC_SECRET_API_KEY=
157
+ RC_WEBHOOK_SECRET=
158
+ RC_ENTITLEMENT_ID=pro
159
+ RC_OFFERING_ID=default
160
+ `);
161
+ }
162
+ function writeDrizzleConfig(dir) {
163
+ writeFile(path.join(dir, 'drizzle.config.ts'), `import type { Config } from 'drizzle-kit';
164
+ export default {
165
+ schema: './src/db/schema.ts',
166
+ out: './src/db/migrations',
167
+ dialect: 'postgresql',
168
+ dbCredentials: { url: process.env.DATABASE_URL! }
169
+ } satisfies Config;
170
+ `);
171
+ }
172
+ function writeVitestConfig(dir) {
173
+ writeFile(path.join(dir, 'vitest.config.ts'), `import { defineConfig } from 'vitest/config';
174
+
175
+ export default defineConfig({
176
+ test: {
177
+ globals: true,
178
+ environment: 'node',
179
+ setupFiles: ['./test/setup.ts'],
180
+ fileParallelism: false,
181
+ maxWorkers: 1,
182
+ testTimeout: 30000,
183
+ },
184
+ });
185
+ `);
186
+ }
187
+ function writeVercelJson(dir) {
188
+ writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
189
+ version: 2,
190
+ builds: [{ src: 'api/index.ts', use: '@vercel/node' }],
191
+ routes: [{ src: '/(.*)', dest: 'api/index.ts' }],
192
+ }, null, 2) + '\n');
193
+ }
194
+ // ═══════════════════════════════════════════════════════════════════════════
195
+ // Types
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+ function writeTypes(dir) {
198
+ writeFile(path.join(dir, 'src/types/index.ts'), `import type { SubscriptionTier, SubscriptionStatus } from '../db/schema.js';
199
+
200
+ export interface AppUser {
201
+ id: string;
202
+ subscriptionTier?: SubscriptionTier;
203
+ subscriptionStatus?: SubscriptionStatus;
204
+ }
205
+
206
+ export interface AppEnv {
207
+ PORT: number;
208
+ NODE_ENV: string;
209
+ LOG_LEVEL: string;
210
+ CLERK_PUBLISHABLE_KEY?: string | null;
211
+ CLERK_SECRET_KEY?: string | null;
212
+ FRONTEND_URL?: string | null;
213
+ EXTENSION_ORIGINS?: string | null;
214
+ DATABASE_URL?: string | null;
215
+ R2_ACCOUNT_ID?: string | null;
216
+ R2_ACCESS_KEY_ID?: string | null;
217
+ R2_SECRET_ACCESS_KEY?: string | null;
218
+ R2_BUCKET?: string | null;
219
+ RC_SECRET_API_KEY: string;
220
+ RC_WEBHOOK_SECRET: string;
221
+ RC_ENTITLEMENT_ID: string;
222
+ RC_OFFERING_ID: string;
223
+ }
224
+ `);
225
+ writeFile(path.join(dir, 'src/types/fastify-env.d.ts'), `import 'fastify';
226
+ import type { AppEnv } from '../types/index.js';
227
+
228
+ type FastifyReplyType = import('fastify').FastifyReply;
229
+ type FastifyRequestType = import('fastify').FastifyRequest;
230
+
231
+ declare module 'fastify' {
232
+ interface FastifyInstance {
233
+ env: AppEnv;
234
+ authenticate?: (req: FastifyRequestType, reply: FastifyReplyType) => Promise<void> | void;
235
+ }
236
+
237
+ interface FastifyRequest {
238
+ _startedAt?: number;
239
+ }
240
+ }
241
+ `);
242
+ writeFile(path.join(dir, 'src/types/fastify.d.ts'), `import 'fastify';
243
+ import type { AppConfig } from '../config/index.js';
244
+ import type { Repositories } from '../repositories/index.js';
245
+ import type { AppUser } from '../types/index.js';
246
+
247
+ declare module 'fastify' {
248
+ interface FastifyInstance {
249
+ authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>;
250
+ config: AppConfig;
251
+ repos: Repositories;
252
+ }
253
+
254
+ interface FastifyRequest {
255
+ user?: AppUser;
256
+ _startedAt?: number;
257
+ waitUntil?: (promise: Promise<unknown>) => void;
258
+ }
259
+ }
260
+
261
+ export {};
262
+ `);
263
+ }
264
+ // ═══════════════════════════════════════════════════════════════════════════
265
+ // Database
266
+ // ═══════════════════════════════════════════════════════════════════════════
267
+ function writeDbFiles(dir) {
268
+ writeFile(path.join(dir, 'src/db/index.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
269
+ import { neon } from '@neondatabase/serverless';
270
+
271
+ if (!process.env.DATABASE_URL) {
272
+ throw new Error('DATABASE_URL is not defined');
273
+ }
274
+
275
+ const sql = neon(process.env.DATABASE_URL);
276
+ export const db = drizzle(sql);
277
+ export type Database = typeof db;
278
+ `);
279
+ writeFile(path.join(dir, 'src/db/schema.ts'), `import { pgTable, text, timestamp, boolean, integer, index, jsonb } from 'drizzle-orm/pg-core';
280
+ import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
281
+
282
+ export const SUBSCRIPTION_TIERS = ['free', 'pro'] as const;
283
+ export type SubscriptionTier = (typeof SUBSCRIPTION_TIERS)[number];
284
+
285
+ export const SUBSCRIPTION_STATUSES = ['active', 'expired', 'canceled'] as const;
286
+ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
287
+
288
+ export const SUBSCRIPTION_METHODS = ['trialing', 'redeemed', 'paid'] as const;
289
+ export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];
290
+
291
+ export const SUBSCRIPTION_ORIGINS = ['web', 'apple', 'google'] as const;
292
+ export type SubscriptionOrigin = (typeof SUBSCRIPTION_ORIGINS)[number];
293
+
294
+ export const SUBSCRIPTION_EVENT_TYPES = [
295
+ 'trial_started', 'trial_converted', 'trial_expired',
296
+ 'initial_purchase', 'renewal', 'product_change',
297
+ 'cancellation_scheduled', 'cancellation_unscheduled', 'subscription_expired', 'subscription_canceled',
298
+ 'promo_redeemed', 'billing_issue', 'billing_recovered',
299
+ ] as const;
300
+ export type SubscriptionEventType = (typeof SUBSCRIPTION_EVENT_TYPES)[number];
301
+
302
+ export type SubscriptionMetadata = {
303
+ revenueCatProductId?: string; lastWebhookUpdate?: string; cancelReason?: string;
304
+ redeemCode?: string; redeemCodeRedeemedAt?: string;
305
+ };
306
+
307
+ export type SubscriptionEventMetadata = {
308
+ revenueCatEventType?: string; revenueCatProductId?: string; promoCode?: string;
309
+ billingPeriod?: string; price?: { amount: number; currency: string };
310
+ store?: string; transactionId?: string; reason?: string;
311
+ };
312
+
313
+ export const PROMO_CODE_DURATIONS = [1, 6, 12] as const;
314
+ export type PromoCodeDuration = (typeof PROMO_CODE_DURATIONS)[number];
315
+
316
+ export const users = pgTable('users', {
317
+ id: text('id').primaryKey(),
318
+ email: text('email').notNull().unique(),
319
+ displayName: text('display_name'),
320
+ lastLoginAt: timestamp('last_login_at'),
321
+ profileImageUrl: text('profile_image_url'),
322
+ avatarObjectKey: text('avatar_object_key'),
323
+ avatarMimeType: text('avatar_mime_type'),
324
+ creditsBalance: integer('credits_balance').notNull().default(250),
325
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
326
+ });
327
+
328
+ export const subscriptions = pgTable('subscriptions', {
329
+ id: text('id').primaryKey(),
330
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }).unique(),
331
+ tier: text('tier').$type<SubscriptionTier>().notNull().default('free'),
332
+ status: text('status').$type<SubscriptionStatus>().notNull().default('active'),
333
+ method: text('method').$type<SubscriptionMethod>(),
334
+ origin: text('origin').$type<SubscriptionOrigin>(),
335
+ billingPeriod: text('billing_period'),
336
+ currentPeriodStart: timestamp('current_period_start'),
337
+ currentPeriodEnd: timestamp('current_period_end'),
338
+ canceledAt: timestamp('canceled_at'),
339
+ cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
340
+ trialStart: timestamp('trial_start'),
341
+ trialEnd: timestamp('trial_end'),
342
+ dailyLimit: integer('daily_limit'),
343
+ metadata: jsonb('metadata').$type<SubscriptionMetadata>(),
344
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
345
+ updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
346
+ }, (t) => [
347
+ index('subscriptions_user_idx').on(t.userId),
348
+ index('subscriptions_status_idx').on(t.status),
349
+ index('subscriptions_tier_idx').on(t.tier),
350
+ index('subscriptions_period_end_idx').on(t.currentPeriodEnd),
351
+ ]);
352
+
353
+ export const subscriptionEvents = pgTable('subscription_events', {
354
+ id: text('id').primaryKey(),
355
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
356
+ subscriptionId: text('subscription_id').references(() => subscriptions.id, { onDelete: 'set null' }),
357
+ eventType: text('event_type').$type<SubscriptionEventType>().notNull(),
358
+ previousTier: text('previous_tier').$type<SubscriptionTier>(),
359
+ previousStatus: text('previous_status').$type<SubscriptionStatus>(),
360
+ previousMethod: text('previous_method').$type<SubscriptionMethod>(),
361
+ newTier: text('new_tier').$type<SubscriptionTier>(),
362
+ newStatus: text('new_status').$type<SubscriptionStatus>(),
363
+ newMethod: text('new_method').$type<SubscriptionMethod>(),
364
+ revenueCatEventType: text('revenuecat_event_type'),
365
+ revenueCatProductId: text('revenuecat_product_id'),
366
+ origin: text('origin').$type<SubscriptionOrigin>(),
367
+ periodStart: timestamp('period_start'),
368
+ periodEnd: timestamp('period_end'),
369
+ metadata: jsonb('metadata').$type<SubscriptionEventMetadata>(),
370
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
371
+ }, (t) => [
372
+ index('sub_events_user_idx').on(t.userId),
373
+ index('sub_events_user_created_idx').on(t.userId, t.createdAt),
374
+ index('sub_events_type_idx').on(t.eventType),
375
+ ]);
376
+
377
+ export const trialClaims = pgTable('trial_claims', {
378
+ id: text('id').primaryKey(),
379
+ userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
380
+ email: text('email').notNull().unique(),
381
+ claimedAt: timestamp('claimed_at').$defaultFn(() => new Date()).notNull(),
382
+ }, (t) => [index('trial_claims_email_idx').on(t.email)]);
383
+
384
+ export const promoCodes = pgTable('promo_codes', {
385
+ id: text('id').primaryKey(),
386
+ code: text('code').notNull().unique(),
387
+ months: integer('months').$type<PromoCodeDuration>().notNull(),
388
+ maxRedemptions: integer('max_redemptions').notNull().default(1),
389
+ redeemedCount: integer('redeemed_count').notNull().default(0),
390
+ expiresAt: timestamp('expires_at'),
391
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
392
+ }, (t) => [index('promo_codes_code_idx').on(t.code)]);
393
+
394
+ export const promoRedemptions = pgTable('promo_redemptions', {
395
+ id: text('id').primaryKey(),
396
+ promoCodeId: text('promo_code_id').notNull().references(() => promoCodes.id, { onDelete: 'cascade' }),
397
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
398
+ redeemedAt: timestamp('redeemed_at').$defaultFn(() => new Date()).notNull(),
399
+ entitlementExpiresAt: timestamp('entitlement_expires_at').notNull(),
400
+ }, (t) => [
401
+ index('promo_redemptions_user_idx').on(t.userId),
402
+ index('promo_redemptions_code_idx').on(t.promoCodeId),
403
+ ]);
404
+
405
+ export type Users = InferSelectModel<typeof users>;
406
+ export type NewUsers = InferInsertModel<typeof users>;
407
+ export type Subscriptions = InferSelectModel<typeof subscriptions>;
408
+ export type NewSubscriptions = InferInsertModel<typeof subscriptions>;
409
+ export type SubscriptionEvents = InferSelectModel<typeof subscriptionEvents>;
410
+ export type NewSubscriptionEvents = InferInsertModel<typeof subscriptionEvents>;
411
+ export type TrialClaims = InferSelectModel<typeof trialClaims>;
412
+ export type NewTrialClaims = InferInsertModel<typeof trialClaims>;
413
+ export type PromoCodes = InferSelectModel<typeof promoCodes>;
414
+ export type NewPromoCodes = InferInsertModel<typeof promoCodes>;
415
+ export type PromoRedemptions = InferSelectModel<typeof promoRedemptions>;
416
+ export type NewPromoRedemptions = InferInsertModel<typeof promoRedemptions>;
417
+ `);
418
+ writeFile(path.join(dir, 'src/db/migrate.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
419
+ import { migrate } from 'drizzle-orm/neon-http/migrator';
420
+ import { neon } from '@neondatabase/serverless';
421
+ import dotenv from 'dotenv';
422
+
423
+ dotenv.config({ quiet: true });
424
+
425
+ const runMigrate = async () => {
426
+ if (!process.env.DATABASE_URL) {
427
+ throw new Error('DATABASE_URL is not defined');
428
+ }
429
+
430
+ const client = neon(process.env.DATABASE_URL);
431
+ const db = drizzle(client);
432
+
433
+ console.log('⏳ Running migrations...');
434
+ const start = Date.now();
435
+ await migrate(db, { migrationsFolder: './src/db/migrations' });
436
+ console.log('✅ Migrations completed in', Date.now() - start, 'ms');
437
+ process.exit(0);
438
+ };
439
+
440
+ runMigrate().catch((err) => {
441
+ console.error('❌ Migration failed');
442
+ console.error(err);
443
+ process.exit(1);
444
+ });
445
+ `);
446
+ }
447
+ // ═══════════════════════════════════════════════════════════════════════════
448
+ // Errors
449
+ // ═══════════════════════════════════════════════════════════════════════════
450
+ function writeErrorFiles(dir) {
451
+ writeFile(path.join(dir, 'src/errors/AppError.ts'), `export class AppError extends Error {
452
+ constructor(
453
+ public readonly code: string,
454
+ public readonly statusCode: number,
455
+ message: string,
456
+ public readonly details?: unknown
457
+ ) {
458
+ super(message);
459
+ this.name = this.constructor.name;
460
+ Error.captureStackTrace(this, this.constructor);
461
+ }
462
+
463
+ toJSON() {
464
+ const json: { error: string; message: string; details?: unknown } = {
465
+ error: this.code,
466
+ message: this.message,
467
+ };
468
+ if (this.details !== undefined) json.details = this.details;
469
+ return json;
470
+ }
471
+ }
472
+ `);
473
+ writeFile(path.join(dir, 'src/errors/AuthError.ts'), `import { AppError } from './AppError.js';
474
+
475
+ export class AuthError extends AppError {
476
+ constructor(code: string, message: string, details?: unknown, statusCode: number = 401) {
477
+ super(code, statusCode, message, details);
478
+ }
479
+
480
+ static missingAuthorization() {
481
+ return new AuthError('missing_authorization', 'Authorization header is required');
482
+ }
483
+
484
+ static invalidAuthorization() {
485
+ return new AuthError('invalid_authorization', 'Invalid authorization credentials');
486
+ }
487
+
488
+ static invalidToken() {
489
+ return new AuthError('invalid_session_token', 'Invalid or malformed authentication token');
490
+ }
491
+
492
+ static tokenExpired() {
493
+ return new AuthError('token_expired', 'Authentication token has expired');
494
+ }
495
+
496
+ static userRequired() {
497
+ return new AuthError('user_authentication_required', 'This endpoint requires user authentication (JWT token)');
498
+ }
499
+
500
+ static unauthorized() {
501
+ return new AuthError('unauthorized', 'Authentication required');
502
+ }
503
+
504
+ static forbidden() {
505
+ return new AuthError('forbidden', 'Insufficient permissions', undefined, 403);
506
+ }
507
+ }
508
+ `);
509
+ writeFile(path.join(dir, 'src/errors/ValidationError.ts'), `import { AppError } from './AppError.js';
510
+
511
+ export class ValidationError extends AppError {
512
+ constructor(message: string, details?: unknown) {
513
+ super('validation_error', 400, message, details);
514
+ }
515
+
516
+ static missingField(field: string) {
517
+ return new ValidationError(\`Required field is missing: \${field}\`, { field });
518
+ }
519
+
520
+ static invalidFormat(field: string, expected: string) {
521
+ return new ValidationError(\`Invalid format for \${field}. Expected: \${expected}\`, { field, expected });
522
+ }
523
+ }
524
+ `);
525
+ writeFile(path.join(dir, 'src/errors/NotFoundError.ts'), `import { AppError } from './AppError.js';
526
+
527
+ export class NotFoundError extends AppError {
528
+ constructor(resource: string) {
529
+ super('not_found', 404, \`\${resource} not found\`);
530
+ }
531
+ }
532
+ `);
533
+ writeFile(path.join(dir, 'src/errors/RateLimitError.ts'), `import { AppError } from './AppError.js';
534
+
535
+ export class RateLimitError extends AppError {
536
+ constructor(message: string, details?: unknown) {
537
+ super('rate_limited', 429, message, details);
538
+ }
539
+
540
+ static dailyLimit(resource: string, limit: number) {
541
+ return new RateLimitError(
542
+ \`Daily \${resource} limit reached (\${limit} requests per day)\`,
543
+ { resource, limit, window: '24h' }
544
+ );
545
+ }
546
+
547
+ static tooManyRequests(retryAfter?: number) {
548
+ return new RateLimitError('Too many requests. Please try again later.', { retryAfter });
549
+ }
550
+ }
551
+ `);
552
+ writeFile(path.join(dir, 'src/errors/ServerError.ts'), `import { AppError } from './AppError.js';
553
+
554
+ export class ServerError extends AppError {
555
+ constructor(message: string, details?: unknown) {
556
+ super('server_error', 500, message, details);
557
+ }
558
+
559
+ static generic(details?: unknown) {
560
+ return new ServerError('An internal server error occurred', details);
561
+ }
562
+
563
+ static missingConfiguration(key: string) {
564
+ return new ServerError(\`Server misconfiguration: \${key} is not defined\`, { missingKey: key });
565
+ }
566
+
567
+ static databaseError(operation: string, error?: unknown) {
568
+ return new ServerError(\`Database operation failed: \${operation}\`, { operation, error });
569
+ }
570
+ }
571
+ `);
572
+ writeFile(path.join(dir, 'src/errors/errorHandler.ts'), `import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
573
+ import { AppError } from './AppError.js';
574
+
575
+ export function errorHandler(
576
+ error: Error | FastifyError,
577
+ req: FastifyRequest,
578
+ reply: FastifyReply
579
+ ) {
580
+ const isProduction = process.env.NODE_ENV === 'production';
581
+ const statusCode =
582
+ error instanceof AppError
583
+ ? error.statusCode
584
+ : ('statusCode' in error && typeof error.statusCode === 'number')
585
+ ? error.statusCode
586
+ : ('validation' in error && (error as FastifyError).validation)
587
+ ? 400
588
+ : 500;
589
+
590
+ const logPayload = {
591
+ msg: '[error_handler]',
592
+ id: req.id,
593
+ method: req.method,
594
+ url: req.url,
595
+ statusCode,
596
+ errorName: error.name,
597
+ errorMessage: error.message,
598
+ errorCode: (error as AppError).code,
599
+ ...(isProduction && statusCode < 500 ? {} : { stack: error.stack }),
600
+ };
601
+
602
+ if (statusCode < 500) {
603
+ req.log.warn(logPayload);
604
+ } else {
605
+ req.log.error(logPayload);
606
+ }
607
+
608
+ if (error instanceof AppError) {
609
+ return reply.status(error.statusCode).send(error.toJSON());
610
+ }
611
+
612
+ if ('validation' in error && error.validation) {
613
+ const validation = error.validation as Array<{ instancePath?: string; params?: { missingProperty?: string }; keyword?: string }>;
614
+ const missingField = validation.find(v => v.keyword === 'required');
615
+ if (missingField && missingField.params?.missingProperty) {
616
+ return reply.status(400).send({
617
+ error: 'missing_field',
618
+ message: \`Required field is missing: \${missingField.params.missingProperty}\`,
619
+ details: { field: missingField.params.missingProperty },
620
+ });
621
+ }
622
+ return reply.status(400).send({
623
+ error: 'validation_error',
624
+ message: 'Invalid request',
625
+ details: isProduction ? undefined : error.validation,
626
+ });
627
+ }
628
+
629
+ if ('statusCode' in error && typeof error.statusCode === 'number') {
630
+ return reply.status(error.statusCode).send({
631
+ error: error.name || 'error',
632
+ message: error.message,
633
+ });
634
+ }
635
+
636
+ return reply.status(500).send({ error: 'server_error', message: 'An unexpected error occurred' });
637
+ }
638
+ `);
639
+ writeFile(path.join(dir, 'src/errors/index.ts'), `export { AppError } from './AppError.js';
640
+ export { AuthError } from './AuthError.js';
641
+ export { ValidationError } from './ValidationError.js';
642
+ export { NotFoundError } from './NotFoundError.js';
643
+ export { RateLimitError } from './RateLimitError.js';
644
+ export { ServerError } from './ServerError.js';
645
+ export { errorHandler } from './errorHandler.js';
646
+ `);
647
+ }
648
+ // ═══════════════════════════════════════════════════════════════════════════
649
+ // Lib
650
+ // ═══════════════════════════════════════════════════════════════════════════
651
+ function writeLibFiles(dir) {
652
+ writeFile(path.join(dir, 'src/lib/routeHelpers.ts'), `import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
653
+ import { AuthError } from '../errors/index.js';
654
+
655
+ export function createAuthHook(fastify: FastifyInstance) {
656
+ return async (req: FastifyRequest, reply: FastifyReply) => {
657
+ if (typeof fastify.authenticate === 'function') {
658
+ await fastify.authenticate(req, reply);
659
+ } else {
660
+ throw AuthError.unauthorized();
661
+ }
662
+ };
663
+ }
664
+
665
+ export function runBackgroundTask(
666
+ req: FastifyRequest,
667
+ task: Promise<unknown>,
668
+ label?: string
669
+ ): void {
670
+ const wrapped = task.catch((error) => {
671
+ req.log.warn({
672
+ msg: '[background_task_failed]',
673
+ label,
674
+ error: error instanceof Error ? error.message : String(error),
675
+ });
676
+ });
677
+
678
+ if (typeof req.waitUntil === 'function') {
679
+ req.waitUntil(wrapped);
680
+ } else {
681
+ void wrapped;
682
+ }
683
+ }
684
+ `);
685
+ writeFile(path.join(dir, 'src/lib/lruCache.ts'), `interface CacheEntry<T> {
686
+ value: T;
687
+ expiresAt: number;
688
+ }
689
+
690
+ export class LRUCache<T> {
691
+ private cache = new Map<string, CacheEntry<T>>();
692
+ private maxSize: number;
693
+ private ttlMs: number;
694
+ private pruneIntervalMs: number;
695
+ private lastPruneAt = 0;
696
+
697
+ constructor(opts: { maxSize: number; ttlMs: number; pruneIntervalMs?: number }) {
698
+ this.maxSize = opts.maxSize;
699
+ this.ttlMs = opts.ttlMs;
700
+ this.pruneIntervalMs = opts.pruneIntervalMs ?? Math.max(opts.ttlMs * 2, 60_000);
701
+ }
702
+
703
+ get(key: string): T | undefined {
704
+ const entry = this.cache.get(key);
705
+ if (!entry) return undefined;
706
+ if (entry.expiresAt <= Date.now()) {
707
+ this.cache.delete(key);
708
+ return undefined;
709
+ }
710
+ this.cache.delete(key);
711
+ this.cache.set(key, entry);
712
+ return entry.value;
713
+ }
714
+
715
+ set(key: string, value: T): void {
716
+ this.cache.delete(key);
717
+ this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
718
+ this.evictIfNeeded();
719
+ this.maybePrune();
720
+ }
721
+
722
+ invalidate(key: string): void {
723
+ this.cache.delete(key);
724
+ }
725
+
726
+ private evictIfNeeded(): void {
727
+ while (this.cache.size > this.maxSize) {
728
+ const oldest = this.cache.keys().next().value;
729
+ if (oldest !== undefined) this.cache.delete(oldest);
730
+ else break;
731
+ }
732
+ }
733
+
734
+ private maybePrune(): void {
735
+ const now = Date.now();
736
+ if (now - this.lastPruneAt < this.pruneIntervalMs) return;
737
+ this.lastPruneAt = now;
738
+ const expired: string[] = [];
739
+ for (const [key, entry] of this.cache.entries()) {
740
+ if (entry.expiresAt <= now) expired.push(key);
741
+ }
742
+ for (const key of expired) this.cache.delete(key);
743
+ }
744
+ }
745
+ `);
746
+ writeFile(path.join(dir, 'src/lib/fetchWithTimeout.ts'), `export interface FetchWithTimeoutOptions extends RequestInit {
747
+ timeout?: number;
748
+ }
749
+
750
+ export async function fetchWithTimeout(
751
+ url: string,
752
+ options: FetchWithTimeoutOptions = {}
753
+ ): Promise<Response> {
754
+ const { timeout = 60000, ...fetchOptions } = options;
755
+
756
+ const controller = new AbortController();
757
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
758
+
759
+ try {
760
+ const response = await fetch(url, { ...fetchOptions, signal: controller.signal });
761
+ return response;
762
+ } catch (error) {
763
+ if (error instanceof Error && error.name === 'AbortError') {
764
+ throw new Error(\`Request timeout after \${timeout}ms: \${url}\`);
765
+ }
766
+ throw error;
767
+ } finally {
768
+ clearTimeout(timeoutId);
769
+ }
770
+ }
771
+
772
+ export async function fetchWithTimeoutAndRetry(
773
+ url: string,
774
+ options: FetchWithTimeoutOptions = {},
775
+ retries = 0
776
+ ): Promise<Response> {
777
+ let lastError: Error | null = null;
778
+
779
+ for (let attempt = 0; attempt <= retries; attempt++) {
780
+ try {
781
+ return await fetchWithTimeout(url, options);
782
+ } catch (error) {
783
+ lastError = error instanceof Error ? error : new Error(String(error));
784
+ if (attempt < retries) {
785
+ const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
786
+ await new Promise(resolve => setTimeout(resolve, delay));
787
+ }
788
+ }
789
+ }
790
+
791
+ throw lastError || new Error('Request failed');
792
+ }
793
+ `);
794
+ writeFile(path.join(dir, 'src/lib/constants.ts'), `export {
795
+ SUBSCRIPTION_TIERS, SUBSCRIPTION_STATUSES, SUBSCRIPTION_METHODS, SUBSCRIPTION_ORIGINS,
796
+ type SubscriptionTier, type SubscriptionStatus, type SubscriptionMethod, type SubscriptionOrigin,
797
+ } from '../db/schema.js';
798
+
799
+ export const TIER_LIMITS = {
800
+ free: { tier: 'free' as const, dailyLimit: 0 },
801
+ pro: { tier: 'pro' as const, dailyLimit: null },
802
+ } as const;
803
+
804
+ export const TIER_PRICING = {
805
+ free: { tier: 'free' as const, priceMonthly: 0, priceYearly: 0, currency: 'USD', displayName: 'Free', description: 'Get started for free' },
806
+ pro: { tier: 'pro' as const, priceMonthly: 5, priceYearly: 54, currency: 'USD', displayName: 'Pro', description: 'Unlimited access' },
807
+ } as const;
808
+
809
+ export const CACHE_SECONDS = {
810
+ SUBSCRIPTIONS_PRICING: 60,
811
+ } as const;
812
+ `);
813
+ writeFile(path.join(dir, 'src/lib/revenuecat.ts'), `import { createHmac } from 'node:crypto';
814
+ import { fetchWithTimeout } from './fetchWithTimeout.js';
815
+ import { LRUCache } from './lruCache.js';
816
+
817
+ export interface RevenueCatEntitlement {
818
+ pro: boolean;
819
+ expiresAt?: string;
820
+ purchaseDate?: string;
821
+ managementUrl?: string;
822
+ cancelAtPeriodEnd?: boolean;
823
+ }
824
+
825
+ export interface GrantPromoResult {
826
+ success: boolean;
827
+ expiresAt: Date;
828
+ }
829
+
830
+ const RC_CACHE_TTL_MS = process.env.NODE_ENV === 'production' ? 60_000 : 10_000;
831
+ const rcEntitlementCache = new LRUCache<RevenueCatEntitlement>({ maxSize: 100_000, ttlMs: RC_CACHE_TTL_MS });
832
+ const inflightCached = new Map<string, Promise<RevenueCatEntitlement>>();
833
+ const inflightForced = new Map<string, Promise<RevenueCatEntitlement>>();
834
+
835
+ export function invalidateRcEntitlementCache(appUserId: string, entitlementId: string): void {
836
+ rcEntitlementCache.invalidate(\`\${entitlementId}:\${appUserId}\`);
837
+ }
838
+
839
+ function addMonthsClamped(date: Date, months: number): Date {
840
+ const m = Number.isFinite(months) ? months : 0;
841
+ if (m === 0) return new Date(date.getTime());
842
+ const yr = date.getFullYear(), mo = date.getMonth(), day = date.getDate();
843
+ const targetMo = mo + m;
844
+ const targetYr = yr + Math.floor(targetMo / 12);
845
+ const finalMo = ((targetMo % 12) + 12) % 12;
846
+ const lastDay = new Date(targetYr, finalMo + 1, 0).getDate();
847
+ return new Date(targetYr, finalMo, Math.min(day, lastDay), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
848
+ }
849
+
850
+ interface RcSubscriberResponse {
851
+ subscriber?: {
852
+ entitlements?: Record<string, { expires_date?: string; purchase_date?: string; product_identifier?: string; will_renew?: boolean | null; unsubscribe_detected_at?: string | null }>;
853
+ subscriptions?: Record<string, { expires_date?: string; management_url?: string; unsubscribe_detected_at?: string | null }>;
854
+ management_url?: string;
855
+ };
856
+ }
857
+
858
+ export async function getRcEntitlement(
859
+ appUserId: string, entitlementId: string, apiKey: string, opts?: { bypassCache?: boolean }
860
+ ): Promise<RevenueCatEntitlement> {
861
+ const cacheKey = \`\${entitlementId}:\${appUserId}\`;
862
+ const bypass = opts?.bypassCache === true;
863
+
864
+ if (!bypass) {
865
+ const cached = rcEntitlementCache.get(cacheKey);
866
+ if (cached) return cached;
867
+ const inflight = inflightCached.get(cacheKey);
868
+ if (inflight) return inflight;
869
+ } else {
870
+ const inflight = inflightForced.get(cacheKey);
871
+ if (inflight) return inflight;
872
+ }
873
+
874
+ const map = bypass ? inflightForced : inflightCached;
875
+ const p = (async (): Promise<RevenueCatEntitlement> => {
876
+ const res = await fetchWithTimeout(\`https://api.revenuecat.com/v1/subscribers/\${encodeURIComponent(appUserId)}\`, {
877
+ headers: { Authorization: \`Bearer \${apiKey}\` }, timeout: 60_000,
878
+ });
879
+ if (res.status === 404) return { pro: false };
880
+ if (!res.ok) throw new Error(\`RevenueCat API error: \${res.status}\`);
881
+
882
+ const data = (await res.json()) as RcSubscriberResponse;
883
+ const now = new Date();
884
+ const ent = data.subscriber?.entitlements?.[entitlementId];
885
+ const entExpires = ent?.expires_date ? new Date(ent.expires_date) : null;
886
+ const entActive = entExpires ? entExpires > now : false;
887
+
888
+ const subs = data.subscriber?.subscriptions ?? {};
889
+ let bestSub: { expires_date?: string; management_url?: string; unsubscribe_detected_at?: string | null } | undefined;
890
+ let bestSubExpires: Date | null = null;
891
+ for (const sub of Object.values(subs)) {
892
+ const d = sub?.expires_date ? new Date(sub.expires_date) : null;
893
+ if (!d || !Number.isFinite(d.getTime())) continue;
894
+ if (!bestSubExpires || d > bestSubExpires) { bestSubExpires = d; bestSub = sub; }
895
+ }
896
+
897
+ const pro = entActive || (bestSubExpires ? bestSubExpires > now : false);
898
+ const productId = ent?.product_identifier;
899
+ const entSub = productId ? data.subscriber?.subscriptions?.[productId] : undefined;
900
+ const managementUrl = entSub?.management_url || bestSub?.management_url || data.subscriber?.management_url;
901
+ const cancelAtPeriodEnd = ent?.will_renew === false || ent?.unsubscribe_detected_at != null
902
+ || (entSub?.unsubscribe_detected_at ?? bestSub?.unsubscribe_detected_at) != null;
903
+
904
+ const entMs = entExpires?.getTime() ?? 0;
905
+ const subMs = bestSubExpires?.getTime() ?? 0;
906
+ const bestMs = Math.max(entMs, subMs);
907
+ const expiresAt = bestMs ? new Date(bestMs).toISOString() : undefined;
908
+
909
+ return { pro, expiresAt, purchaseDate: ent?.purchase_date, managementUrl, cancelAtPeriodEnd };
910
+ })();
911
+
912
+ map.set(cacheKey, p);
913
+ try {
914
+ const value = await p;
915
+ if (!bypass && value.pro) rcEntitlementCache.set(cacheKey, value);
916
+ return value;
917
+ } finally {
918
+ map.delete(cacheKey);
919
+ }
920
+ }
921
+
922
+ export function verifyRevenueCatWebhook(payload: string, signature: string | undefined, secret: string): boolean {
923
+ if (!signature) return false;
924
+ try {
925
+ const parts = signature.split('=');
926
+ if (parts.length !== 2 || parts[0] !== 'v1' || !parts[1]) return false;
927
+ const hmac = createHmac('sha256', secret);
928
+ hmac.update(payload);
929
+ const computed = hmac.digest('hex');
930
+ const a = Buffer.from(computed, 'hex');
931
+ const b = Buffer.from(parts[1], 'hex');
932
+ if (a.length !== b.length) return false;
933
+ let result = 0;
934
+ for (let i = 0; i < a.length; i++) result |= (a[i] ?? 0) ^ (b[i] ?? 0);
935
+ return result === 0;
936
+ } catch { return false; }
937
+ }
938
+
939
+ export function verifyRevenueCatWebhookAuthorization(authHeader: string | undefined, secret: string): boolean {
940
+ if (!authHeader) return false;
941
+ const normalize = (v: string) => v.trim().replace(/^bearer\\s+/i, '');
942
+ return normalize(authHeader) === normalize(secret);
943
+ }
944
+
945
+ export async function grantPromotionalEntitlement(
946
+ appUserId: string, entitlementId: string, months: number, apiKey: string
947
+ ): Promise<GrantPromoResult> {
948
+ const safeMonths = Number.isFinite(months) && months > 0 ? months : 1;
949
+ const startsAt = new Date();
950
+ const expiresAt = addMonthsClamped(startsAt, safeMonths);
951
+ const url = \`https://api.revenuecat.com/v1/subscribers/\${encodeURIComponent(appUserId)}/entitlements/\${encodeURIComponent(entitlementId)}/promotional\`;
952
+ const res = await fetchWithTimeout(url, {
953
+ method: 'POST',
954
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: \`Bearer \${apiKey}\` },
955
+ body: JSON.stringify({ duration: \`\${safeMonths}m\`, end_time_ms: expiresAt.getTime() }),
956
+ timeout: 60_000,
957
+ });
958
+ if (!res.ok) throw new Error(\`RevenueCat promo grant failed: \${res.status} \${await res.text()}\`);
959
+ return { success: true, expiresAt };
960
+ }
961
+ `);
962
+ writeFile(path.join(dir, 'src/lib/r2.ts'), `import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
963
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
964
+
965
+ type UploadAvatarInput = {
966
+ objectKey: string;
967
+ body: Buffer;
968
+ contentType: string;
969
+ };
970
+
971
+ function hasR2Config() {
972
+ return Boolean(
973
+ process.env.R2_ACCOUNT_ID
974
+ && process.env.R2_ACCESS_KEY_ID
975
+ && process.env.R2_SECRET_ACCESS_KEY
976
+ && process.env.R2_BUCKET
977
+ );
978
+ }
979
+
980
+ function getR2Client() {
981
+ if (!hasR2Config()) {
982
+ throw new Error('R2 is not configured');
983
+ }
984
+
985
+ return new S3Client({
986
+ region: 'auto',
987
+ endpoint: \`https://\${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com\`,
988
+ credentials: {
989
+ accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
990
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? '',
991
+ },
992
+ });
993
+ }
994
+
995
+ function getBucket() {
996
+ if (!process.env.R2_BUCKET) {
997
+ throw new Error('R2_BUCKET is not configured');
998
+ }
999
+
1000
+ return process.env.R2_BUCKET;
1001
+ }
1002
+
1003
+ export function isR2Configured() {
1004
+ return hasR2Config();
1005
+ }
1006
+
1007
+ export async function uploadAvatarToR2(input: UploadAvatarInput) {
1008
+ const client = getR2Client();
1009
+ const bucket = getBucket();
1010
+
1011
+ await client.send(new PutObjectCommand({
1012
+ Bucket: bucket,
1013
+ Key: input.objectKey,
1014
+ Body: input.body,
1015
+ ContentType: input.contentType,
1016
+ }));
1017
+
1018
+ return {
1019
+ bucket,
1020
+ objectKey: input.objectKey,
1021
+ };
1022
+ }
1023
+
1024
+ export async function createSignedAvatarUrl(objectKey: string) {
1025
+ const client = getR2Client();
1026
+ return getSignedUrl(client, new GetObjectCommand({
1027
+ Bucket: getBucket(),
1028
+ Key: objectKey,
1029
+ }), { expiresIn: 60 * 10 });
1030
+ }
1031
+ `);
1032
+ writeFile(path.join(dir, 'src/services/SubscriptionService.ts'), `import type { FastifyInstance, FastifyRequest } from 'fastify';
1033
+ import { getRcEntitlement, invalidateRcEntitlementCache } from '../lib/revenuecat.js';
1034
+ import { TIER_PRICING, TIER_LIMITS } from '../lib/constants.js';
1035
+ import type { Subscriptions, SubscriptionEventType } from '../db/schema.js';
1036
+ import type { RecordEventParams } from '../repositories/index.js';
1037
+ import { runBackgroundTask } from '../lib/routeHelpers.js';
1038
+
1039
+ export interface EntitlementWithTrial {
1040
+ tier: 'free' | 'pro';
1041
+ pro: boolean;
1042
+ inTrial: boolean;
1043
+ trialEndsAt?: string;
1044
+ expiresAt?: string;
1045
+ periodStart?: string;
1046
+ periodEnd?: string;
1047
+ method?: 'trialing' | 'redeemed' | 'paid' | null;
1048
+ managementUrl?: string;
1049
+ cancelAtPeriodEnd?: boolean;
1050
+ }
1051
+
1052
+ export class SubscriptionService {
1053
+ constructor(private fastify: FastifyInstance) {}
1054
+
1055
+ private async recordEvent(req: FastifyRequest | undefined, params: RecordEventParams, label: string): Promise<void> {
1056
+ const task = this.fastify.repos.subscriptionEvents.recordEvent(params);
1057
+ if (req) { runBackgroundTask(req, task, label); return; }
1058
+ await task;
1059
+ }
1060
+
1061
+ async getPricing() {
1062
+ return {
1063
+ tiers: [
1064
+ { tier: TIER_PRICING.free.tier, displayName: TIER_PRICING.free.displayName, description: TIER_PRICING.free.description, priceMonthly: TIER_PRICING.free.priceMonthly, priceYearly: TIER_PRICING.free.priceYearly, currency: TIER_PRICING.free.currency, limits: { dailyLimit: TIER_LIMITS.free.dailyLimit } },
1065
+ { tier: TIER_PRICING.pro.tier, displayName: TIER_PRICING.pro.displayName, description: TIER_PRICING.pro.description, priceMonthly: TIER_PRICING.pro.priceMonthly, priceYearly: TIER_PRICING.pro.priceYearly, currency: TIER_PRICING.pro.currency, limits: { dailyLimit: TIER_LIMITS.pro.dailyLimit }, paymentInfo: { revenueCatEntitlementId: this.fastify.env.RC_ENTITLEMENT_ID, revenueCatOfferingId: this.fastify.env.RC_OFFERING_ID } },
1066
+ ],
1067
+ };
1068
+ }
1069
+
1070
+ private async getEntitlement(appUserId: string, opts?: { bypassCache?: boolean }) {
1071
+ const key = this.fastify.env.RC_SECRET_API_KEY;
1072
+ if (!key) throw new Error('RevenueCat not configured');
1073
+ return getRcEntitlement(appUserId, this.fastify.env.RC_ENTITLEMENT_ID || 'pro', key, { bypassCache: opts?.bypassCache });
1074
+ }
1075
+
1076
+ async getEntitlementWithTrial(appUserId: string, opts?: { refreshRevenueCat?: boolean }, req?: FastifyRequest): Promise<EntitlementWithTrial> {
1077
+ const forceRefresh = opts?.refreshRevenueCat === true;
1078
+ let subscription = await this.fastify.repos.subscriptions.findByUserId(appUserId);
1079
+ if (!subscription) {
1080
+ try { subscription = await this.fastify.repos.subscriptions.getOrCreate(appUserId, { allowTrial: true }); } catch {}
1081
+ }
1082
+
1083
+ const now = new Date();
1084
+ const hasRC = Boolean(this.fastify.env.RC_SECRET_API_KEY);
1085
+ const periodEndMs = subscription?.currentPeriodEnd?.getTime();
1086
+ const nearPeriodEnd = typeof periodEndMs === 'number' && periodEndMs - now.getTime() <= 12 * 60 * 60 * 1000;
1087
+ const isPaidOrRedeemed = subscription?.method === 'paid' || subscription?.method === 'redeemed';
1088
+
1089
+ const shouldSyncRC = hasRC && (forceRefresh || !subscription || subscription.status !== 'active' || subscription.method === 'trialing' || nearPeriodEnd);
1090
+
1091
+ let rcEnt: Awaited<ReturnType<SubscriptionService['getEntitlement']>> | null = null;
1092
+ let rcFailed = false;
1093
+ if (shouldSyncRC) {
1094
+ try { rcEnt = await this.getEntitlement(appUserId, { bypassCache: forceRefresh }); }
1095
+ catch { rcFailed = true; }
1096
+ }
1097
+
1098
+ // RC says pro → trust it, sync DB
1099
+ if (rcEnt?.pro) {
1100
+ const rcEnd = rcEnt.expiresAt ? new Date(rcEnt.expiresAt) : null;
1101
+ const rcStart = rcEnt.purchaseDate ? new Date(rcEnt.purchaseDate) : null;
1102
+ const method: 'paid' | 'redeemed' = subscription?.method === 'redeemed' ? 'redeemed' : 'paid';
1103
+ const cancelAtPeriodEnd = rcEnt.cancelAtPeriodEnd ?? subscription?.cancelAtPeriodEnd ?? false;
1104
+ const needsUpdate = subscription?.method !== method || subscription?.status !== 'active' || subscription?.tier !== 'pro'
1105
+ || (rcEnd && subscription?.currentPeriodEnd?.getTime() !== rcEnd.getTime())
1106
+ || (subscription?.cancelAtPeriodEnd ?? false) !== cancelAtPeriodEnd;
1107
+
1108
+ if (needsUpdate) {
1109
+ const wasNotPaid = subscription?.method !== method || subscription?.status !== 'active';
1110
+ await this.fastify.repos.subscriptions.upsert(appUserId, {
1111
+ tier: 'pro', status: 'active', method, cancelAtPeriodEnd, trialStart: null, trialEnd: null,
1112
+ ...(rcStart ? { currentPeriodStart: rcStart } : {}),
1113
+ ...(rcEnd ? { currentPeriodEnd: rcEnd } : {}),
1114
+ });
1115
+ if (wasNotPaid) {
1116
+ const eventType: SubscriptionEventType = subscription?.method === 'trialing' ? 'trial_converted' : 'initial_purchase';
1117
+ await this.recordEvent(req, { userId: appUserId, subscriptionId: subscription?.id, eventType, previousState: { tier: subscription?.tier, status: subscription?.status, method: subscription?.method }, newState: { tier: 'pro', status: 'active', method }, periodStart: rcStart, periodEnd: rcEnd }, eventType);
1118
+ }
1119
+ }
1120
+ return { tier: 'pro', pro: true, inTrial: false, expiresAt: rcEnt.expiresAt, periodStart: rcStart?.toISOString() ?? subscription?.currentPeriodStart?.toISOString(), periodEnd: rcEnt.expiresAt ?? subscription?.currentPeriodEnd?.toISOString(), method, managementUrl: rcEnt.managementUrl, cancelAtPeriodEnd };
1121
+ }
1122
+
1123
+ // Expire paid if RC confirmed not-pro on forced refresh
1124
+ if (forceRefresh && !rcFailed && rcEnt && !rcEnt.pro && subscription?.tier === 'pro' && subscription.status === 'active' && isPaidOrRedeemed) {
1125
+ await this.expirePaidSubscription(appUserId, rcEnt.expiresAt, req);
1126
+ subscription = await this.fastify.repos.subscriptions.findByUserId(appUserId);
1127
+ }
1128
+
1129
+ // Trial handling
1130
+ if (subscription?.method === 'trialing' && subscription.trialEnd) {
1131
+ if (subscription.trialEnd > now) {
1132
+ return { tier: 'pro', pro: true, inTrial: true, trialEndsAt: subscription.trialEnd.toISOString(), periodStart: subscription.trialStart?.toISOString(), periodEnd: subscription.trialEnd.toISOString(), method: 'trialing', cancelAtPeriodEnd: false };
1133
+ }
1134
+ if (subscription.status !== 'expired') {
1135
+ await this.expireTrial(appUserId, req);
1136
+ subscription = await this.fastify.repos.subscriptions.findByUserId(appUserId);
1137
+ }
1138
+ }
1139
+
1140
+ // DB says active pro
1141
+ if (subscription?.tier === 'pro' && subscription.status === 'active') {
1142
+ return { tier: 'pro', pro: true, inTrial: false, expiresAt: subscription.currentPeriodEnd?.toISOString(), periodStart: subscription.currentPeriodStart?.toISOString(), periodEnd: subscription.currentPeriodEnd?.toISOString(), method: subscription.method, cancelAtPeriodEnd: subscription.cancelAtPeriodEnd };
1143
+ }
1144
+
1145
+ return { tier: 'free', pro: false, inTrial: false, periodStart: subscription?.currentPeriodStart?.toISOString() ?? subscription?.trialStart?.toISOString(), periodEnd: subscription?.currentPeriodEnd?.toISOString() ?? subscription?.trialEnd?.toISOString(), method: subscription?.method, cancelAtPeriodEnd: false };
1146
+ }
1147
+
1148
+ async expireTrial(appUserId: string, req?: FastifyRequest): Promise<void> {
1149
+ const sub = await this.fastify.repos.subscriptions.findByUserId(appUserId);
1150
+ await this.fastify.repos.subscriptions.updateByUserId(appUserId, { tier: 'free', status: 'expired' });
1151
+ await this.recordEvent(req, { userId: appUserId, subscriptionId: sub?.id, eventType: 'trial_expired', previousState: { tier: sub?.tier, status: sub?.status, method: sub?.method }, newState: { tier: 'free', status: 'expired', method: sub?.method }, periodStart: sub?.trialStart, periodEnd: sub?.trialEnd }, 'trial_expired');
1152
+ }
1153
+
1154
+ async expirePaidSubscription(appUserId: string, expiresAt?: string, req?: FastifyRequest): Promise<void> {
1155
+ const sub = await this.fastify.repos.subscriptions.findByUserId(appUserId);
1156
+ const expiresDate = expiresAt ? new Date(expiresAt) : sub?.currentPeriodEnd ?? null;
1157
+ await this.fastify.repos.subscriptions.updateByUserId(appUserId, { tier: 'free', status: 'expired', cancelAtPeriodEnd: false, ...(expiresDate ? { currentPeriodEnd: expiresDate } : {}) });
1158
+ await this.recordEvent(req, { userId: appUserId, subscriptionId: sub?.id, eventType: 'subscription_expired', previousState: { tier: sub?.tier, status: sub?.status, method: sub?.method }, newState: { tier: 'free', status: 'expired', method: sub?.method }, periodStart: sub?.currentPeriodStart, periodEnd: expiresDate }, 'subscription_expired');
1159
+ }
1160
+
1161
+ async handleRevenueCatWebhook(event: Record<string, unknown>, req?: FastifyRequest): Promise<void> {
1162
+ const appUserId = event.app_user_id as string | undefined;
1163
+ if (!appUserId) return;
1164
+
1165
+ const rcEventType = event.type as string | undefined;
1166
+ const entitlementId = this.fastify.env.RC_ENTITLEMENT_ID || 'pro';
1167
+ const entIds = event.entitlement_ids as unknown;
1168
+ const entIdSingle = event.entitlement_id as string | null | undefined;
1169
+ const hasEnt = Array.isArray(entIds) ? entIds.some(x => x === entitlementId) : entIdSingle === entitlementId;
1170
+
1171
+ const expirationMs = event.expiration_at_ms as number | undefined;
1172
+ const expiresAt = typeof expirationMs === 'number' ? new Date(expirationMs) : null;
1173
+ if (expiresAt && isNaN(expiresAt.getTime())) return;
1174
+
1175
+ const now = new Date();
1176
+ const isActive = hasEnt && (expiresAt ? expiresAt > now : false);
1177
+ const tier = isActive ? 'pro' : 'free';
1178
+ const status = isActive ? 'active' : 'expired';
1179
+ const method = isActive ? 'paid' : null;
1180
+
1181
+ const purchasedMs = event.purchased_at_ms as number | undefined;
1182
+ const periodStart = typeof purchasedMs === 'number' ? new Date(purchasedMs) : null;
1183
+ const validStart = periodStart && !isNaN(periodStart.getTime()) ? periodStart : null;
1184
+ const storeRaw = event.store as string | undefined;
1185
+ const storeNorm = storeRaw?.toLowerCase();
1186
+ const willRenew = event.will_renew as boolean | undefined;
1187
+ const cancelReason = event.cancel_reason as string | undefined;
1188
+ const price = event.price as number | undefined;
1189
+ const currency = event.currency as string | undefined;
1190
+ const transactionId = event.transaction_id as string | undefined;
1191
+ const productId = event.product_id as string | undefined;
1192
+ const origin = storeNorm === 'app_store' ? 'apple' : storeNorm === 'play_store' ? 'google' : (storeNorm === 'stripe' || storeNorm === 'rc_billing') ? 'web' : undefined;
1193
+
1194
+ const currentSub = await this.fastify.repos.subscriptions.findByUserId(appUserId);
1195
+ await this.fastify.repos.subscriptions.upsert(appUserId, {
1196
+ tier: tier as 'free' | 'pro', status: status as 'active' | 'expired', method,
1197
+ origin: origin as 'web' | 'apple' | 'google' | undefined,
1198
+ currentPeriodStart: validStart, currentPeriodEnd: expiresAt,
1199
+ billingPeriod: (event.period_type as string | undefined) ?? null,
1200
+ canceledAt: (rcEventType === 'CANCELLATION' || rcEventType === 'EXPIRATION') ? new Date() : null,
1201
+ cancelAtPeriodEnd: willRenew === false && isActive,
1202
+ trialStart: null, trialEnd: null,
1203
+ metadata: { ...(productId ? { revenueCatProductId: productId } : {}), ...(cancelReason ? { cancelReason } : {}), lastWebhookUpdate: new Date().toISOString() },
1204
+ });
1205
+
1206
+ invalidateRcEntitlementCache(appUserId, entitlementId);
1207
+
1208
+ const eventMap: Record<string, string> = {
1209
+ INITIAL_PURCHASE: 'initial_purchase', RENEWAL: 'renewal', PRODUCT_CHANGE: 'product_change',
1210
+ CANCELLATION: 'cancellation_scheduled', UNCANCELLATION: 'cancellation_unscheduled',
1211
+ EXPIRATION: 'subscription_expired', BILLING_ISSUE: 'billing_issue', BILLING_ISSUE_RESOLVED: 'billing_recovered',
1212
+ };
1213
+ const mappedType = rcEventType ? (eventMap[rcEventType] ?? (!isActive ? 'subscription_expired' : 'renewal')) : null;
1214
+ if (mappedType) {
1215
+ await this.recordEvent(req, {
1216
+ userId: appUserId, subscriptionId: currentSub?.id, eventType: mappedType as SubscriptionEventType,
1217
+ previousState: { tier: currentSub?.tier, status: currentSub?.status, method: currentSub?.method },
1218
+ newState: { tier: tier as 'free' | 'pro', status: status as 'active' | 'expired', method },
1219
+ revenueCatEventType: rcEventType, revenueCatProductId: productId,
1220
+ origin: origin as 'web' | 'apple' | 'google' | undefined,
1221
+ periodStart: validStart, periodEnd: expiresAt,
1222
+ metadata: { ...(rcEventType ? { revenueCatEventType: rcEventType } : {}), ...(productId ? { revenueCatProductId: productId } : {}), ...(storeRaw ? { store: storeRaw } : {}), ...(transactionId ? { transactionId } : {}), ...(price !== undefined && currency ? { price: { amount: price, currency } } : {}) },
1223
+ }, 'revenuecat_event');
1224
+ }
1225
+ }
1226
+ }
1227
+ `);
1228
+ writeFile(path.join(dir, 'src/lib/requestUtils.ts'), `import type { FastifyRequest } from 'fastify';
1229
+
1230
+ export function extractDeviceType(userAgent: string | null | undefined): string | null {
1231
+ if (!userAgent) return null;
1232
+ const ua = userAgent.toLowerCase();
1233
+ if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) return 'mobile';
1234
+ if (ua.includes('tablet') || ua.includes('ipad')) return 'tablet';
1235
+ if (ua.includes('chrome-extension') || ua.includes('firefox-extension')) return 'extension';
1236
+ return 'desktop';
1237
+ }
1238
+
1239
+ export function extractIpAddress(req: FastifyRequest): string | null {
1240
+ const realIp = req.headers['x-real-ip'] as string | undefined;
1241
+ if (realIp) return realIp;
1242
+ const forwardedFor = req.headers['x-forwarded-for'] as string | undefined;
1243
+ if (forwardedFor) {
1244
+ const firstIp = forwardedFor.split(',')[0]?.trim();
1245
+ if (firstIp) return firstIp;
1246
+ }
1247
+ return req.ip || null;
1248
+ }
1249
+
1250
+ export function extractDeviceInfo(req: FastifyRequest) {
1251
+ const userAgent = req.headers['user-agent'] || null;
1252
+ const deviceType = extractDeviceType(userAgent);
1253
+ const rawIp = extractIpAddress(req);
1254
+ const ipAddress = anonymizeIp(rawIp);
1255
+ return { deviceType, userAgent, ipAddress };
1256
+ }
1257
+
1258
+ export function sanitizeEmail(email: unknown): string {
1259
+ return String(email || '').toLowerCase().trim();
1260
+ }
1261
+
1262
+ export function maskEmail(email: string): string {
1263
+ if (!email || !email.includes('@')) return '***';
1264
+ const [local, domain] = email.split('@');
1265
+ if (!local || !domain) return '***';
1266
+ const maskedLocal = local.length > 0 ? \`\${local[0]}***\` : '***';
1267
+ return \`\${maskedLocal}@\${domain}\`;
1268
+ }
1269
+
1270
+ export function anonymizeIp(ip: string | null): string | null {
1271
+ if (!ip) return null;
1272
+ if (ip.includes('.') && !ip.includes(':')) {
1273
+ const parts = ip.split('.');
1274
+ if (parts.length === 4) { parts[3] = '0'; return parts.join('.'); }
1275
+ }
1276
+ if (ip.includes(':')) {
1277
+ const parts = ip.split(':');
1278
+ if (parts.length >= 3) return \`\${parts.slice(0, 3).join(':')}::\`;
1279
+ }
1280
+ return ip;
1281
+ }
1282
+ `);
1283
+ }
1284
+ // ═══════════════════════════════════════════════════════════════════════════
1285
+ // Plugins
1286
+ // ═══════════════════════════════════════════════════════════════════════════
1287
+ function writePlugins(dir) {
1288
+ writeFile(path.join(dir, 'src/plugins/env.ts'), `import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
1289
+ import fastifyEnv from '@fastify/env';
1290
+ import fp from 'fastify-plugin';
1291
+
1292
+ const configPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1293
+ const schema = {
1294
+ type: 'object',
1295
+ required: ['PORT', 'DATABASE_URL'],
1296
+ properties: {
1297
+ PORT: { type: 'number' },
1298
+ NODE_ENV: { type: 'string', default: 'development' },
1299
+ LOG_LEVEL: { type: 'string', default: 'info' },
1300
+ CLERK_PUBLISHABLE_KEY: { type: 'string', nullable: true },
1301
+ CLERK_SECRET_KEY: { type: 'string', nullable: true },
1302
+ FRONTEND_URL: { type: 'string', nullable: true },
1303
+ EXTENSION_ORIGINS: { type: 'string', nullable: true },
1304
+ DATABASE_URL: { type: 'string' },
1305
+ R2_ACCOUNT_ID: { type: 'string', nullable: true },
1306
+ R2_ACCESS_KEY_ID: { type: 'string', nullable: true },
1307
+ R2_SECRET_ACCESS_KEY: { type: 'string', nullable: true },
1308
+ R2_BUCKET: { type: 'string', nullable: true },
1309
+ RC_SECRET_API_KEY: { type: 'string', default: '' },
1310
+ RC_WEBHOOK_SECRET: { type: 'string', default: '' },
1311
+ RC_ENTITLEMENT_ID: { type: 'string', default: 'pro' },
1312
+ RC_OFFERING_ID: { type: 'string', default: 'default' },
1313
+ },
1314
+ } as const;
1315
+
1316
+ await fastify.register(fastifyEnv as unknown as FastifyPluginAsync, {
1317
+ schema,
1318
+ dotenv: !process.env.VERCEL,
1319
+ confKey: 'env',
1320
+ } as unknown as Record<string, unknown>);
1321
+ };
1322
+
1323
+ export default fp(configPlugin, { name: 'env-config', fastify: '5.x' });
1324
+ `);
1325
+ writeFile(path.join(dir, 'src/plugins/clerkAuth.ts'), `import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
1326
+ import fp from 'fastify-plugin';
1327
+ import { clerkPlugin, getAuth, clerkClient } from '@clerk/fastify';
1328
+ import { AuthError } from '../errors/index.js';
1329
+ import { runBackgroundTask } from '../lib/routeHelpers.js';
1330
+ import type { AppUser } from '../types/index.js';
1331
+ import crypto from 'node:crypto';
1332
+ import { LRUCache } from '../lib/lruCache.js';
1333
+
1334
+ export const clerkAuthPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1335
+ await fastify.register(clerkPlugin);
1336
+
1337
+ const trackedSessions = new LRUCache<number>({ maxSize: 50_000, ttlMs: 24 * 60 * 60 * 1000 });
1338
+
1339
+ type ClerkUser = Awaited<ReturnType<(typeof clerkClient.users)['getUser']>>;
1340
+ const CLERK_USER_CACHE = new LRUCache<ClerkUser>({
1341
+ maxSize: 50_000,
1342
+ ttlMs: fastify.env?.NODE_ENV === 'production' ? 300_000 : 60_000,
1343
+ });
1344
+ const INFLIGHT_CLERK_USER = new Map<string, Promise<ClerkUser>>();
1345
+
1346
+ const getClerkUser = async (userId: string): Promise<ClerkUser> => {
1347
+ const cached = CLERK_USER_CACHE.get(userId);
1348
+ if (cached) return cached;
1349
+ const existing = INFLIGHT_CLERK_USER.get(userId);
1350
+ if (existing) return existing;
1351
+ const p = (async () => {
1352
+ try {
1353
+ const user = await clerkClient.users.getUser(userId);
1354
+ CLERK_USER_CACHE.set(userId, user);
1355
+ return user;
1356
+ } finally {
1357
+ INFLIGHT_CLERK_USER.delete(userId);
1358
+ }
1359
+ })();
1360
+ INFLIGHT_CLERK_USER.set(userId, p);
1361
+ return p;
1362
+ };
1363
+
1364
+ fastify.addHook('onRequest', async (req) => { req._startedAt = Date.now(); });
1365
+
1366
+ const authenticate = async (req: FastifyRequest, _reply: FastifyReply) => {
1367
+ if (req.method === 'OPTIONS' || req.url.startsWith('/health')) return;
1368
+
1369
+ const authHeader = req.headers.authorization;
1370
+ if (!authHeader?.startsWith('Bearer ')) throw AuthError.unauthorized();
1371
+
1372
+ const jwtToken = authHeader.slice('Bearer '.length).trim();
1373
+ if (!jwtToken) throw AuthError.unauthorized();
1374
+
1375
+ try {
1376
+ const auth = getAuth(req);
1377
+ if (!auth.userId) throw AuthError.unauthorized();
1378
+
1379
+ const userId = auth.userId as string;
1380
+ const clerkUser = await getClerkUser(userId);
1381
+ if (!clerkUser) throw AuthError.unauthorized();
1382
+
1383
+ let userData = await fastify.repos.users.getAuthDataForPlugin(userId);
1384
+
1385
+ if (!userData) {
1386
+ const primaryEmail = clerkUser.emailAddresses.find(
1387
+ (e: { id: string }) => e.id === clerkUser.primaryEmailAddressId
1388
+ );
1389
+ const displayName = [clerkUser.firstName, clerkUser.lastName]
1390
+ .filter(Boolean).join(' ').trim() || (primaryEmail?.emailAddress?.split('@')[0] ?? '');
1391
+
1392
+ await fastify.repos.users.createIfMissing({
1393
+ id: userId,
1394
+ email: primaryEmail?.emailAddress || '',
1395
+ displayName,
1396
+ profileImageUrl: clerkUser.imageUrl || null,
1397
+ });
1398
+
1399
+ await fastify.repos.subscriptions.createIfMissing({
1400
+ id: crypto.randomUUID(),
1401
+ userId,
1402
+ tier: 'free',
1403
+ status: 'active',
1404
+ });
1405
+
1406
+ userData = await fastify.repos.users.getAuthDataForPlugin(userId);
1407
+ } else {
1408
+ const sessionKey = auth.sessionId ?? userId;
1409
+ if (trackedSessions.get(sessionKey) === undefined) {
1410
+ trackedSessions.set(sessionKey, Date.now());
1411
+ runBackgroundTask(req, fastify.repos.users.updateLastLogin(userId), 'session_sync');
1412
+ }
1413
+ }
1414
+
1415
+ if (!userData) throw AuthError.unauthorized();
1416
+
1417
+ const userObj: AppUser = { id: userId };
1418
+ if (userData.subscriptionTier) userObj.subscriptionTier = userData.subscriptionTier;
1419
+ if (userData.subscriptionStatus) userObj.subscriptionStatus = userData.subscriptionStatus;
1420
+ req.user = userObj;
1421
+ } catch (err) {
1422
+ if (err instanceof AuthError) throw err;
1423
+ req.log.error({ msg: '[clerk-auth:error]', error: (err as Error).message });
1424
+ throw err;
1425
+ }
1426
+ };
1427
+
1428
+ fastify.decorate('authenticate', authenticate);
1429
+ };
1430
+
1431
+ export default fp(clerkAuthPlugin, { name: 'clerk-auth-plugin', fastify: '5.x' });
1432
+ `);
1433
+ }
1434
+ // ═══════════════════════════════════════════════════════════════════════════
1435
+ // Config
1436
+ // ═══════════════════════════════════════════════════════════════════════════
1437
+ function writeConfig(dir) {
1438
+ writeFile(path.join(dir, 'src/config/index.ts'), `import type { AppEnv } from '../types/index.js';
1439
+ import { ServerConfig } from './server.js';
1440
+ import { DatabaseConfig } from './database.js';
1441
+ import { CorsConfig } from './cors.js';
1442
+
1443
+ export class AppConfig {
1444
+ readonly server: ServerConfig;
1445
+ readonly database: DatabaseConfig;
1446
+ readonly cors: CorsConfig;
1447
+
1448
+ constructor(env: AppEnv) {
1449
+ this.server = new ServerConfig(env);
1450
+ this.database = new DatabaseConfig(env);
1451
+ this.cors = new CorsConfig(env);
1452
+ }
1453
+
1454
+ static fromEnv(env: AppEnv): AppConfig {
1455
+ return new AppConfig(env);
1456
+ }
1457
+ }
1458
+
1459
+ export { ServerConfig } from './server.js';
1460
+ export { DatabaseConfig } from './database.js';
1461
+ export { CorsConfig } from './cors.js';
1462
+ `);
1463
+ writeFile(path.join(dir, 'src/config/server.ts'), `import type { AppEnv } from '../types/index.js';
1464
+
1465
+ export class ServerConfig {
1466
+ readonly port: number;
1467
+ readonly host: string;
1468
+ readonly nodeEnv: string;
1469
+ readonly isDevelopment: boolean;
1470
+ readonly isProduction: boolean;
1471
+
1472
+ constructor(env: AppEnv) {
1473
+ this.port = env.PORT;
1474
+ this.host = '0.0.0.0';
1475
+ this.nodeEnv = env.NODE_ENV;
1476
+ this.isDevelopment = env.NODE_ENV === 'development';
1477
+ this.isProduction = env.NODE_ENV === 'production';
1478
+ }
1479
+ }
1480
+ `);
1481
+ writeFile(path.join(dir, 'src/config/database.ts'), `import type { AppEnv } from '../types/index.js';
1482
+
1483
+ export class DatabaseConfig {
1484
+ readonly url: string;
1485
+
1486
+ constructor(env: AppEnv) {
1487
+ if (!env.DATABASE_URL) throw new Error('DATABASE_URL is required');
1488
+ this.url = env.DATABASE_URL;
1489
+ }
1490
+ }
1491
+ `);
1492
+ writeFile(path.join(dir, 'src/config/cors.ts'), `import type { AppEnv } from '../types/index.js';
1493
+
1494
+ export class CorsConfig {
1495
+ readonly frontendUrl?: string;
1496
+ readonly extensionOrigins: string[];
1497
+ readonly restrictedOrigins: string[];
1498
+
1499
+ constructor(env: AppEnv) {
1500
+ if (env.FRONTEND_URL) this.frontendUrl = env.FRONTEND_URL;
1501
+ this.extensionOrigins = String(env.EXTENSION_ORIGINS || '')
1502
+ .split(',').map(o => o.trim()).filter(o => o.length > 0);
1503
+ this.restrictedOrigins = [
1504
+ ...new Set([
1505
+ ...(this.frontendUrl ? [this.frontendUrl] : []),
1506
+ ...this.extensionOrigins,
1507
+ ]),
1508
+ ];
1509
+ }
1510
+
1511
+ getRestrictedOrigins(): string[] | false {
1512
+ return this.restrictedOrigins.length > 0 ? this.restrictedOrigins : false;
1513
+ }
1514
+ }
1515
+ `);
1516
+ }
1517
+ // ═══════════════════════════════════════════════════════════════════════════
1518
+ // Repositories
1519
+ // ═══════════════════════════════════════════════════════════════════════════
1520
+ function writeRepositories(dir) {
1521
+ writeFile(path.join(dir, 'src/repositories/UserRepository.ts'), `import { eq } from 'drizzle-orm';
1522
+ import type { Database } from '../db/index.js';
1523
+ import { users } from '../db/schema.js';
1524
+ import type { NewUsers, SubscriptionStatus, SubscriptionTier } from '../db/schema.js';
1525
+ import { LRUCache } from '../lib/lruCache.js';
1526
+ import { subscriptions } from '../db/schema.js';
1527
+
1528
+ export type UserAuthData = {
1529
+ id: string;
1530
+ email: string;
1531
+ subscriptionTier?: SubscriptionTier | null;
1532
+ subscriptionStatus?: SubscriptionStatus | null;
1533
+ };
1534
+
1535
+ const authPluginCache = new LRUCache<UserAuthData>({
1536
+ maxSize: 50_000,
1537
+ ttlMs: 60_000,
1538
+ });
1539
+
1540
+ export function invalidateAuthPluginCache(userId: string): void {
1541
+ authPluginCache.invalidate(\`auth:\${userId}\`);
1542
+ }
1543
+
1544
+ export class UserRepository {
1545
+ constructor(private db: Database) {}
1546
+
1547
+ async getAuthDataForPlugin(userId: string): Promise<UserAuthData | null> {
1548
+ const cached = authPluginCache.get(\`auth:\${userId}\`);
1549
+ if (cached) return cached;
1550
+
1551
+ const rows = await this.db
1552
+ .select({
1553
+ id: users.id,
1554
+ email: users.email,
1555
+ subscriptionTier: subscriptions.tier,
1556
+ subscriptionStatus: subscriptions.status,
1557
+ })
1558
+ .from(users)
1559
+ .leftJoin(subscriptions, eq(users.id, subscriptions.userId))
1560
+ .where(eq(users.id, userId))
1561
+ .limit(1);
1562
+
1563
+ const row = rows[0] ?? null;
1564
+ if (row) authPluginCache.set(\`auth:\${userId}\`, row);
1565
+ return row;
1566
+ }
1567
+
1568
+ async findById(userId: string) {
1569
+ const rows = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
1570
+ return rows[0] ?? null;
1571
+ }
1572
+
1573
+ async findByEmail(email: string) {
1574
+ const rows = await this.db.select().from(users).where(eq(users.email, email)).limit(1);
1575
+ return rows[0] ?? null;
1576
+ }
1577
+
1578
+ async createIfMissing(data: NewUsers): Promise<{ created: boolean }> {
1579
+ const existing = await this.findById(data.id);
1580
+ if (existing) return { created: false };
1581
+ await this.db.insert(users).values(data).onConflictDoNothing();
1582
+ return { created: true };
1583
+ }
1584
+
1585
+ async updateAvatar(userId: string, input: {
1586
+ avatarObjectKey: string;
1587
+ avatarMimeType: string;
1588
+ }): Promise<void> {
1589
+ await this.db.update(users).set({
1590
+ avatarObjectKey: input.avatarObjectKey,
1591
+ avatarMimeType: input.avatarMimeType,
1592
+ }).where(eq(users.id, userId));
1593
+ authPluginCache.invalidate(\`auth:\${userId}\`);
1594
+ }
1595
+
1596
+ async getDashboardProfile(userId: string) {
1597
+ const rows = await this.db
1598
+ .select({
1599
+ id: users.id,
1600
+ email: users.email,
1601
+ displayName: users.displayName,
1602
+ profileImageUrl: users.profileImageUrl,
1603
+ avatarObjectKey: users.avatarObjectKey,
1604
+ avatarMimeType: users.avatarMimeType,
1605
+ creditsBalance: users.creditsBalance,
1606
+ subscriptionTier: subscriptions.tier,
1607
+ subscriptionStatus: subscriptions.status,
1608
+ })
1609
+ .from(users)
1610
+ .leftJoin(subscriptions, eq(users.id, subscriptions.userId))
1611
+ .where(eq(users.id, userId))
1612
+ .limit(1);
1613
+
1614
+ return rows[0] ?? null;
1615
+ }
1616
+
1617
+ async updateLastLogin(userId: string): Promise<void> {
1618
+ await this.db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId));
1619
+ authPluginCache.invalidate(\`auth:\${userId}\`);
1620
+ }
1621
+
1622
+ async deleteById(userId: string): Promise<void> {
1623
+ await this.db.delete(users).where(eq(users.id, userId));
1624
+ authPluginCache.invalidate(\`auth:\${userId}\`);
1625
+ }
1626
+ }
1627
+ `);
1628
+ writeFile(path.join(dir, 'src/repositories/SubscriptionRepository.ts'), `import crypto from 'node:crypto';
1629
+ import { eq } from 'drizzle-orm';
1630
+ import type { Database } from '../db/index.js';
1631
+ import { subscriptions } from '../db/schema.js';
1632
+ import type { Subscriptions, NewSubscriptions } from '../db/schema.js';
1633
+ import { LRUCache } from '../lib/lruCache.js';
1634
+ import { invalidateAuthPluginCache } from './UserRepository.js';
1635
+
1636
+ const subscriptionCache = new LRUCache<Subscriptions>({ maxSize: 50_000, ttlMs: 30_000 });
1637
+
1638
+ export class SubscriptionRepository {
1639
+ constructor(private db: Database) {}
1640
+
1641
+ async findByUserId(userId: string): Promise<Subscriptions | null> {
1642
+ const cached = subscriptionCache.get(\`sub:\${userId}\`);
1643
+ if (cached !== undefined) return cached;
1644
+ const [row] = await this.db.select().from(subscriptions)
1645
+ .where(eq(subscriptions.userId, userId)).limit(1);
1646
+ const result = row || null;
1647
+ if (result) subscriptionCache.set(\`sub:\${userId}\`, result);
1648
+ return result;
1649
+ }
1650
+
1651
+ async createIfMissing(data: NewSubscriptions): Promise<{ subscription: Subscriptions; created: boolean }> {
1652
+ try {
1653
+ const [inserted] = await this.db.insert(subscriptions).values(data)
1654
+ .onConflictDoNothing({ target: subscriptions.userId }).returning();
1655
+ if (inserted) {
1656
+ subscriptionCache.set(\`sub:\${inserted.userId}\`, inserted);
1657
+ return { subscription: inserted, created: true };
1658
+ }
1659
+ } catch {}
1660
+ const existing = await this.findByUserId(data.userId);
1661
+ if (existing) return { subscription: existing, created: false };
1662
+ throw new Error(\`Failed to create subscription for user \${data.userId}\`);
1663
+ }
1664
+
1665
+ async upsert(userId: string, data: Partial<Omit<NewSubscriptions, 'id' | 'userId'>>): Promise<Subscriptions> {
1666
+ const insertData: NewSubscriptions = {
1667
+ id: crypto.randomUUID(), userId,
1668
+ tier: data.tier ?? 'free',
1669
+ status: data.status ?? 'active',
1670
+ method: data.method ?? null,
1671
+ origin: data.origin ?? null,
1672
+ billingPeriod: data.billingPeriod ?? null,
1673
+ currentPeriodStart: data.currentPeriodStart ?? null,
1674
+ currentPeriodEnd: data.currentPeriodEnd ?? null,
1675
+ canceledAt: data.canceledAt ?? null,
1676
+ cancelAtPeriodEnd: data.cancelAtPeriodEnd ?? false,
1677
+ trialStart: data.trialStart ?? null,
1678
+ trialEnd: data.trialEnd ?? null,
1679
+ dailyLimit: data.dailyLimit ?? null,
1680
+ metadata: data.metadata ?? null,
1681
+ };
1682
+ const updateData = Object.fromEntries(
1683
+ Object.entries({ ...data, updatedAt: new Date() }).filter(([, v]) => v !== undefined)
1684
+ ) as Partial<Subscriptions>;
1685
+ const [row] = await this.db.insert(subscriptions).values(insertData)
1686
+ .onConflictDoUpdate({ target: subscriptions.userId, set: updateData }).returning();
1687
+ if (!row) throw new Error(\`Failed to upsert subscription for user \${userId}\`);
1688
+ subscriptionCache.set(\`sub:\${userId}\`, row);
1689
+ invalidateAuthPluginCache(userId);
1690
+ return row;
1691
+ }
1692
+
1693
+ async updateByUserId(userId: string, data: Partial<Subscriptions>): Promise<Subscriptions | null> {
1694
+ const [updated] = await this.db.update(subscriptions)
1695
+ .set({ ...data, updatedAt: new Date() })
1696
+ .where(eq(subscriptions.userId, userId)).returning();
1697
+ if (updated) subscriptionCache.set(\`sub:\${userId}\`, updated);
1698
+ else subscriptionCache.invalidate(\`sub:\${userId}\`);
1699
+ invalidateAuthPluginCache(userId);
1700
+ return updated || null;
1701
+ }
1702
+
1703
+ async getOrCreate(userId: string, options?: { allowTrial?: boolean }): Promise<Subscriptions> {
1704
+ const existing = await this.findByUserId(userId);
1705
+ if (existing) return existing;
1706
+ const allowTrial = options?.allowTrial ?? true;
1707
+ const now = new Date();
1708
+ const trialEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
1709
+ const { subscription } = await this.createIfMissing(allowTrial
1710
+ ? { id: crypto.randomUUID(), userId, tier: 'pro', status: 'active', method: 'trialing', trialStart: now, trialEnd }
1711
+ : { id: crypto.randomUUID(), userId, tier: 'free', status: 'active', method: null, trialStart: null, trialEnd: null }
1712
+ );
1713
+ return subscription;
1714
+ }
1715
+ }
1716
+ `);
1717
+ writeFile(path.join(dir, 'src/repositories/SubscriptionEventRepository.ts'), `import { eq, desc, and, inArray, sql } from 'drizzle-orm';
1718
+ import crypto from 'node:crypto';
1719
+ import type { Database } from '../db/index.js';
1720
+ import {
1721
+ subscriptionEvents,
1722
+ type SubscriptionEvents, type NewSubscriptionEvents,
1723
+ type SubscriptionEventType, type SubscriptionTier, type SubscriptionStatus,
1724
+ type SubscriptionMethod, type SubscriptionOrigin, type SubscriptionEventMetadata,
1725
+ } from '../db/schema.js';
1726
+
1727
+ export interface RecordEventParams {
1728
+ userId: string;
1729
+ subscriptionId?: string | null;
1730
+ eventType: SubscriptionEventType;
1731
+ previousState?: { tier?: SubscriptionTier | null; status?: SubscriptionStatus | null; method?: SubscriptionMethod | null };
1732
+ newState?: { tier?: SubscriptionTier | null; status?: SubscriptionStatus | null; method?: SubscriptionMethod | null };
1733
+ revenueCatEventType?: string;
1734
+ revenueCatProductId?: string;
1735
+ origin?: SubscriptionOrigin;
1736
+ periodStart?: Date | null;
1737
+ periodEnd?: Date | null;
1738
+ metadata?: SubscriptionEventMetadata;
1739
+ }
1740
+
1741
+ export class SubscriptionEventRepository {
1742
+ constructor(private db: Database) {}
1743
+
1744
+ async recordEvent(params: RecordEventParams): Promise<SubscriptionEvents> {
1745
+ const data: NewSubscriptionEvents = {
1746
+ id: crypto.randomUUID(),
1747
+ userId: params.userId,
1748
+ subscriptionId: params.subscriptionId ?? null,
1749
+ eventType: params.eventType,
1750
+ previousTier: params.previousState?.tier ?? null,
1751
+ previousStatus: params.previousState?.status ?? null,
1752
+ previousMethod: params.previousState?.method ?? null,
1753
+ newTier: params.newState?.tier ?? null,
1754
+ newStatus: params.newState?.status ?? null,
1755
+ newMethod: params.newState?.method ?? null,
1756
+ revenueCatEventType: params.revenueCatEventType ?? null,
1757
+ revenueCatProductId: params.revenueCatProductId ?? null,
1758
+ origin: params.origin ?? null,
1759
+ periodStart: params.periodStart ?? null,
1760
+ periodEnd: params.periodEnd ?? null,
1761
+ metadata: params.metadata ?? null,
1762
+ };
1763
+ const [event] = await this.db.insert(subscriptionEvents).values(data).returning();
1764
+ return event!;
1765
+ }
1766
+
1767
+ async getEventHistory(userId: string, opts: { limit?: number; offset?: number; eventTypes?: SubscriptionEventType[] } = {}): Promise<SubscriptionEvents[]> {
1768
+ const { limit = 50, offset = 0, eventTypes } = opts;
1769
+ const conditions = [eq(subscriptionEvents.userId, userId)];
1770
+ if (eventTypes?.length) conditions.push(inArray(subscriptionEvents.eventType, eventTypes));
1771
+ return this.db.select().from(subscriptionEvents)
1772
+ .where(and(...conditions))
1773
+ .orderBy(desc(subscriptionEvents.createdAt))
1774
+ .limit(limit).offset(offset);
1775
+ }
1776
+
1777
+ async getEventCountByType(userId: string): Promise<Record<string, number>> {
1778
+ const results = await this.db
1779
+ .select({ eventType: subscriptionEvents.eventType, count: sql<number>\`count(*)::int\` })
1780
+ .from(subscriptionEvents).where(eq(subscriptionEvents.userId, userId))
1781
+ .groupBy(subscriptionEvents.eventType);
1782
+ return results.reduce((acc, r) => { acc[r.eventType] = r.count; return acc; }, {} as Record<string, number>);
1783
+ }
1784
+ }
1785
+ `);
1786
+ writeFile(path.join(dir, 'src/repositories/PromoCodeRepository.ts'), `import { eq, and, lt, sql } from 'drizzle-orm';
1787
+ import crypto from 'node:crypto';
1788
+ import type { Database } from '../db/index.js';
1789
+ import { promoCodes, promoRedemptions } from '../db/schema.js';
1790
+ import type { PromoCodes } from '../db/schema.js';
1791
+
1792
+ export interface ValidateCodeResult {
1793
+ valid: boolean;
1794
+ error?: string;
1795
+ promoCode?: PromoCodes;
1796
+ }
1797
+
1798
+ export class PromoCodeRepository {
1799
+ constructor(private db: Database) {}
1800
+
1801
+ async validateCode(code: string, userId: string): Promise<ValidateCodeResult> {
1802
+ const [promoCode] = await this.db.select().from(promoCodes)
1803
+ .where(eq(promoCodes.code, code.toUpperCase())).limit(1);
1804
+ if (!promoCode) return { valid: false, error: 'invalid_code' };
1805
+
1806
+ const now = new Date();
1807
+ if (promoCode.expiresAt && promoCode.expiresAt < now) return { valid: false, error: 'code_expired' };
1808
+ if (promoCode.redeemedCount >= promoCode.maxRedemptions) return { valid: false, error: 'code_fully_used' };
1809
+
1810
+ const [existing] = await this.db.select().from(promoRedemptions)
1811
+ .where(and(eq(promoRedemptions.promoCodeId, promoCode.id), eq(promoRedemptions.userId, userId))).limit(1);
1812
+ if (existing) return { valid: false, error: 'already_redeemed' };
1813
+
1814
+ return { valid: true, promoCode };
1815
+ }
1816
+
1817
+ async recordRedemption(promoCodeId: string, userId: string, entitlementExpiresAt: Date): Promise<void> {
1818
+ await this.db.insert(promoRedemptions).values({
1819
+ id: crypto.randomUUID(), promoCodeId, userId, entitlementExpiresAt,
1820
+ });
1821
+ await this.db.update(promoCodes)
1822
+ .set({ redeemedCount: sql\`\${promoCodes.redeemedCount} + 1\` })
1823
+ .where(eq(promoCodes.id, promoCodeId));
1824
+ }
1825
+
1826
+ async findExpiredRedemptions(userId: string): Promise<typeof promoRedemptions.$inferSelect[]> {
1827
+ return this.db.select().from(promoRedemptions)
1828
+ .where(and(eq(promoRedemptions.userId, userId), lt(promoRedemptions.entitlementExpiresAt, new Date())));
1829
+ }
1830
+ }
1831
+ `);
1832
+ writeFile(path.join(dir, 'src/repositories/index.ts'), `import type { Database } from '../db/index.js';
1833
+ import { UserRepository } from './UserRepository.js';
1834
+ import { SubscriptionRepository } from './SubscriptionRepository.js';
1835
+ import { SubscriptionEventRepository } from './SubscriptionEventRepository.js';
1836
+ import { PromoCodeRepository } from './PromoCodeRepository.js';
1837
+
1838
+ export class Repositories {
1839
+ public readonly users: UserRepository;
1840
+ public readonly subscriptions: SubscriptionRepository;
1841
+ public readonly subscriptionEvents: SubscriptionEventRepository;
1842
+ public readonly promoCodes: PromoCodeRepository;
1843
+
1844
+ constructor(db: Database) {
1845
+ this.users = new UserRepository(db);
1846
+ this.subscriptions = new SubscriptionRepository(db);
1847
+ this.subscriptionEvents = new SubscriptionEventRepository(db);
1848
+ this.promoCodes = new PromoCodeRepository(db);
1849
+ }
1850
+ }
1851
+
1852
+ export { UserRepository } from './UserRepository.js';
1853
+ export { SubscriptionRepository } from './SubscriptionRepository.js';
1854
+ export { SubscriptionEventRepository } from './SubscriptionEventRepository.js';
1855
+ export { PromoCodeRepository } from './PromoCodeRepository.js';
1856
+ export type { UserAuthData } from './UserRepository.js';
1857
+ export type { RecordEventParams } from './SubscriptionEventRepository.js';
1858
+ `);
1859
+ }
1860
+ // ═══════════════════════════════════════════════════════════════════════════
1861
+ // Routes
1862
+ // ═══════════════════════════════════════════════════════════════════════════
1863
+ function writeRoutes(dir, displayName) {
1864
+ writeFile(path.join(dir, 'src/routes/health.ts'), `import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
1865
+
1866
+ const healthRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1867
+ fastify.get('', async (_req, reply) => {
1868
+ return reply.send({
1869
+ ok: true,
1870
+ timestamp: new Date().toISOString(),
1871
+ uptime: process.uptime(),
1872
+ });
1873
+ });
1874
+
1875
+ fastify.get('/ready', async (_req, reply) => {
1876
+ try {
1877
+ await fastify.repos.users.findByEmail('health-check@test.invalid');
1878
+ return reply.send({ ok: true, database: 'connected' });
1879
+ } catch {
1880
+ return reply.status(503).send({ ok: false, error: 'Database connection failed' });
1881
+ }
1882
+ });
1883
+ };
1884
+
1885
+ export default healthRoutes;
1886
+ `);
1887
+ writeFile(path.join(dir, 'src/routes/auth.ts'), `import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
1888
+ import { clerkClient } from '@clerk/fastify';
1889
+ import { AuthError } from '../errors/index.js';
1890
+ import { createAuthHook } from '../lib/routeHelpers.js';
1891
+ import { createSignedAvatarUrl, isR2Configured, uploadAvatarToR2 } from '../lib/r2.js';
1892
+
1893
+ function getDisplayName(input: {
1894
+ firstName?: string | null;
1895
+ lastName?: string | null;
1896
+ email?: string | null;
1897
+ fallback?: string | null;
1898
+ }) {
1899
+ return [input.firstName, input.lastName].filter(Boolean).join(' ').trim()
1900
+ || input.fallback
1901
+ || (input.email ? input.email.split('@')[0] : 'Builder');
1902
+ }
1903
+
1904
+ async function resolveAvatarUrl(input: {
1905
+ avatarObjectKey?: string | null;
1906
+ fallbackUrl?: string | null;
1907
+ }) {
1908
+ if (input.avatarObjectKey && isR2Configured()) {
1909
+ try {
1910
+ return await createSignedAvatarUrl(input.avatarObjectKey);
1911
+ } catch {
1912
+ return input.fallbackUrl ?? null;
1913
+ }
1914
+ }
1915
+
1916
+ return input.fallbackUrl ?? null;
1917
+ }
1918
+
1919
+ function sanitizeFileName(fileName: string) {
1920
+ return fileName
1921
+ .toLowerCase()
1922
+ .replace(/[^a-z0-9.-]+/g, '-')
1923
+ .replace(/^-+|-+$/g, '');
1924
+ }
1925
+
1926
+ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1927
+ fastify.get('/me', {
1928
+ preHandler: createAuthHook(fastify),
1929
+ }, async (req: FastifyRequest, reply: FastifyReply) => {
1930
+ if (!req.user?.id) throw AuthError.unauthorized();
1931
+
1932
+ const clerkUser = await clerkClient.users.getUser(req.user.id);
1933
+ const userData = await fastify.repos.users.getDashboardProfile(req.user.id);
1934
+ const primaryEmail = clerkUser.emailAddresses.find(
1935
+ (e: { id: string }) => e.id === clerkUser.primaryEmailAddressId
1936
+ );
1937
+ const email = primaryEmail?.emailAddress ?? '';
1938
+ const displayName = getDisplayName({
1939
+ firstName: clerkUser.firstName,
1940
+ lastName: clerkUser.lastName,
1941
+ email,
1942
+ fallback: userData?.displayName ?? null,
1943
+ });
1944
+ const avatarUrl = await resolveAvatarUrl({
1945
+ avatarObjectKey: userData?.avatarObjectKey ?? null,
1946
+ fallbackUrl: userData?.profileImageUrl ?? clerkUser.imageUrl ?? null,
1947
+ });
1948
+
1949
+ return reply.send({
1950
+ user: {
1951
+ id: clerkUser.id,
1952
+ email,
1953
+ firstName: clerkUser.firstName,
1954
+ lastName: clerkUser.lastName,
1955
+ displayName,
1956
+ imageUrl: userData?.profileImageUrl ?? clerkUser.imageUrl,
1957
+ avatarUrl,
1958
+ creditsBalance: userData?.creditsBalance ?? 250,
1959
+ subscriptionTier: userData?.subscriptionTier || 'free',
1960
+ subscriptionStatus: userData?.subscriptionStatus || 'active',
1961
+ },
1962
+ });
1963
+ });
1964
+
1965
+ fastify.post('/signout', {
1966
+ preHandler: createAuthHook(fastify),
1967
+ }, async (req: FastifyRequest, reply: FastifyReply) => {
1968
+ if (!req.user?.id) throw AuthError.unauthorized();
1969
+ return reply.send({ success: true });
1970
+ });
1971
+
1972
+ fastify.post<{ Body: { base64: string; mimeType: string; fileName?: string } }>('/avatar', {
1973
+ preHandler: createAuthHook(fastify),
1974
+ }, async (req, reply) => {
1975
+ if (!req.user?.id) throw AuthError.unauthorized();
1976
+ if (!isR2Configured()) {
1977
+ return reply.code(500).send({ error: 'r2_not_configured', message: 'Configure Cloudflare R2 before uploading avatars.' });
1978
+ }
1979
+
1980
+ const { base64, mimeType, fileName } = req.body;
1981
+ if (!base64 || !mimeType) {
1982
+ return reply.code(400).send({ error: 'invalid_payload', message: 'Avatar payload is incomplete.' });
1983
+ }
1984
+ if (!mimeType.startsWith('image/')) {
1985
+ return reply.code(400).send({ error: 'invalid_mime_type', message: 'Only image uploads are supported.' });
1986
+ }
1987
+
1988
+ const normalizedBase64 = base64.includes(',') ? (base64.split(',')[1] ?? '') : base64;
1989
+ const buffer = Buffer.from(normalizedBase64, 'base64');
1990
+ if (buffer.length === 0) {
1991
+ return reply.code(400).send({ error: 'invalid_payload', message: 'Avatar payload is empty.' });
1992
+ }
1993
+ if (buffer.length > 5 * 1024 * 1024) {
1994
+ return reply.code(400).send({ error: 'file_too_large', message: 'Avatar uploads are capped at 5MB.' });
1995
+ }
1996
+
1997
+ const safeName = sanitizeFileName(fileName || 'avatar');
1998
+ const objectKey = \`avatars/\${req.user.id}/\${Date.now()}-\${safeName || 'avatar'}\`;
1999
+ await uploadAvatarToR2({
2000
+ objectKey,
2001
+ body: buffer,
2002
+ contentType: mimeType,
2003
+ });
2004
+
2005
+ await fastify.repos.users.updateAvatar(req.user.id, {
2006
+ avatarObjectKey: objectKey,
2007
+ avatarMimeType: mimeType,
2008
+ });
2009
+
2010
+ const avatarUrl = await createSignedAvatarUrl(objectKey);
2011
+ return reply.send({ ok: true, avatarUrl });
2012
+ });
2013
+
2014
+ fastify.delete('/account', {
2015
+ preHandler: createAuthHook(fastify),
2016
+ }, async (req: FastifyRequest, reply: FastifyReply) => {
2017
+ if (!req.user?.id) throw AuthError.unauthorized();
2018
+ await clerkClient.users.deleteUser(req.user.id);
2019
+ await fastify.repos.users.deleteById(req.user.id);
2020
+ return reply.code(204).send();
2021
+ });
2022
+ };
2023
+
2024
+ export default authRoutes;
2025
+ `);
2026
+ writeFile(path.join(dir, 'src/routes/subscriptions.ts'), `import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
2027
+ import clerkAuthPlugin from '../plugins/clerkAuth.js';
2028
+ import { SubscriptionService } from '../services/SubscriptionService.js';
2029
+ import { AuthError } from '../errors/index.js';
2030
+ import { verifyRevenueCatWebhook, verifyRevenueCatWebhookAuthorization, grantPromotionalEntitlement } from '../lib/revenuecat.js';
2031
+ import { createAuthHook, runBackgroundTask } from '../lib/routeHelpers.js';
2032
+ import { CACHE_SECONDS } from '../lib/constants.js';
2033
+
2034
+ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
2035
+ const service = new SubscriptionService(fastify);
2036
+
2037
+ fastify.get('/pricing', async (_req, reply) => {
2038
+ const data = await service.getPricing();
2039
+ reply.header('Cache-Control', \`private, max-age=\${CACHE_SECONDS.SUBSCRIPTIONS_PRICING}\`);
2040
+ return reply.send(data);
2041
+ });
2042
+
2043
+ await fastify.register(async (app) => {
2044
+ await app.register(clerkAuthPlugin);
2045
+
2046
+ app.get<{ Querystring: { refresh?: boolean } }>('/entitlements/me', {
2047
+ preHandler: createAuthHook(app),
2048
+ }, async (req, reply) => {
2049
+ const userId = req.user?.id;
2050
+ if (!userId) throw AuthError.unauthorized();
2051
+ const refreshRaw = (req.query as unknown as { refresh?: unknown }).refresh;
2052
+ const requestedRefresh = refreshRaw === true || refreshRaw === 'true' || refreshRaw === 1 || refreshRaw === '1';
2053
+ const forceRefresh = Boolean(app.env.RC_SECRET_API_KEY) && requestedRefresh;
2054
+ const data = await service.getEntitlementWithTrial(userId, { refreshRevenueCat: forceRefresh }, req);
2055
+ reply.header('Cache-Control', 'private, no-store');
2056
+ return reply.send(data);
2057
+ });
2058
+
2059
+ app.post<{ Body: { code: string } }>('/redeem', {
2060
+ preHandler: createAuthHook(app),
2061
+ }, async (req, reply) => {
2062
+ const userId = req.user?.id;
2063
+ if (!userId) throw AuthError.unauthorized();
2064
+ const { code } = req.body;
2065
+ const validation = await app.repos.promoCodes.validateCode(code, userId);
2066
+ if (!validation.valid || !validation.promoCode) {
2067
+ const msgs: Record<string, string> = { invalid_code: 'This code is not valid.', code_expired: 'This code has expired.', code_fully_used: 'This code has already been used.', already_redeemed: 'You have already redeemed this code.' };
2068
+ return reply.code(400).send({ error: validation.error, message: msgs[validation.error!] || 'Invalid code.' });
2069
+ }
2070
+ const rcKey = app.env.RC_SECRET_API_KEY;
2071
+ if (!rcKey) return reply.code(500).send({ error: 'configuration_error', message: 'Subscription service not configured.' });
2072
+
2073
+ const { promoCode } = validation;
2074
+ const entitlementId = app.env.RC_ENTITLEMENT_ID || 'pro';
2075
+ try {
2076
+ const currentSub = await app.repos.subscriptions.findByUserId(userId);
2077
+ const result = await grantPromotionalEntitlement(userId, entitlementId, promoCode.months, rcKey);
2078
+ await app.repos.promoCodes.recordRedemption(promoCode.id, userId, result.expiresAt);
2079
+ const now = new Date();
2080
+ await app.repos.subscriptions.upsert(userId, {
2081
+ tier: 'pro', status: 'active', method: 'redeemed',
2082
+ currentPeriodStart: now, currentPeriodEnd: result.expiresAt,
2083
+ trialStart: null, trialEnd: null,
2084
+ metadata: { redeemCode: promoCode.code, redeemCodeRedeemedAt: now.toISOString() },
2085
+ });
2086
+ runBackgroundTask(req, app.repos.subscriptionEvents.recordEvent({
2087
+ userId, subscriptionId: currentSub?.id, eventType: 'promo_redeemed',
2088
+ previousState: { tier: currentSub?.tier, status: currentSub?.status, method: currentSub?.method },
2089
+ newState: { tier: 'pro', status: 'active', method: 'redeemed' },
2090
+ periodStart: now, periodEnd: result.expiresAt,
2091
+ metadata: { promoCode: promoCode.code },
2092
+ }), 'promo_redeemed_event');
2093
+ return { ok: true, expiresAt: result.expiresAt.toISOString() };
2094
+ } catch (err) {
2095
+ app.log.error({ msg: '[redeem:failed]', userId, error: (err as Error).message });
2096
+ return reply.code(500).send({ error: 'redemption_failed', message: 'Failed to redeem code. Please try again.' });
2097
+ }
2098
+ });
2099
+ });
2100
+
2101
+ await fastify.register(async (app) => {
2102
+ app.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => {
2103
+ try {
2104
+ const raw = typeof body === 'string' ? body : body.toString();
2105
+ (req as unknown as { rawBody?: string }).rawBody = raw;
2106
+ done(null, raw.trim().length === 0 ? {} : JSON.parse(raw));
2107
+ } catch (err) { done(err as Error, undefined); }
2108
+ });
2109
+
2110
+ app.post('/webhooks/revenuecat', async (req: FastifyRequest, reply) => {
2111
+ const rawBody = (req as unknown as { rawBody?: string }).rawBody;
2112
+ const bodyString = rawBody ?? (typeof req.body === 'string' ? req.body : '');
2113
+ const signature = req.headers['x-revenuecat-signature'] as string | undefined;
2114
+ const authorization = req.headers.authorization as string | undefined;
2115
+ const webhookSecret = fastify.env.RC_WEBHOOK_SECRET;
2116
+
2117
+ if (!webhookSecret && fastify.env.NODE_ENV === 'production') {
2118
+ return reply.code(500).send({ ok: false, error: 'webhook_not_configured' });
2119
+ }
2120
+ if (webhookSecret) {
2121
+ if (!bodyString) return reply.code(400).send({ ok: false, error: 'invalid_body' });
2122
+ const verifiedByAuth = verifyRevenueCatWebhookAuthorization(authorization, webhookSecret);
2123
+ const verifiedBySig = signature ? verifyRevenueCatWebhook(bodyString, signature, webhookSecret) : false;
2124
+ if (!verifiedByAuth && !verifiedBySig) return reply.code(401).send({ ok: false, error: 'invalid_signature' });
2125
+ }
2126
+
2127
+ const parsed = req.body as Record<string, unknown>;
2128
+ const event = (parsed?.event && typeof parsed.event === 'object') ? (parsed.event as Record<string, unknown>) : parsed;
2129
+ try { await service.handleRevenueCatWebhook(event, req); } catch (err) {
2130
+ fastify.log.error({ msg: '[webhook:revenuecat_failed]', error: (err as Error).message });
2131
+ }
2132
+ return { ok: true };
2133
+ });
2134
+ });
2135
+
2136
+ };
2137
+
2138
+ export default subscriptionRoutes;
2139
+ `);
2140
+ writeFile(path.join(dir, 'src/routes/index.ts'), `import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
2141
+ import clerkAuthPlugin from '../plugins/clerkAuth.js';
2142
+ import health from './health.js';
2143
+ import auth from './auth.js';
2144
+ import subscriptions from './subscriptions.js';
2145
+ import cors from '@fastify/cors';
2146
+
2147
+ const routes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
2148
+ const FRONTEND_URL = fastify.env?.FRONTEND_URL;
2149
+ const extensionOrigins = String(fastify.env?.EXTENSION_ORIGINS ?? '')
2150
+ .split(',').map(o => o.trim()).filter(o => o.length > 0);
2151
+ const restrictedOrigins = [...new Set([
2152
+ ...(FRONTEND_URL ? [FRONTEND_URL] : []),
2153
+ ...extensionOrigins,
2154
+ ])];
2155
+ const restrictedCorsOrigin = restrictedOrigins.length > 0 ? restrictedOrigins : false;
2156
+
2157
+ await fastify.register(async (app) => {
2158
+ await app.register(cors, { origin: restrictedCorsOrigin, maxAge: 86_400, methods: ['GET', 'OPTIONS'], allowedHeaders: ['Content-Type'] });
2159
+ await app.register(health);
2160
+ }, { prefix: '/health' });
2161
+
2162
+ await fastify.register(async (app) => {
2163
+ await app.register(cors, { origin: restrictedCorsOrigin, maxAge: 86_400, methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Platform'] });
2164
+ await app.register(clerkAuthPlugin);
2165
+ await app.register(auth);
2166
+ }, { prefix: '/auth' });
2167
+
2168
+ await fastify.register(async (app) => {
2169
+ await app.register(cors, { origin: restrictedCorsOrigin, maxAge: 86_400, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Platform', 'X-RevenueCat-Signature'] });
2170
+ await app.register(subscriptions);
2171
+ }, { prefix: '/subscriptions' });
2172
+ };
2173
+
2174
+ export default routes;
2175
+ `);
2176
+ }
2177
+ // ═══════════════════════════════════════════════════════════════════════════
2178
+ // App + Index + Vercel entry
2179
+ // ═══════════════════════════════════════════════════════════════════════════
2180
+ function writeAppAndIndex(dir, displayName) {
2181
+ writeFile(path.join(dir, 'src/app.ts'), `import Fastify from 'fastify';
2182
+ import type { FastifyInstance } from 'fastify';
2183
+ import routes from './routes/index.js';
2184
+ import envConfig from './plugins/env.js';
2185
+ import helmet from '@fastify/helmet';
2186
+ import compress from '@fastify/compress';
2187
+ import rateLimit from '@fastify/rate-limit';
2188
+ import { errorHandler } from './errors/index.js';
2189
+ import { AppConfig } from './config/index.js';
2190
+ import { Repositories } from './repositories/index.js';
2191
+ import { db } from './db/index.js';
2192
+
2193
+ const isProduction = process.env.NODE_ENV === 'production';
2194
+
2195
+ function createBaseApp(): FastifyInstance {
2196
+ return Fastify({
2197
+ trustProxy: true,
2198
+ logger: {
2199
+ level: process.env.LOG_LEVEL ?? (isProduction ? 'warn' : 'info'),
2200
+ redact: ['req.headers.authorization'],
2201
+ ...(isProduction ? {} : {
2202
+ transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname' } },
2203
+ }),
2204
+ },
2205
+ bodyLimit: 12 * 1024 * 1024,
2206
+ disableRequestLogging: isProduction,
2207
+ });
2208
+ }
2209
+
2210
+ export async function buildApp(): Promise<FastifyInstance> {
2211
+ const app = createBaseApp();
2212
+
2213
+ if (!isProduction && !process.env.VERCEL) {
2214
+ await import('dotenv/config');
2215
+ }
2216
+
2217
+ await app.register(envConfig);
2218
+
2219
+ const config = AppConfig.fromEnv(app.env!);
2220
+ app.decorate('config', config);
2221
+
2222
+ const repos = new Repositories(db);
2223
+ app.decorate('repos', repos);
2224
+
2225
+ await app.register(compress, { global: true, threshold: 512, encodings: ['br', 'gzip', 'deflate'] });
2226
+ app.setErrorHandler(errorHandler);
2227
+
2228
+ const isDevelopment = app.env?.NODE_ENV === 'development';
2229
+
2230
+ await app.register(helmet, {
2231
+ contentSecurityPolicy: isDevelopment ? false : undefined,
2232
+ crossOriginEmbedderPolicy: false,
2233
+ });
2234
+
2235
+ await app.register(rateLimit, {
2236
+ max: 120,
2237
+ timeWindow: '1 minute',
2238
+ allowList: isDevelopment ? ['127.0.0.1'] : [],
2239
+ });
2240
+
2241
+ if (isDevelopment) {
2242
+ const [{ default: swagger }, { default: swaggerUI }] = await Promise.all([
2243
+ import('@fastify/swagger'),
2244
+ import('@fastify/swagger-ui'),
2245
+ ]);
2246
+ await app.register(swagger, {
2247
+ openapi: {
2248
+ info: { title: '${displayName} API', version: '1.0.0' },
2249
+ servers: [{ url: \`http://localhost:\${app.env?.PORT ?? 3000}\` }],
2250
+ components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } },
2251
+ },
2252
+ });
2253
+ await app.register(swaggerUI, { routePrefix: '/docs' });
2254
+ }
2255
+
2256
+ await app.register(routes);
2257
+
2258
+ return app;
2259
+ }
2260
+ `);
2261
+ writeFile(path.join(dir, 'src/index.ts'), `import { buildApp } from './app.js';
2262
+
2263
+ async function main() {
2264
+ const fastify = await buildApp();
2265
+ const PORT = fastify.env?.PORT ?? 0;
2266
+ if (!Number.isFinite(PORT) || Number(PORT) <= 0) {
2267
+ throw new Error(\`PORT must be set and be a positive number. Got: \${PORT}\`);
2268
+ }
2269
+ await fastify.listen({ port: Number(PORT), host: '0.0.0.0' });
2270
+ fastify.log.info({ msg: '[startup]', addr: \`http://0.0.0.0:\${PORT}\` });
2271
+ }
2272
+
2273
+ main().catch((err) => {
2274
+ console.error('Failed to start server', err);
2275
+ process.exit(1);
2276
+ });
2277
+ `);
2278
+ }
2279
+ function writeVercelEntry(dir) {
2280
+ writeFile(path.join(dir, 'api/index.ts'), `import type { VercelRequest, VercelResponse } from '@vercel/node';
2281
+ import type { FastifyInstance } from 'fastify';
2282
+ import { buildApp } from '../src/app.js';
2283
+
2284
+ let appReadyPromise: Promise<FastifyInstance> | undefined;
2285
+
2286
+ async function getApp(): Promise<FastifyInstance> {
2287
+ if (!appReadyPromise) {
2288
+ appReadyPromise = (async () => {
2289
+ const app = await buildApp();
2290
+ await app.ready();
2291
+ return app;
2292
+ })().catch((err) => {
2293
+ appReadyPromise = undefined;
2294
+ throw err;
2295
+ });
2296
+ }
2297
+ return appReadyPromise;
2298
+ }
2299
+
2300
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
2301
+ const app = await getApp();
2302
+ app.server.emit('request', req, res);
2303
+ }
2304
+ `);
2305
+ }
2306
+ // ═══════════════════════════════════════════════════════════════════════════
2307
+ // Tests
2308
+ // ═══════════════════════════════════════════════════════════════════════════
2309
+ function writeTestFiles(dir) {
2310
+ writeFile(path.join(dir, 'test/setup.ts'), `import dotenv from 'dotenv';
2311
+ dotenv.config();
2312
+ `);
2313
+ writeFile(path.join(dir, 'test/health.test.ts'), `import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2314
+ import { buildApp } from '../src/app.js';
2315
+ import type { FastifyInstance } from 'fastify';
2316
+
2317
+ let app: FastifyInstance;
2318
+
2319
+ beforeAll(async () => {
2320
+ app = await buildApp();
2321
+ await app.ready();
2322
+ });
2323
+
2324
+ afterAll(async () => {
2325
+ await app.close();
2326
+ });
2327
+
2328
+ describe('Health Routes', () => {
2329
+ it('GET /health returns ok', async () => {
2330
+ const res = await app.inject({ method: 'GET', url: '/health' });
2331
+ expect(res.statusCode).toBe(200);
2332
+ const body = JSON.parse(res.payload);
2333
+ expect(body.ok).toBe(true);
2334
+ });
2335
+ });
2336
+ `);
2337
+ }