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