anhedral 0.4.2 → 0.6.1
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/README.md +35 -265
- package/assets/anhedral-cli-init-technical.svg +322 -0
- package/assets/anhedral-cli-init.svg +257 -173
- package/dist/bin.js +28 -191
- package/dist/dependencies.d.ts +7 -57
- package/dist/dependencies.js +96 -308
- package/dist/index.d.ts +2 -7
- package/dist/index.js +1 -4
- package/dist/platforms/desktop.js +401 -0
- package/dist/platforms/extension.js +401 -0
- package/dist/platforms/mobile.js +232 -0
- package/dist/project.d.ts +0 -26
- package/dist/standard-products.d.ts +2 -0
- package/dist/standard-products.js +6 -0
- package/dist/standard.d.ts +18 -0
- package/dist/standard.js +595 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/application-stack-standard.md +700 -0
- package/package.json +29 -51
- package/CHANGELOG.md +0 -178
- package/CONTRIBUTING.md +0 -77
- package/SECURITY.md +0 -37
- package/SKILL.md +0 -339
- package/assets/images/svg/ably-symbol-color.svg +0 -8
- package/bin/anhedral.js +0 -2
- package/dist/architecture/contributions.d.ts +0 -28
- package/dist/architecture/contributions.js +0 -125
- package/dist/architecture/index.d.ts +0 -12
- package/dist/architecture/index.js +0 -6
- package/dist/architecture/manifest.d.ts +0 -53
- package/dist/architecture/manifest.js +0 -283
- package/dist/architecture/modules.d.ts +0 -41
- package/dist/architecture/modules.js +0 -187
- package/dist/architecture/plan.d.ts +0 -58
- package/dist/architecture/plan.js +0 -145
- package/dist/architecture/templates.d.ts +0 -10
- package/dist/architecture/templates.js +0 -23
- package/dist/cli.d.ts +0 -32
- package/dist/cli.js +0 -281
- package/dist/composers/root.d.ts +0 -6
- package/dist/composers/root.js +0 -35
- package/dist/print.d.ts +0 -8
- package/dist/print.js +0 -48
- package/dist/prompts.d.ts +0 -6
- package/dist/prompts.js +0 -41
- package/dist/scaffold.d.ts +0 -64
- package/dist/scaffold.js +0 -2102
- package/dist/template-source.d.ts +0 -20
- package/dist/template-source.js +0 -180
- package/dist/templates/api.d.ts +0 -2
- package/dist/templates/api.js +0 -3187
- package/dist/templates/assets-private-proxy.d.ts +0 -3
- package/dist/templates/assets-private-proxy.js +0 -277
- package/dist/templates/desktop.js +0 -792
- package/dist/templates/electron-updater.d.ts +0 -3
- package/dist/templates/electron-updater.js +0 -358
- package/dist/templates/extension.js +0 -856
- package/dist/templates/first-run.d.ts +0 -2
- package/dist/templates/first-run.js +0 -167
- package/dist/templates/mobile.js +0 -861
- package/dist/templates/shared.d.ts +0 -2
- package/dist/templates/shared.js +0 -577
- package/dist/templates/web.d.ts +0 -2
- package/dist/templates/web.js +0 -569
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +0 -8
- package/dist/ui.d.ts +0 -33
- package/dist/ui.js +0 -199
- package/dist/workspace-config.d.ts +0 -11
- package/dist/workspace-config.js +0 -372
- package/docs/NORTHSTAR.md +0 -114
- package/docs/conventions.md +0 -94
- package/docs/engineering-principles.md +0 -88
- package/docs/master-stack-map.md +0 -199
- package/docs/output-tree-contract.md +0 -493
- package/docs/references/manual-scaffolding.md +0 -421
- package/docs/references/provisioning.md +0 -577
- package/scripts/render-master-stack-map.mjs +0 -510
- package/templates/api-fastify/apps/api/tsconfig.json +0 -20
- package/templates/catalog.json +0 -23
- package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
- package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
- package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
- package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
- package/templates/web-next/apps/web/next-env.d.ts +0 -6
- /package/dist/{templates → platforms}/desktop.d.ts +0 -0
- /package/dist/{templates → platforms}/extension.d.ts +0 -0
- /package/dist/{templates → platforms}/mobile.d.ts +0 -0
package/dist/templates/api.js
DELETED
|
@@ -1,3187 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { anhedralPrint } from '../print.js';
|
|
3
|
-
import { appendGitignore, writeFile } from '../util.js';
|
|
4
|
-
import { childPackageName, jsString } from '../render.js';
|
|
5
|
-
import { BACKEND_DEPENDENCIES } from '../dependencies.js';
|
|
6
|
-
function pick(source, names) {
|
|
7
|
-
return Object.fromEntries(names.map((name) => [name, source[name]]).filter((entry) => Boolean(entry[1])));
|
|
8
|
-
}
|
|
9
|
-
function dependenciesFor(options) {
|
|
10
|
-
const dependencies = BACKEND_DEPENDENCIES.dependencies ?? {};
|
|
11
|
-
const devDependencies = BACKEND_DEPENDENCIES.devDependencies ?? {};
|
|
12
|
-
const selected = pick(dependencies, [
|
|
13
|
-
'@shared/contracts',
|
|
14
|
-
'fastify',
|
|
15
|
-
'@fastify/cors',
|
|
16
|
-
'@fastify/compress',
|
|
17
|
-
'@fastify/helmet',
|
|
18
|
-
'@fastify/rate-limit',
|
|
19
|
-
'zod',
|
|
20
|
-
...(options.features.database ? ['@shared/db', 'drizzle-orm'] : []),
|
|
21
|
-
...(options.features.auth ? ['@clerk/fastify'] : []),
|
|
22
|
-
...(options.features.billing ? ['ably'] : []),
|
|
23
|
-
...(options.features.storage ? ['@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'] : []),
|
|
24
|
-
]);
|
|
25
|
-
return {
|
|
26
|
-
dependencies: selected,
|
|
27
|
-
devDependencies: pick(devDependencies, ['typescript', 'tsx', '@types/node', 'vitest', '@vitest/coverage-v8']),
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
function corsOrigins(options) {
|
|
31
|
-
return [
|
|
32
|
-
options.apps.web ? 'http://localhost:3000' : null,
|
|
33
|
-
options.apps.mobile ? 'http://localhost:8081' : null,
|
|
34
|
-
options.apps.desktop ? 'http://127.0.0.1:5173' : null,
|
|
35
|
-
options.apps.desktop ? 'null' : null,
|
|
36
|
-
].filter((value) => value !== null);
|
|
37
|
-
}
|
|
38
|
-
function envSource(options) {
|
|
39
|
-
const defaultCorsOrigins = corsOrigins(options);
|
|
40
|
-
const hasSemanticProviderValidation = options.features.database || options.features.auth || options.features.storage;
|
|
41
|
-
const fields = [
|
|
42
|
-
"NODE_ENV: z.enum(['development', 'test', 'production']).default('development')",
|
|
43
|
-
"PORT: z.coerce.number().int().positive().default(8787)",
|
|
44
|
-
'TRUST_PROXY_HOPS: z.coerce.number().int().min(0).max(16).default(0)',
|
|
45
|
-
'CORS_ORIGINS: CorsOriginsSchema',
|
|
46
|
-
"ANHEDRAL_DEMO: z.enum(['true', 'false']).default('false')",
|
|
47
|
-
options.features.database ? 'DATABASE_URL: z.string().min(1)' : null,
|
|
48
|
-
options.features.auth ? 'CLERK_PUBLISHABLE_KEY: z.string().optional()' : null,
|
|
49
|
-
options.features.auth ? 'CLERK_SECRET_KEY: z.string().optional()' : null,
|
|
50
|
-
options.features.billing ? 'RC_WEBHOOK_SECRET: OptionalSecretSchema' : null,
|
|
51
|
-
options.features.billing ? 'RC_SECRET_API_KEY: OptionalSecretSchema' : null,
|
|
52
|
-
options.features.billing ? "RC_ENTITLEMENT_ID: z.string().default('pro')" : null,
|
|
53
|
-
options.features.billing ? 'ABLY_API_KEY: OptionalSecretSchema' : null,
|
|
54
|
-
options.features.storage ? 'R2_ACCOUNT_ID: z.string().optional()' : null,
|
|
55
|
-
options.features.storage ? 'R2_ACCESS_KEY_ID: z.string().optional()' : null,
|
|
56
|
-
options.features.storage ? 'R2_SECRET_ACCESS_KEY: z.string().optional()' : null,
|
|
57
|
-
options.features.storage ? 'BASE_URL: OptionalUrlSchema' : null,
|
|
58
|
-
options.features.storage ? 'R2_BUCKET_NAME: z.string().optional()' : null,
|
|
59
|
-
options.features.storage ? "R2_PREFIX: z.string().trim().regex(/^[a-zA-Z0-9._-]+$/).default('storage')" : null,
|
|
60
|
-
options.features.storage ? 'R2_PROXY_READ_URL_TTL_SECONDS: z.coerce.number().int().min(60).max(604800).default(600)' : null,
|
|
61
|
-
options.features.storage ? 'CLOUDFLARE_API_TOKEN: z.string().optional()' : null,
|
|
62
|
-
options.features.billing || options.features.storage ? 'CRON_SECRET: OptionalSecretSchema' : null,
|
|
63
|
-
].filter((value) => value !== null);
|
|
64
|
-
const productionKeys = [...new Set([
|
|
65
|
-
...(options.features.auth ? ['CLERK_PUBLISHABLE_KEY', 'CLERK_SECRET_KEY'] : []),
|
|
66
|
-
...(options.features.billing ? ['RC_WEBHOOK_SECRET', 'RC_SECRET_API_KEY', 'ABLY_API_KEY', 'CRON_SECRET'] : []),
|
|
67
|
-
...(options.features.storage ? ['BASE_URL', 'R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET_NAME', 'CRON_SECRET'] : []),
|
|
68
|
-
])];
|
|
69
|
-
return `import { z } from 'zod';
|
|
70
|
-
|
|
71
|
-
${hasSemanticProviderValidation ? `function isPlaceholder(value: string): boolean {
|
|
72
|
-
const normalized = value.trim().toLowerCase();
|
|
73
|
-
return normalized.length === 0
|
|
74
|
-
|| normalized.includes('***')
|
|
75
|
-
|| /^(?:x|\\*)+$/.test(normalized)
|
|
76
|
-
|| /^(?:(?:change|replace)(?:[-_ ]?me)?|your|example|placeholder|test|secret|password|pass|username|user)(?:[-_ ].*)?$/.test(normalized);
|
|
77
|
-
}
|
|
78
|
-
` : ''}
|
|
79
|
-
${options.features.billing || options.features.storage ? `function hasLowSecretDiversity(value: string): boolean {
|
|
80
|
-
return new Set(value).size < 8 || /(.)\\1{15,}/.test(value);
|
|
81
|
-
}
|
|
82
|
-
` : ''}
|
|
83
|
-
${options.features.database ? `function assertProductionDatabaseUrl(value: string): void {
|
|
84
|
-
let url: URL;
|
|
85
|
-
try {
|
|
86
|
-
url = new URL(value);
|
|
87
|
-
} catch {
|
|
88
|
-
throw new Error('DATABASE_URL must be a valid PostgreSQL URL in production');
|
|
89
|
-
}
|
|
90
|
-
if (!['postgres:', 'postgresql:'].includes(url.protocol)) {
|
|
91
|
-
throw new Error('DATABASE_URL must use the postgres: or postgresql: protocol in production');
|
|
92
|
-
}
|
|
93
|
-
let username = url.username;
|
|
94
|
-
let password = url.password;
|
|
95
|
-
try {
|
|
96
|
-
username = decodeURIComponent(username);
|
|
97
|
-
password = decodeURIComponent(password);
|
|
98
|
-
} catch {
|
|
99
|
-
throw new Error('DATABASE_URL credentials must be valid URL-encoded values in production');
|
|
100
|
-
}
|
|
101
|
-
if (!username || !password || isPlaceholder(username) || isPlaceholder(password)) {
|
|
102
|
-
throw new Error('DATABASE_URL must contain non-placeholder username and password credentials in production');
|
|
103
|
-
}
|
|
104
|
-
const hostname = url.hostname.toLowerCase();
|
|
105
|
-
const localOrExampleHost = hostname === 'localhost'
|
|
106
|
-
|| hostname.endsWith('.localhost')
|
|
107
|
-
|| hostname === '127.0.0.1'
|
|
108
|
-
|| hostname.startsWith('127.')
|
|
109
|
-
|| hostname === '0.0.0.0'
|
|
110
|
-
|| hostname === '[::1]'
|
|
111
|
-
|| hostname === '::1'
|
|
112
|
-
|| hostname === 'example'
|
|
113
|
-
|| hostname === 'example.com'
|
|
114
|
-
|| hostname.startsWith('example.')
|
|
115
|
-
|| hostname.includes('.example.')
|
|
116
|
-
|| hostname.endsWith('.example')
|
|
117
|
-
|| hostname.endsWith('.example.com')
|
|
118
|
-
|| hostname.endsWith('.invalid')
|
|
119
|
-
|| hostname.endsWith('.test');
|
|
120
|
-
if (!hostname || isPlaceholder(hostname) || localOrExampleHost) {
|
|
121
|
-
throw new Error('DATABASE_URL must use a non-local, non-placeholder database host in production');
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
` : ''}
|
|
125
|
-
${options.features.auth ? `function assertProductionClerkKey(
|
|
126
|
-
key: 'CLERK_PUBLISHABLE_KEY' | 'CLERK_SECRET_KEY',
|
|
127
|
-
value: string | undefined,
|
|
128
|
-
prefix: 'pk_live_' | 'sk_live_',
|
|
129
|
-
): void {
|
|
130
|
-
const suffix = value?.startsWith(prefix) ? value.slice(prefix.length) : '';
|
|
131
|
-
if (!value || suffix.length < 20 || !/^[A-Za-z0-9_-]+={0,2}$/.test(suffix) || isPlaceholder(suffix)) {
|
|
132
|
-
throw new Error(\`\${key} must be a well-formed \${prefix} production key\`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
` : ''}
|
|
136
|
-
${options.features.storage ? `function assertProductionR2Configuration(env: AppEnv): void {
|
|
137
|
-
if (!env.BASE_URL || new URL(env.BASE_URL).protocol !== 'https:' || isPlaceholder(new URL(env.BASE_URL).hostname)) {
|
|
138
|
-
throw new Error('BASE_URL must be the canonical HTTPS application origin in production');
|
|
139
|
-
}
|
|
140
|
-
if (!env.R2_ACCOUNT_ID || !/^[a-f0-9]{32}$/i.test(env.R2_ACCOUNT_ID) || isPlaceholder(env.R2_ACCOUNT_ID)) {
|
|
141
|
-
throw new Error('R2_ACCOUNT_ID must be a 32-character Cloudflare account ID in production');
|
|
142
|
-
}
|
|
143
|
-
if (!env.R2_ACCESS_KEY_ID || !/^[a-f0-9]{32}$/i.test(env.R2_ACCESS_KEY_ID) || isPlaceholder(env.R2_ACCESS_KEY_ID)) {
|
|
144
|
-
throw new Error('R2_ACCESS_KEY_ID must be a 32-character R2 access key ID in production');
|
|
145
|
-
}
|
|
146
|
-
if (!env.R2_SECRET_ACCESS_KEY || !/^[a-f0-9]{64}$/i.test(env.R2_SECRET_ACCESS_KEY) || isPlaceholder(env.R2_SECRET_ACCESS_KEY)) {
|
|
147
|
-
throw new Error('R2_SECRET_ACCESS_KEY must be a 64-character R2 secret access key in production');
|
|
148
|
-
}
|
|
149
|
-
if (!env.R2_BUCKET_NAME || !/^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$/.test(env.R2_BUCKET_NAME) || isPlaceholder(env.R2_BUCKET_NAME)) {
|
|
150
|
-
throw new Error('R2_BUCKET_NAME must be 3-63 lowercase letters, numbers, or hyphens and start and end with an alphanumeric character');
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
` : ''}
|
|
154
|
-
${options.features.billing ? `const OptionalSecretSchema = z.preprocess(
|
|
155
|
-
(value) => typeof value === 'string' && value.trim() === '' ? undefined : value,
|
|
156
|
-
z.string().optional(),
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
function assertStrongBillingSecret(key: 'RC_WEBHOOK_SECRET' | 'RC_SECRET_API_KEY' | 'ABLY_API_KEY' | 'CRON_SECRET', value: string | undefined): void {
|
|
160
|
-
const normalized = value?.trim().toLowerCase() ?? '';
|
|
161
|
-
const placeholder = normalized.includes('***')
|
|
162
|
-
|| /^(?:change[-_ ]?me|replace[-_ ]?me|your[-_ ]|example|placeholder|test|secret|webhook[-_ ]?secret)/.test(normalized)
|
|
163
|
-
|| /^(?:x|\\*)+$/.test(normalized);
|
|
164
|
-
if (!value || value.length < 32 || placeholder || hasLowSecretDiversity(value)) {
|
|
165
|
-
throw new Error(\`\${key} must be at least 32 characters, non-placeholder, and sufficiently diverse in production\`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
` : ''}
|
|
169
|
-
${options.features.storage && !options.features.billing ? `const OptionalSecretSchema = z.preprocess(
|
|
170
|
-
(value) => typeof value === 'string' && value.trim() === '' ? undefined : value,
|
|
171
|
-
z.string().optional(),
|
|
172
|
-
);
|
|
173
|
-
` : ''}
|
|
174
|
-
${options.features.storage ? `const OptionalUrlSchema = z.preprocess(
|
|
175
|
-
(value) => typeof value === 'string' && value.trim() === '' ? undefined : value,
|
|
176
|
-
z.string().url().optional(),
|
|
177
|
-
);
|
|
178
|
-
` : ''}
|
|
179
|
-
${options.features.billing || options.features.storage ? `function assertStrongCronSecret(value: string | undefined): void {
|
|
180
|
-
if (!value || value.length < 32 || isPlaceholder(value) || hasLowSecretDiversity(value)) {
|
|
181
|
-
throw new Error('CRON_SECRET must be at least 32 characters, non-placeholder, and sufficiently diverse in production');
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
` : ''}
|
|
185
|
-
|
|
186
|
-
const CorsOriginsSchema = z.string().default(${JSON.stringify(defaultCorsOrigins.join(','))}).transform((value, context) => {
|
|
187
|
-
const origins = [...new Set(value.split(',').map((origin) => origin.trim()).filter(Boolean))];
|
|
188
|
-
for (const origin of origins) {
|
|
189
|
-
if (origin === 'null') continue;
|
|
190
|
-
try {
|
|
191
|
-
const url = new URL(origin);
|
|
192
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin) throw new Error('not an HTTP(S) origin');
|
|
193
|
-
} catch {
|
|
194
|
-
context.addIssue({ code: 'custom', message: \`Invalid CORS origin: \${origin}\` });
|
|
195
|
-
return z.NEVER;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return origins;
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const EnvSchema = z.object({
|
|
202
|
-
${fields.join(',\n ')},
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
export type AppEnv = z.infer<typeof EnvSchema>;
|
|
206
|
-
|
|
207
|
-
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|
208
|
-
const env = EnvSchema.parse(source);
|
|
209
|
-
if (env.NODE_ENV === 'production' && env.ANHEDRAL_DEMO === 'true') {
|
|
210
|
-
throw new Error('ANHEDRAL_DEMO must be false in production');
|
|
211
|
-
}
|
|
212
|
-
if (env.NODE_ENV === 'production') {
|
|
213
|
-
${defaultCorsOrigins.length > 0 ? `if (!source.CORS_ORIGINS?.trim() || env.CORS_ORIGINS.length === 0) {
|
|
214
|
-
throw new Error('Missing production environment variable: CORS_ORIGINS');
|
|
215
|
-
}` : ''}
|
|
216
|
-
for (const origin of env.CORS_ORIGINS) {
|
|
217
|
-
if (origin !== 'null' && new URL(origin).protocol !== 'https:') {
|
|
218
|
-
throw new Error('CORS_ORIGINS must contain only explicit HTTPS origins in production (literal null is allowed for desktop applications)');
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
${productionKeys.length > 0 ? `const productionKeys = ${JSON.stringify(productionKeys)} as const;
|
|
222
|
-
for (const key of productionKeys) {
|
|
223
|
-
if (!env[key]?.trim()) throw new Error(\`Missing production environment variable: \${key}\`);
|
|
224
|
-
}` : ''}
|
|
225
|
-
${options.features.database ? 'assertProductionDatabaseUrl(env.DATABASE_URL);' : ''}
|
|
226
|
-
${options.features.auth ? `assertProductionClerkKey('CLERK_PUBLISHABLE_KEY', env.CLERK_PUBLISHABLE_KEY, 'pk_live_');
|
|
227
|
-
assertProductionClerkKey('CLERK_SECRET_KEY', env.CLERK_SECRET_KEY, 'sk_live_');` : ''}
|
|
228
|
-
${options.features.billing ? `assertStrongBillingSecret('RC_WEBHOOK_SECRET', env.RC_WEBHOOK_SECRET);
|
|
229
|
-
assertStrongBillingSecret('RC_SECRET_API_KEY', env.RC_SECRET_API_KEY);
|
|
230
|
-
assertStrongBillingSecret('ABLY_API_KEY', env.ABLY_API_KEY);` : ''}
|
|
231
|
-
${options.features.billing || options.features.storage ? 'assertStrongCronSecret(env.CRON_SECRET);' : ''}
|
|
232
|
-
${options.features.storage ? 'assertProductionR2Configuration(env);' : ''}
|
|
233
|
-
}
|
|
234
|
-
return env;
|
|
235
|
-
}
|
|
236
|
-
`;
|
|
237
|
-
}
|
|
238
|
-
function envTestSource(options) {
|
|
239
|
-
const defaultCorsOrigins = corsOrigins(options);
|
|
240
|
-
const productionFields = [
|
|
241
|
-
"NODE_ENV: 'production'",
|
|
242
|
-
"ANHEDRAL_DEMO: 'false'",
|
|
243
|
-
defaultCorsOrigins.length > 0 ? "CORS_ORIGINS: 'https://console.acme.dev,null'" : null,
|
|
244
|
-
options.features.database
|
|
245
|
-
? "DATABASE_URL: syntheticDatabaseUrl('ep-bright-cloud-a1b2c3.us-east-2.aws.neon.tech')"
|
|
246
|
-
: null,
|
|
247
|
-
options.features.auth ? "CLERK_PUBLISHABLE_KEY: syntheticClerkKey('pk')" : null,
|
|
248
|
-
options.features.auth ? "CLERK_SECRET_KEY: syntheticClerkKey('sk')" : null,
|
|
249
|
-
options.features.billing ? "RC_WEBHOOK_SECRET: syntheticSecret('webhook')" : null,
|
|
250
|
-
options.features.billing ? "RC_SECRET_API_KEY: syntheticSecret('revenuecat')" : null,
|
|
251
|
-
options.features.billing ? "RC_ENTITLEMENT_ID: 'pro'" : null,
|
|
252
|
-
options.features.billing ? "ABLY_API_KEY: syntheticSecret('ably')" : null,
|
|
253
|
-
options.features.storage ? "R2_ACCOUNT_ID: 'a'.repeat(32)" : null,
|
|
254
|
-
options.features.storage ? "R2_ACCESS_KEY_ID: 'b'.repeat(32)" : null,
|
|
255
|
-
options.features.storage ? "R2_SECRET_ACCESS_KEY: 'c'.repeat(64)" : null,
|
|
256
|
-
options.features.storage ? "BASE_URL: 'https://app.acme.dev'" : null,
|
|
257
|
-
options.features.storage ? "R2_BUCKET_NAME: 'production-uploads'" : null,
|
|
258
|
-
options.features.storage ? "R2_PREFIX: 'storage'" : null,
|
|
259
|
-
options.features.storage ? "R2_PROXY_READ_URL_TTL_SECONDS: '600'" : null,
|
|
260
|
-
options.features.storage ? 'CLOUDFLARE_API_TOKEN: undefined' : null,
|
|
261
|
-
options.features.billing || options.features.storage ? "CRON_SECRET: syntheticSecret('cron')" : null,
|
|
262
|
-
].filter((value) => value !== null);
|
|
263
|
-
const cases = [
|
|
264
|
-
` it('rejects demo mode in production', () => {
|
|
265
|
-
expect(() => loadEnv({ ...validProductionEnv, ANHEDRAL_DEMO: 'true' })).toThrow(/ANHEDRAL_DEMO must be false/);
|
|
266
|
-
});`,
|
|
267
|
-
defaultCorsOrigins.length > 0 ? ` it('requires explicit HTTPS browser origins while retaining the desktop null origin', () => {
|
|
268
|
-
expect(() => loadEnv({ ...validProductionEnv, CORS_ORIGINS: undefined })).toThrow(/Missing production environment variable: CORS_ORIGINS/);
|
|
269
|
-
expect(() => loadEnv({ ...validProductionEnv, CORS_ORIGINS: 'http://app.acme.dev' })).toThrow(/only explicit HTTPS origins/);
|
|
270
|
-
expect(loadEnv({ ...validProductionEnv, CORS_ORIGINS: 'https://app.acme.dev,null' }).CORS_ORIGINS).toEqual([
|
|
271
|
-
'https://app.acme.dev',
|
|
272
|
-
'null',
|
|
273
|
-
]);
|
|
274
|
-
});` : ` it('still rejects insecure origins when an API-only deployment opts into browser CORS', () => {
|
|
275
|
-
expect(() => loadEnv({ ...validProductionEnv, CORS_ORIGINS: 'http://app.acme.dev' })).toThrow(/only explicit HTTPS origins/);
|
|
276
|
-
expect(loadEnv({ ...validProductionEnv, CORS_ORIGINS: 'https://app.acme.dev' }).CORS_ORIGINS).toEqual(['https://app.acme.dev']);
|
|
277
|
-
});`,
|
|
278
|
-
options.features.database ? ` it('requires a real PostgreSQL URL with non-placeholder credentials and host', () => {
|
|
279
|
-
expect(() => loadEnv({ ...validProductionEnv, DATABASE_URL: syntheticDatabaseUrl('db.acme.dev').replace('postgresql:', 'https:') })).toThrow(/postgres: or postgresql:/);
|
|
280
|
-
expect(() => loadEnv({ ...validProductionEnv, DATABASE_URL: syntheticDatabaseUrl('db.acme.dev', 'user') })).toThrow(/non-placeholder username and password/);
|
|
281
|
-
expect(() => loadEnv({ ...validProductionEnv, DATABASE_URL: syntheticDatabaseUrl('localhost') })).toThrow(/non-local, non-placeholder database host/);
|
|
282
|
-
expect(() => loadEnv({ ...validProductionEnv, DATABASE_URL: syntheticDatabaseUrl('db.example.com') })).toThrow(/non-local, non-placeholder database host/);
|
|
283
|
-
});` : null,
|
|
284
|
-
options.features.auth ? ` it('requires well-formed Clerk live keys', () => {
|
|
285
|
-
expect(() => loadEnv({ ...validProductionEnv, CLERK_PUBLISHABLE_KEY: syntheticClerkKey('pk', 'test') })).toThrow(/CLERK_PUBLISHABLE_KEY must be a well-formed pk_live_/);
|
|
286
|
-
expect(() => loadEnv({ ...validProductionEnv, CLERK_PUBLISHABLE_KEY: 'pk_live_example-placeholder-value' })).toThrow(/CLERK_PUBLISHABLE_KEY must be a well-formed pk_live_/);
|
|
287
|
-
expect(() => loadEnv({ ...validProductionEnv, CLERK_SECRET_KEY: syntheticClerkKey('sk', 'test') })).toThrow(/CLERK_SECRET_KEY must be a well-formed sk_live_/);
|
|
288
|
-
});` : null,
|
|
289
|
-
options.features.billing ? ` it('retains strong RevenueCat server-secret validation', () => {
|
|
290
|
-
expect(() => loadEnv({ ...validProductionEnv, RC_WEBHOOK_SECRET: 'webhook-secret' })).toThrow(/RC_WEBHOOK_SECRET must be at least 32 characters/);
|
|
291
|
-
expect(() => loadEnv({ ...validProductionEnv, RC_SECRET_API_KEY: '${'x'.repeat(40)}' })).toThrow(/RC_SECRET_API_KEY must be at least 32 characters/);
|
|
292
|
-
expect(() => loadEnv({ ...validProductionEnv, RC_WEBHOOK_SECRET: 'a'.repeat(40) })).toThrow(/sufficiently diverse/);
|
|
293
|
-
});` : null,
|
|
294
|
-
options.features.storage ? ` it('validates R2 credential and bucket formats plus cron-secret entropy', () => {
|
|
295
|
-
expect(() => loadEnv({ ...validProductionEnv, R2_ACCOUNT_ID: 'account' })).toThrow(/R2_ACCOUNT_ID must be a 32-character/);
|
|
296
|
-
expect(() => loadEnv({ ...validProductionEnv, R2_ACCESS_KEY_ID: 'access' })).toThrow(/R2_ACCESS_KEY_ID must be a 32-character/);
|
|
297
|
-
expect(() => loadEnv({ ...validProductionEnv, R2_SECRET_ACCESS_KEY: '${'c'.repeat(63)}' })).toThrow(/R2_SECRET_ACCESS_KEY must be a 64-character/);
|
|
298
|
-
expect(() => loadEnv({ ...validProductionEnv, BASE_URL: 'http://app.acme.dev' })).toThrow(/BASE_URL must be the canonical HTTPS/);
|
|
299
|
-
expect(() => loadEnv({ ...validProductionEnv, R2_BUCKET_NAME: 'Production_Uploads' })).toThrow(/R2_BUCKET_NAME must be 3-63/);
|
|
300
|
-
expect(() => loadEnv({ ...validProductionEnv, R2_PREFIX: 'nested/storage' })).toThrow();
|
|
301
|
-
expect(() => loadEnv({ ...validProductionEnv, R2_PROXY_READ_URL_TTL_SECONDS: '59' })).toThrow();
|
|
302
|
-
expect(() => loadEnv({ ...validProductionEnv, CRON_SECRET: 'change-me-${'x'.repeat(32)}' })).toThrow(/CRON_SECRET must be at least 32 characters/);
|
|
303
|
-
expect(() => loadEnv({ ...validProductionEnv, CRON_SECRET: 'a'.repeat(40) })).toThrow(/sufficiently diverse/);
|
|
304
|
-
});` : null,
|
|
305
|
-
options.features.storage ? ` it('treats a blank optional BASE_URL as unset outside production', () => {
|
|
306
|
-
expect(loadEnv({ ...validProductionEnv, NODE_ENV: 'test', BASE_URL: '' }).BASE_URL).toBeUndefined();
|
|
307
|
-
});` : null,
|
|
308
|
-
].filter((value) => value !== null);
|
|
309
|
-
return `import { describe, expect, it } from 'vitest';
|
|
310
|
-
import { loadEnv } from '../src/env';
|
|
311
|
-
|
|
312
|
-
${options.features.database ? `function syntheticDatabaseUrl(host: string, username = 'app_owner'): string {
|
|
313
|
-
return ['postgresql://', username, ':', 'S3cureRandomDatabaseCredential123', '@', host, '/app?sslmode=require'].join('');
|
|
314
|
-
}
|
|
315
|
-
` : ''}${options.features.auth ? `function syntheticClerkKey(kind: 'pk' | 'sk', environment: 'live' | 'test' = 'live'): string {
|
|
316
|
-
return [kind, environment, 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'].join('_');
|
|
317
|
-
}
|
|
318
|
-
` : ''}${options.features.billing || options.features.storage ? `function syntheticSecret(label: string): string {
|
|
319
|
-
return [label, 'A1b2C3d4E5f6G7h8', 'I9j0K1l2M3n4O5p6'].join('_');
|
|
320
|
-
}
|
|
321
|
-
` : ''}
|
|
322
|
-
const validProductionEnv = {
|
|
323
|
-
${productionFields.join(',\n ')},
|
|
324
|
-
} satisfies NodeJS.ProcessEnv;
|
|
325
|
-
|
|
326
|
-
describe('production environment validation', () => {
|
|
327
|
-
it('accepts a complete, well-shaped provider configuration', () => {
|
|
328
|
-
expect(() => loadEnv(validProductionEnv)).not.toThrow();
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
${cases.join('\n\n')}
|
|
332
|
-
});
|
|
333
|
-
`;
|
|
334
|
-
}
|
|
335
|
-
function authSource() {
|
|
336
|
-
return `import { clerkPlugin, getAuth } from '@clerk/fastify';
|
|
337
|
-
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
338
|
-
import type { AppEnv } from './env';
|
|
339
|
-
|
|
340
|
-
export async function registerAuth(app: FastifyInstance, env: AppEnv) {
|
|
341
|
-
if (env.ANHEDRAL_DEMO === 'true') return;
|
|
342
|
-
await app.register(clerkPlugin, {
|
|
343
|
-
publishableKey: env.CLERK_PUBLISHABLE_KEY,
|
|
344
|
-
secretKey: env.CLERK_SECRET_KEY,
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function authenticatedUserId(request: FastifyRequest, env: AppEnv): string {
|
|
349
|
-
if (env.ANHEDRAL_DEMO === 'true') return 'demo-user';
|
|
350
|
-
const userId = getAuth(request).userId;
|
|
351
|
-
if (!userId) {
|
|
352
|
-
const error = new Error('Authentication required') as Error & { statusCode: number };
|
|
353
|
-
error.statusCode = 401;
|
|
354
|
-
throw error;
|
|
355
|
-
}
|
|
356
|
-
return userId;
|
|
357
|
-
}
|
|
358
|
-
`;
|
|
359
|
-
}
|
|
360
|
-
function storageSource() {
|
|
361
|
-
return `import crypto from 'node:crypto';
|
|
362
|
-
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
363
|
-
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
364
|
-
import { and, asc, eq, gt, isNull, lte, or } from 'drizzle-orm';
|
|
365
|
-
import { db, sqlClient, uploads } from '@shared/db';
|
|
366
|
-
import type { CreateUploadRequest, UploadRecord as PublicUploadRecord } from '@shared/contracts';
|
|
367
|
-
import type { AppEnv } from './env';
|
|
368
|
-
|
|
369
|
-
export const UPLOAD_URL_EXPIRES_IN_SECONDS = 120;
|
|
370
|
-
export const MAX_PENDING_UPLOADS_PER_USER = 10;
|
|
371
|
-
export const MAX_PENDING_UPLOAD_BYTES_PER_USER = 50 * 1024 * 1024;
|
|
372
|
-
export const MAX_UPLOAD_URL_ISSUANCES_PER_USER_PER_HOUR = 60;
|
|
373
|
-
export const MAX_UPLOAD_CLEANUP_BATCH_SIZE = 50;
|
|
374
|
-
export const MAX_UPLOAD_CLEANUP_ITEMS_PER_RUN = 250;
|
|
375
|
-
export const MAX_UPLOAD_CLEANUP_RUN_MS = 8_000;
|
|
376
|
-
export const UPLOAD_CLEANUP_GRACE_MS = 10 * 60 * 1000;
|
|
377
|
-
|
|
378
|
-
type UploadRow = typeof uploads.$inferSelect;
|
|
379
|
-
|
|
380
|
-
export class StorageError extends Error {
|
|
381
|
-
constructor(
|
|
382
|
-
message: string,
|
|
383
|
-
readonly statusCode: number,
|
|
384
|
-
readonly code: string,
|
|
385
|
-
) {
|
|
386
|
-
super(message);
|
|
387
|
-
this.name = 'StorageError';
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
export function objectKeyForUser(userId: string, fileName: string, prefix = 'storage'): string {
|
|
392
|
-
const safeUser = userId.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
|
393
|
-
const safeFile = fileName.normalize('NFKD').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 120) || 'upload';
|
|
394
|
-
return \`\${prefix}/staging/\${safeUser}/\${crypto.randomUUID()}--\${safeFile}\`;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function confirmedObjectKeyForUpload(userId: string, uploadId: string, stagingObjectKey: string): string {
|
|
398
|
-
const safeUser = userId.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
|
399
|
-
const prefix = stagingObjectKey.split('/')[0] ?? 'storage';
|
|
400
|
-
const leaf = stagingObjectKey.split('/').at(-1) ?? 'upload';
|
|
401
|
-
const separator = leaf.indexOf('--');
|
|
402
|
-
const safeFile = separator >= 0 ? leaf.slice(separator + 2) : leaf;
|
|
403
|
-
// Confirmation validates only provider-reported size/type metadata. The object bytes remain
|
|
404
|
-
// untrusted application input and require content-specific validation before use.
|
|
405
|
-
return \`\${prefix}/confirmed/\${safeUser}/\${uploadId}--\${safeFile}\`;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function publicUpload(row: UploadRow, baseUrl: string): PublicUploadRecord {
|
|
409
|
-
const status = row.status === 'pending' || row.status === 'confirmed' || row.status === 'rejected'
|
|
410
|
-
? row.status
|
|
411
|
-
: 'rejected';
|
|
412
|
-
return {
|
|
413
|
-
id: row.id,
|
|
414
|
-
objectKey: row.objectKey ?? row.stagingObjectKey,
|
|
415
|
-
contentType: row.contentType as PublicUploadRecord['contentType'],
|
|
416
|
-
sizeBytes: row.actualSize ?? row.expectedSize,
|
|
417
|
-
status,
|
|
418
|
-
contentTrust: 'untrusted' as const,
|
|
419
|
-
createdAt: row.createdAt.toISOString(),
|
|
420
|
-
confirmedAt: row.confirmedAt?.toISOString() ?? null,
|
|
421
|
-
privateReadUrl: baseUrl.replace(/\\/+$/, '') + '/api/storage/uploads/' + encodeURIComponent(row.id) + '/read-url',
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
export interface UploadStore {
|
|
426
|
-
reserve(input: {
|
|
427
|
-
id: string;
|
|
428
|
-
userId: string;
|
|
429
|
-
stagingObjectKey: string;
|
|
430
|
-
contentType: string;
|
|
431
|
-
expectedSize: number;
|
|
432
|
-
uploadUrlExpiresAt: Date;
|
|
433
|
-
}): Promise<void>;
|
|
434
|
-
find(id: string, userId: string): Promise<UploadRow | null>;
|
|
435
|
-
confirm(id: string, userId: string, finalObjectKey: string, actualSize: number): Promise<UploadRow>;
|
|
436
|
-
reject(id: string, userId: string, actualSize: number | null, reason: string): Promise<void>;
|
|
437
|
-
listCleanupCandidates(
|
|
438
|
-
now: Date,
|
|
439
|
-
limit: number,
|
|
440
|
-
after?: { createdAt: Date; id: string },
|
|
441
|
-
): Promise<UploadRow[]>;
|
|
442
|
-
completeStagingCleanup(upload: UploadRow, completedAt: Date): Promise<void>;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export const uploadStore: UploadStore = {
|
|
446
|
-
async reserve(input) {
|
|
447
|
-
// Neon HTTP supports a one-shot transaction. The per-user advisory transaction lock makes
|
|
448
|
-
// expiration, issuance-rate enforcement, quota checks, and insertion atomic across API
|
|
449
|
-
// instances without a long-lived database session.
|
|
450
|
-
const transactionResults = await sqlClient.transaction((txn) => [
|
|
451
|
-
txn\`SELECT pg_advisory_xact_lock(hashtextextended(\${input.userId}, 0))\`,
|
|
452
|
-
txn\`UPDATE "uploads"
|
|
453
|
-
SET "status" = 'rejected', "rejection_reason" = 'upload_expired'
|
|
454
|
-
WHERE "user_id" = \${input.userId}
|
|
455
|
-
AND "status" = 'pending'
|
|
456
|
-
AND "upload_url_expires_at" <= now() - (\${UPLOAD_CLEANUP_GRACE_MS} * interval '1 millisecond')\`,
|
|
457
|
-
txn\`SELECT count(*)::integer AS "recentIssuances" FROM "uploads"
|
|
458
|
-
WHERE "user_id" = \${input.userId}
|
|
459
|
-
AND "created_at" >= now() - interval '1 hour'\`,
|
|
460
|
-
txn\`INSERT INTO "uploads" (
|
|
461
|
-
"id", "user_id", "staging_object_key", "content_type", "expected_size", "status", "upload_url_expires_at"
|
|
462
|
-
)
|
|
463
|
-
SELECT
|
|
464
|
-
\${input.id}, \${input.userId}, \${input.stagingObjectKey}, \${input.contentType},
|
|
465
|
-
\${input.expectedSize}, 'pending', \${input.uploadUrlExpiresAt}
|
|
466
|
-
WHERE (
|
|
467
|
-
SELECT count(*) FROM "uploads"
|
|
468
|
-
WHERE "user_id" = \${input.userId}
|
|
469
|
-
AND "status" = 'pending'
|
|
470
|
-
) < \${MAX_PENDING_UPLOADS_PER_USER}
|
|
471
|
-
AND (
|
|
472
|
-
SELECT coalesce(sum("expected_size"), 0) FROM "uploads"
|
|
473
|
-
WHERE "user_id" = \${input.userId}
|
|
474
|
-
AND "status" = 'pending'
|
|
475
|
-
) + \${input.expectedSize} <= \${MAX_PENDING_UPLOAD_BYTES_PER_USER}
|
|
476
|
-
AND (
|
|
477
|
-
SELECT count(*) FROM "uploads"
|
|
478
|
-
WHERE "user_id" = \${input.userId}
|
|
479
|
-
AND "created_at" >= now() - interval '1 hour'
|
|
480
|
-
) < \${MAX_UPLOAD_URL_ISSUANCES_PER_USER_PER_HOUR}
|
|
481
|
-
RETURNING "id"\`,
|
|
482
|
-
], { isolationLevel: 'ReadCommitted' });
|
|
483
|
-
const recentIssuances = Number((transactionResults[2] as Array<{ recentIssuances: number }> | undefined)?.[0]?.recentIssuances ?? 0);
|
|
484
|
-
const inserted = transactionResults[3] as Array<{ id: string }> | undefined;
|
|
485
|
-
if (!inserted?.[0]) {
|
|
486
|
-
if (recentIssuances >= MAX_UPLOAD_URL_ISSUANCES_PER_USER_PER_HOUR) {
|
|
487
|
-
throw new StorageError('Upload URL issuance rate exceeded', 429, 'upload_rate_exceeded');
|
|
488
|
-
}
|
|
489
|
-
throw new StorageError('Pending upload quota exceeded', 429, 'upload_quota_exceeded');
|
|
490
|
-
}
|
|
491
|
-
},
|
|
492
|
-
|
|
493
|
-
async find(id, userId) {
|
|
494
|
-
const [upload] = await db.select().from(uploads)
|
|
495
|
-
.where(and(eq(uploads.id, id), eq(uploads.userId, userId)))
|
|
496
|
-
.limit(1);
|
|
497
|
-
return upload ?? null;
|
|
498
|
-
},
|
|
499
|
-
|
|
500
|
-
async confirm(id, userId, finalObjectKey, actualSize) {
|
|
501
|
-
const [upload] = await db.update(uploads).set({
|
|
502
|
-
status: 'confirmed',
|
|
503
|
-
objectKey: finalObjectKey,
|
|
504
|
-
actualSize,
|
|
505
|
-
confirmedAt: new Date(),
|
|
506
|
-
rejectionReason: null,
|
|
507
|
-
}).where(and(
|
|
508
|
-
eq(uploads.id, id),
|
|
509
|
-
eq(uploads.userId, userId),
|
|
510
|
-
eq(uploads.status, 'pending'),
|
|
511
|
-
)).returning();
|
|
512
|
-
if (upload) return upload;
|
|
513
|
-
const existing = await this.find(id, userId);
|
|
514
|
-
if (existing?.status === 'confirmed' && existing.objectKey === finalObjectKey) return existing;
|
|
515
|
-
throw new StorageError('Upload state changed while it was being finalized', 409, 'upload_state_changed');
|
|
516
|
-
},
|
|
517
|
-
|
|
518
|
-
async reject(id, userId, actualSize, reason) {
|
|
519
|
-
await db.update(uploads).set({
|
|
520
|
-
status: 'rejected',
|
|
521
|
-
actualSize,
|
|
522
|
-
rejectionReason: reason.slice(0, 200),
|
|
523
|
-
}).where(and(
|
|
524
|
-
eq(uploads.id, id),
|
|
525
|
-
eq(uploads.userId, userId),
|
|
526
|
-
eq(uploads.status, 'pending'),
|
|
527
|
-
));
|
|
528
|
-
},
|
|
529
|
-
|
|
530
|
-
async listCleanupCandidates(now, limit, after) {
|
|
531
|
-
return db.select().from(uploads).where(and(
|
|
532
|
-
isNull(uploads.stagingDeletedAt),
|
|
533
|
-
lte(uploads.uploadUrlExpiresAt, now),
|
|
534
|
-
or(
|
|
535
|
-
eq(uploads.status, 'pending'),
|
|
536
|
-
eq(uploads.status, 'confirmed'),
|
|
537
|
-
eq(uploads.status, 'rejected'),
|
|
538
|
-
),
|
|
539
|
-
after ? or(
|
|
540
|
-
gt(uploads.createdAt, after.createdAt),
|
|
541
|
-
and(eq(uploads.createdAt, after.createdAt), gt(uploads.id, after.id)),
|
|
542
|
-
) : undefined,
|
|
543
|
-
)).orderBy(asc(uploads.createdAt), asc(uploads.id)).limit(limit);
|
|
544
|
-
},
|
|
545
|
-
|
|
546
|
-
async completeStagingCleanup(upload, completedAt) {
|
|
547
|
-
if (upload.status === 'pending') {
|
|
548
|
-
await db.update(uploads).set({
|
|
549
|
-
status: 'rejected',
|
|
550
|
-
rejectionReason: 'upload_expired',
|
|
551
|
-
stagingDeletedAt: completedAt,
|
|
552
|
-
}).where(and(
|
|
553
|
-
eq(uploads.id, upload.id),
|
|
554
|
-
eq(uploads.userId, upload.userId),
|
|
555
|
-
eq(uploads.status, 'pending'),
|
|
556
|
-
lte(uploads.uploadUrlExpiresAt, completedAt),
|
|
557
|
-
isNull(uploads.stagingDeletedAt),
|
|
558
|
-
));
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
await db.update(uploads).set({ stagingDeletedAt: completedAt }).where(and(
|
|
562
|
-
eq(uploads.id, upload.id),
|
|
563
|
-
eq(uploads.userId, upload.userId),
|
|
564
|
-
eq(uploads.status, upload.status),
|
|
565
|
-
isNull(uploads.stagingDeletedAt),
|
|
566
|
-
));
|
|
567
|
-
},
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
type ObjectMetadata = { sizeBytes: number; contentType: string | null; etag: string | null };
|
|
571
|
-
|
|
572
|
-
export interface ObjectGateway {
|
|
573
|
-
signPut(objectKey: string, contentType: string, contentLength: number, expiresIn: number): Promise<string>;
|
|
574
|
-
signGet?(objectKey: string, expiresIn: number): Promise<string>;
|
|
575
|
-
head(objectKey: string): Promise<ObjectMetadata | null>;
|
|
576
|
-
copy(sourceObjectKey: string, destinationObjectKey: string, sourceEtag: string): Promise<void>;
|
|
577
|
-
delete(objectKey: string): Promise<void>;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
let cachedClient: { key: string; client: S3Client } | null = null;
|
|
581
|
-
|
|
582
|
-
function storageConfiguration(env: AppEnv) {
|
|
583
|
-
if (!env.R2_ACCOUNT_ID || !env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.R2_BUCKET_NAME) {
|
|
584
|
-
throw new StorageError('R2 is not configured', 503, 'storage_not_configured');
|
|
585
|
-
}
|
|
586
|
-
return {
|
|
587
|
-
accountId: env.R2_ACCOUNT_ID,
|
|
588
|
-
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
589
|
-
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
590
|
-
bucket: env.R2_BUCKET_NAME,
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function clientFor(env: AppEnv): { client: S3Client; bucket: string } {
|
|
595
|
-
const config = storageConfiguration(env);
|
|
596
|
-
const key = \`\${config.accountId}:\${config.accessKeyId}:\${config.bucket}\`;
|
|
597
|
-
if (cachedClient?.key === key) return { client: cachedClient.client, bucket: config.bucket };
|
|
598
|
-
const client = new S3Client({
|
|
599
|
-
region: 'auto',
|
|
600
|
-
endpoint: \`https://\${config.accountId}.r2.cloudflarestorage.com\`,
|
|
601
|
-
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
|
|
602
|
-
});
|
|
603
|
-
cachedClient = { key, client };
|
|
604
|
-
return { client, bucket: config.bucket };
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function isMissingObject(error: unknown): boolean {
|
|
608
|
-
const candidate = error as { name?: unknown; $metadata?: { httpStatusCode?: unknown } };
|
|
609
|
-
return candidate?.name === 'NotFound'
|
|
610
|
-
|| candidate?.name === 'NoSuchKey'
|
|
611
|
-
|| candidate?.$metadata?.httpStatusCode === 404;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
export function createObjectGateway(env: AppEnv): ObjectGateway {
|
|
615
|
-
const { client, bucket } = clientFor(env);
|
|
616
|
-
return {
|
|
617
|
-
async signPut(objectKey, contentType, contentLength, expiresIn) {
|
|
618
|
-
return getSignedUrl(client, new PutObjectCommand({
|
|
619
|
-
Bucket: bucket,
|
|
620
|
-
Key: objectKey,
|
|
621
|
-
ContentType: contentType,
|
|
622
|
-
ContentLength: contentLength,
|
|
623
|
-
}), {
|
|
624
|
-
expiresIn,
|
|
625
|
-
// The SDK intentionally treats content-type as unsignable unless it is opted back in.
|
|
626
|
-
signableHeaders: new Set(['content-type', 'content-length']),
|
|
627
|
-
});
|
|
628
|
-
},
|
|
629
|
-
async signGet(objectKey, expiresIn) {
|
|
630
|
-
return getSignedUrl(client, new GetObjectCommand({
|
|
631
|
-
Bucket: bucket,
|
|
632
|
-
Key: objectKey,
|
|
633
|
-
}), { expiresIn });
|
|
634
|
-
},
|
|
635
|
-
async head(objectKey) {
|
|
636
|
-
try {
|
|
637
|
-
const result = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey }));
|
|
638
|
-
return {
|
|
639
|
-
sizeBytes: result.ContentLength ?? -1,
|
|
640
|
-
contentType: result.ContentType ?? null,
|
|
641
|
-
etag: result.ETag ?? null,
|
|
642
|
-
};
|
|
643
|
-
} catch (error) {
|
|
644
|
-
if (isMissingObject(error)) return null;
|
|
645
|
-
throw error;
|
|
646
|
-
}
|
|
647
|
-
},
|
|
648
|
-
async copy(sourceObjectKey, destinationObjectKey, sourceEtag) {
|
|
649
|
-
await client.send(new CopyObjectCommand({
|
|
650
|
-
Bucket: bucket,
|
|
651
|
-
Key: destinationObjectKey,
|
|
652
|
-
CopySource: \`\${bucket}/\${encodeURIComponent(sourceObjectKey)}\`,
|
|
653
|
-
CopySourceIfMatch: sourceEtag,
|
|
654
|
-
MetadataDirective: 'COPY',
|
|
655
|
-
}));
|
|
656
|
-
},
|
|
657
|
-
async delete(objectKey) {
|
|
658
|
-
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objectKey }));
|
|
659
|
-
},
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
export interface StorageService {
|
|
664
|
-
createUpload(userId: string, input: CreateUploadRequest): Promise<{
|
|
665
|
-
uploadId: string;
|
|
666
|
-
stagingObjectKey: string;
|
|
667
|
-
uploadUrl: string;
|
|
668
|
-
expiresIn: number;
|
|
669
|
-
requiredHeaders: { 'content-type': CreateUploadRequest['contentType'] };
|
|
670
|
-
sizeBytes: number;
|
|
671
|
-
signedContentLength: number;
|
|
672
|
-
metadataValidationRequired: true;
|
|
673
|
-
}>;
|
|
674
|
-
confirmUpload(userId: string, uploadId: string): Promise<{ upload: PublicUploadRecord }>;
|
|
675
|
-
getUpload(userId: string, uploadId: string): Promise<{ upload: PublicUploadRecord }>;
|
|
676
|
-
createReadUrl(userId: string, uploadId: string): Promise<{ url: string; expiresIn: number }>;
|
|
677
|
-
cleanupUploads(limit?: number): Promise<{
|
|
678
|
-
processed: number;
|
|
679
|
-
cleaned: number;
|
|
680
|
-
failed: number;
|
|
681
|
-
batches: number;
|
|
682
|
-
backlogRemaining: boolean;
|
|
683
|
-
oldestBacklogCreatedAt: string | null;
|
|
684
|
-
itemLimitReached: boolean;
|
|
685
|
-
timeLimitReached: boolean;
|
|
686
|
-
}>;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
class CleanupDeadlineExceeded extends Error {
|
|
690
|
-
constructor() {
|
|
691
|
-
super('Storage cleanup deadline exceeded');
|
|
692
|
-
this.name = 'CleanupDeadlineExceeded';
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async function beforeCleanupDeadline<T>(deadline: number, operation: () => Promise<T>): Promise<T> {
|
|
697
|
-
const remaining = deadline - Date.now();
|
|
698
|
-
if (remaining <= 0) throw new CleanupDeadlineExceeded();
|
|
699
|
-
let timer: NodeJS.Timeout | undefined;
|
|
700
|
-
const timeout = new Promise<never>((_, reject) => {
|
|
701
|
-
timer = setTimeout(() => reject(new CleanupDeadlineExceeded()), remaining);
|
|
702
|
-
timer.unref();
|
|
703
|
-
});
|
|
704
|
-
try {
|
|
705
|
-
return await Promise.race([Promise.resolve().then(operation), timeout]);
|
|
706
|
-
} finally {
|
|
707
|
-
if (timer) clearTimeout(timer);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
export function createStorageService(
|
|
712
|
-
env: AppEnv,
|
|
713
|
-
dependencies: { store?: UploadStore; objects?: ObjectGateway } = {},
|
|
714
|
-
): StorageService {
|
|
715
|
-
const store = dependencies.store ?? uploadStore;
|
|
716
|
-
const baseUrl = env.BASE_URL ?? 'http://localhost:8787';
|
|
717
|
-
const objectGateway = () => dependencies.objects ?? createObjectGateway(env);
|
|
718
|
-
const finishConfirmedUpload = async (upload: UploadRow, objects: ObjectGateway) => {
|
|
719
|
-
if (!upload.stagingDeletedAt) {
|
|
720
|
-
try {
|
|
721
|
-
await objects.delete(upload.stagingObjectKey);
|
|
722
|
-
const now = new Date();
|
|
723
|
-
if (now.getTime() >= upload.uploadUrlExpiresAt.getTime() + UPLOAD_CLEANUP_GRACE_MS) {
|
|
724
|
-
await store.completeStagingCleanup(upload, now);
|
|
725
|
-
}
|
|
726
|
-
} catch {
|
|
727
|
-
throw new StorageError('Upload is confirmed but staging cleanup must be retried', 503, 'upload_staging_cleanup_failed');
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
return { upload: publicUpload(upload, baseUrl) };
|
|
731
|
-
};
|
|
732
|
-
|
|
733
|
-
return {
|
|
734
|
-
async createUpload(userId, input) {
|
|
735
|
-
const uploadId = crypto.randomUUID();
|
|
736
|
-
const stagingObjectKey = objectKeyForUser(userId, input.fileName, env.R2_PREFIX);
|
|
737
|
-
const uploadUrlExpiresAt = new Date(Date.now() + UPLOAD_URL_EXPIRES_IN_SECONDS * 1000);
|
|
738
|
-
await store.reserve({
|
|
739
|
-
id: uploadId,
|
|
740
|
-
userId,
|
|
741
|
-
stagingObjectKey,
|
|
742
|
-
contentType: input.contentType,
|
|
743
|
-
expectedSize: input.sizeBytes,
|
|
744
|
-
uploadUrlExpiresAt,
|
|
745
|
-
});
|
|
746
|
-
try {
|
|
747
|
-
// Content-Length is part of the SigV4 signed headers. A Blob/File client emits the header
|
|
748
|
-
// automatically, so R2 rejects mismatched bodies before storing them. Confirmation checks
|
|
749
|
-
// provider-reported size/type metadata; it does not make uploaded bytes trustworthy.
|
|
750
|
-
const uploadUrl = await objectGateway().signPut(
|
|
751
|
-
stagingObjectKey,
|
|
752
|
-
input.contentType,
|
|
753
|
-
input.sizeBytes,
|
|
754
|
-
UPLOAD_URL_EXPIRES_IN_SECONDS,
|
|
755
|
-
);
|
|
756
|
-
return {
|
|
757
|
-
uploadId,
|
|
758
|
-
stagingObjectKey,
|
|
759
|
-
uploadUrl,
|
|
760
|
-
expiresIn: UPLOAD_URL_EXPIRES_IN_SECONDS,
|
|
761
|
-
requiredHeaders: { 'content-type': input.contentType },
|
|
762
|
-
sizeBytes: input.sizeBytes,
|
|
763
|
-
signedContentLength: input.sizeBytes,
|
|
764
|
-
metadataValidationRequired: true as const,
|
|
765
|
-
};
|
|
766
|
-
} catch (error) {
|
|
767
|
-
await store.reject(uploadId, userId, null, 'url_signing_failed').catch(() => undefined);
|
|
768
|
-
if (error instanceof StorageError) throw error;
|
|
769
|
-
throw new StorageError('Unable to create upload URL', 503, 'upload_url_failed');
|
|
770
|
-
}
|
|
771
|
-
},
|
|
772
|
-
|
|
773
|
-
async confirmUpload(userId, uploadId) {
|
|
774
|
-
const upload = await store.find(uploadId, userId);
|
|
775
|
-
if (!upload) throw new StorageError('Upload not found', 404, 'upload_not_found');
|
|
776
|
-
const objects = objectGateway();
|
|
777
|
-
if (upload.status === 'confirmed') {
|
|
778
|
-
return finishConfirmedUpload(upload, objects);
|
|
779
|
-
}
|
|
780
|
-
if (upload.status === 'rejected') {
|
|
781
|
-
throw new StorageError('Upload was rejected', 409, 'upload_rejected');
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
const metadata = await objects.head(upload.stagingObjectKey);
|
|
785
|
-
if (!metadata) throw new StorageError('Uploaded object is not available yet', 409, 'upload_not_available');
|
|
786
|
-
const actualContentType = metadata.contentType?.split(';', 1)[0]?.trim().toLowerCase() ?? null;
|
|
787
|
-
const expectedContentType = upload.contentType.toLowerCase();
|
|
788
|
-
if (metadata.sizeBytes !== upload.expectedSize || actualContentType !== expectedContentType) {
|
|
789
|
-
try {
|
|
790
|
-
await objects.delete(upload.stagingObjectKey);
|
|
791
|
-
} catch {
|
|
792
|
-
// Keep the row pending so this confirmation (or the bounded cleanup job after expiry)
|
|
793
|
-
// can retry deletion. A rejected row must never strand an undeletable staging object.
|
|
794
|
-
throw new StorageError('Invalid uploaded object cleanup must be retried', 503, 'upload_cleanup_failed');
|
|
795
|
-
}
|
|
796
|
-
await store.reject(
|
|
797
|
-
upload.id,
|
|
798
|
-
userId,
|
|
799
|
-
metadata.sizeBytes >= 0 ? metadata.sizeBytes : null,
|
|
800
|
-
metadata.sizeBytes !== upload.expectedSize ? 'size_mismatch' : 'content_type_mismatch',
|
|
801
|
-
);
|
|
802
|
-
throw new StorageError('Uploaded object metadata did not match the declared size and content type', 422, 'upload_metadata_mismatch');
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
if (!metadata.etag) {
|
|
806
|
-
throw new StorageError('Uploaded object did not provide an ETag for conditional promotion', 503, 'upload_etag_missing');
|
|
807
|
-
}
|
|
808
|
-
const finalObjectKey = confirmedObjectKeyForUpload(userId, upload.id, upload.stagingObjectKey);
|
|
809
|
-
try {
|
|
810
|
-
// Conditional copy closes the HEAD-to-copy race. The final key is never exposed by a PUT URL.
|
|
811
|
-
await objects.copy(upload.stagingObjectKey, finalObjectKey, metadata.etag);
|
|
812
|
-
} catch (error) {
|
|
813
|
-
// Another confirmer may have committed and removed staging after this request's HEAD.
|
|
814
|
-
// Re-reading the deterministic final key makes that race idempotent.
|
|
815
|
-
const current = await store.find(upload.id, userId).catch(() => null);
|
|
816
|
-
if (current?.status === 'confirmed' && current.objectKey === finalObjectKey) {
|
|
817
|
-
return finishConfirmedUpload(current, objects);
|
|
818
|
-
}
|
|
819
|
-
const candidate = error as { name?: unknown; $metadata?: { httpStatusCode?: unknown } };
|
|
820
|
-
if (candidate?.name === 'PreconditionFailed' || candidate?.$metadata?.httpStatusCode === 412) {
|
|
821
|
-
throw new StorageError('Uploaded object metadata changed during confirmation; retry confirmation', 409, 'upload_changed_during_confirmation');
|
|
822
|
-
}
|
|
823
|
-
throw new StorageError('Unable to promote uploaded object', 503, 'upload_promotion_failed');
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
let confirmed: UploadRow;
|
|
827
|
-
try {
|
|
828
|
-
confirmed = await store.confirm(upload.id, userId, finalObjectKey, metadata.sizeBytes);
|
|
829
|
-
} catch (error) {
|
|
830
|
-
let current: UploadRow | null;
|
|
831
|
-
try {
|
|
832
|
-
current = await store.find(upload.id, userId);
|
|
833
|
-
} catch {
|
|
834
|
-
// A database transport failure has an indeterminate commit outcome. Never delete the
|
|
835
|
-
// promoted object here: another confirmer may have committed the same immutable key.
|
|
836
|
-
throw new StorageError('Upload metadata and promoted object require reconciliation', 503, 'upload_reconciliation_required');
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
if (current?.status === 'confirmed' && current.objectKey === finalObjectKey) {
|
|
840
|
-
confirmed = current;
|
|
841
|
-
} else if (
|
|
842
|
-
current === null
|
|
843
|
-
|| current.status === 'rejected'
|
|
844
|
-
|| current.status === 'confirmed'
|
|
845
|
-
|| (error instanceof StorageError && error.code === 'upload_state_changed')
|
|
846
|
-
) {
|
|
847
|
-
// A successful re-read makes these CAS outcomes deterministic. No database row points
|
|
848
|
-
// at this promoted key, so remove it instead of turning an ordinary conflict into an orphan.
|
|
849
|
-
try {
|
|
850
|
-
await objects.delete(finalObjectKey);
|
|
851
|
-
} catch {
|
|
852
|
-
throw new StorageError('Conflicting upload state requires promoted-object cleanup', 503, 'upload_final_cleanup_failed');
|
|
853
|
-
}
|
|
854
|
-
if (current?.status === 'rejected') {
|
|
855
|
-
throw new StorageError('Upload was rejected while it was being finalized', 409, 'upload_rejected');
|
|
856
|
-
}
|
|
857
|
-
if (error instanceof StorageError) throw error;
|
|
858
|
-
throw new StorageError('Upload state changed while it was being finalized', 409, 'upload_state_changed');
|
|
859
|
-
} else {
|
|
860
|
-
// A successful read that still shows pending cannot prove that a failed write did not
|
|
861
|
-
// commit (or that a concurrent confirmer will not commit next), so retain the final key.
|
|
862
|
-
throw new StorageError('Upload metadata and promoted object require reconciliation', 503, 'upload_reconciliation_required');
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
return finishConfirmedUpload(confirmed, objects);
|
|
867
|
-
},
|
|
868
|
-
|
|
869
|
-
async getUpload(userId, uploadId) {
|
|
870
|
-
const upload = await store.find(uploadId, userId);
|
|
871
|
-
if (!upload) throw new StorageError('Upload not found', 404, 'upload_not_found');
|
|
872
|
-
return { upload: publicUpload(upload, baseUrl) };
|
|
873
|
-
},
|
|
874
|
-
|
|
875
|
-
async createReadUrl(userId, uploadId) {
|
|
876
|
-
const upload = await store.find(uploadId, userId);
|
|
877
|
-
if (!upload || upload.status !== 'confirmed' || !upload.objectKey) {
|
|
878
|
-
throw new StorageError('Asset not found', 404, 'asset_not_found');
|
|
879
|
-
}
|
|
880
|
-
const expiresIn = env.R2_PROXY_READ_URL_TTL_SECONDS;
|
|
881
|
-
const objects = objectGateway();
|
|
882
|
-
if (!objects.signGet) throw new StorageError('Private asset reads are not configured', 503, 'storage_read_not_configured');
|
|
883
|
-
return {
|
|
884
|
-
url: await objects.signGet(upload.objectKey, expiresIn),
|
|
885
|
-
expiresIn,
|
|
886
|
-
};
|
|
887
|
-
},
|
|
888
|
-
|
|
889
|
-
async cleanupUploads(limit = MAX_UPLOAD_CLEANUP_ITEMS_PER_RUN) {
|
|
890
|
-
const requestedLimit = Number.isFinite(limit) ? Math.trunc(limit) : MAX_UPLOAD_CLEANUP_ITEMS_PER_RUN;
|
|
891
|
-
const itemLimit = Math.max(1, Math.min(requestedLimit, MAX_UPLOAD_CLEANUP_ITEMS_PER_RUN));
|
|
892
|
-
const startedAt = Date.now();
|
|
893
|
-
const deadline = startedAt + MAX_UPLOAD_CLEANUP_RUN_MS;
|
|
894
|
-
const expiredBefore = new Date(startedAt - UPLOAD_CLEANUP_GRACE_MS);
|
|
895
|
-
let cursor: { createdAt: Date; id: string } | undefined;
|
|
896
|
-
let processed = 0;
|
|
897
|
-
let cleaned = 0;
|
|
898
|
-
let failed = 0;
|
|
899
|
-
let batches = 0;
|
|
900
|
-
let timeLimitReached = false;
|
|
901
|
-
let oldestKnownBacklogCreatedAt: string | null = null;
|
|
902
|
-
const objects = objectGateway();
|
|
903
|
-
|
|
904
|
-
cleanup: while (processed < itemLimit) {
|
|
905
|
-
const batchLimit = Math.min(MAX_UPLOAD_CLEANUP_BATCH_SIZE, itemLimit - processed);
|
|
906
|
-
let candidates: UploadRow[];
|
|
907
|
-
try {
|
|
908
|
-
candidates = await beforeCleanupDeadline(
|
|
909
|
-
deadline,
|
|
910
|
-
() => store.listCleanupCandidates(expiredBefore, batchLimit, cursor),
|
|
911
|
-
);
|
|
912
|
-
} catch (error) {
|
|
913
|
-
if (error instanceof CleanupDeadlineExceeded) {
|
|
914
|
-
timeLimitReached = true;
|
|
915
|
-
break;
|
|
916
|
-
}
|
|
917
|
-
throw error;
|
|
918
|
-
}
|
|
919
|
-
if (candidates.length === 0) break;
|
|
920
|
-
batches += 1;
|
|
921
|
-
|
|
922
|
-
for (const upload of candidates) {
|
|
923
|
-
cursor = { createdAt: upload.createdAt, id: upload.id };
|
|
924
|
-
processed += 1;
|
|
925
|
-
try {
|
|
926
|
-
let cleanupUpload = upload;
|
|
927
|
-
if (upload.status === 'pending') {
|
|
928
|
-
// Reject through the same pending-state CAS used by confirmation, then re-read.
|
|
929
|
-
// Whichever operation wins determines whether the deterministic promoted key is live.
|
|
930
|
-
await beforeCleanupDeadline(deadline, async () => {
|
|
931
|
-
await store.reject(upload.id, upload.userId, upload.actualSize, 'upload_expired');
|
|
932
|
-
const current = await store.find(upload.id, upload.userId);
|
|
933
|
-
if (!current) throw new Error('Upload disappeared during cleanup reconciliation');
|
|
934
|
-
cleanupUpload = current;
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
// DeleteObject is idempotent. If database bookkeeping fails or the run times out
|
|
938
|
-
// after deletion, a later bounded run safely repeats deletion and records completion.
|
|
939
|
-
await beforeCleanupDeadline(deadline, async () => {
|
|
940
|
-
await objects.delete(cleanupUpload.stagingObjectKey);
|
|
941
|
-
if (cleanupUpload.status !== 'confirmed') {
|
|
942
|
-
await objects.delete(confirmedObjectKeyForUpload(
|
|
943
|
-
cleanupUpload.userId,
|
|
944
|
-
cleanupUpload.id,
|
|
945
|
-
cleanupUpload.stagingObjectKey,
|
|
946
|
-
));
|
|
947
|
-
}
|
|
948
|
-
await store.completeStagingCleanup(cleanupUpload, new Date());
|
|
949
|
-
});
|
|
950
|
-
cleaned += 1;
|
|
951
|
-
} catch (error) {
|
|
952
|
-
failed += 1;
|
|
953
|
-
oldestKnownBacklogCreatedAt ??= upload.createdAt.toISOString();
|
|
954
|
-
if (error instanceof CleanupDeadlineExceeded) {
|
|
955
|
-
timeLimitReached = true;
|
|
956
|
-
break cleanup;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// A short keyset page proves that there were no later candidates at query time.
|
|
962
|
-
if (candidates.length < batchLimit) break;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
let backlogRemaining = failed > 0 || processed >= itemLimit || timeLimitReached;
|
|
966
|
-
let oldestBacklogCreatedAt = oldestKnownBacklogCreatedAt;
|
|
967
|
-
if (!timeLimitReached) {
|
|
968
|
-
try {
|
|
969
|
-
// One final bounded probe reports the actual oldest remaining candidate, including a
|
|
970
|
-
// failed row skipped by the per-run cursor. It never expands object-store work.
|
|
971
|
-
const [oldest] = await beforeCleanupDeadline(
|
|
972
|
-
deadline,
|
|
973
|
-
() => store.listCleanupCandidates(expiredBefore, 1),
|
|
974
|
-
);
|
|
975
|
-
backlogRemaining = Boolean(oldest);
|
|
976
|
-
oldestBacklogCreatedAt = oldest?.createdAt.toISOString() ?? null;
|
|
977
|
-
} catch (error) {
|
|
978
|
-
if (error instanceof CleanupDeadlineExceeded) {
|
|
979
|
-
timeLimitReached = true;
|
|
980
|
-
backlogRemaining = true;
|
|
981
|
-
} else {
|
|
982
|
-
throw error;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
return {
|
|
988
|
-
processed,
|
|
989
|
-
cleaned,
|
|
990
|
-
failed,
|
|
991
|
-
batches,
|
|
992
|
-
backlogRemaining,
|
|
993
|
-
oldestBacklogCreatedAt,
|
|
994
|
-
itemLimitReached: processed >= itemLimit && backlogRemaining,
|
|
995
|
-
timeLimitReached,
|
|
996
|
-
};
|
|
997
|
-
},
|
|
998
|
-
};
|
|
999
|
-
}
|
|
1000
|
-
`;
|
|
1001
|
-
}
|
|
1002
|
-
function realtimeSource() {
|
|
1003
|
-
return `import * as Ably from 'ably';
|
|
1004
|
-
import type { RealtimeTokenRequest, SubscriptionChangedEvent } from '@shared/contracts';
|
|
1005
|
-
|
|
1006
|
-
export type SubscriptionMutation = { userId: string; revision: number };
|
|
1007
|
-
|
|
1008
|
-
export interface RealtimeService {
|
|
1009
|
-
createTokenRequest(userId: string): Promise<RealtimeTokenRequest>;
|
|
1010
|
-
publishSubscriptionChanged(change: SubscriptionMutation): Promise<void>;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
export function subscriptionChannelName(userId: string): string {
|
|
1014
|
-
return 'private:users:' + userId + ':subscriptions';
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
export function createRealtimeService(apiKey: string | undefined): RealtimeService {
|
|
1018
|
-
if (!apiKey) {
|
|
1019
|
-
return {
|
|
1020
|
-
async createTokenRequest() { throw new Error('Realtime is not configured'); },
|
|
1021
|
-
async publishSubscriptionChanged() {},
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
const client = new Ably.Rest({ key: apiKey });
|
|
1025
|
-
return {
|
|
1026
|
-
async createTokenRequest(userId) {
|
|
1027
|
-
return client.auth.createTokenRequest({
|
|
1028
|
-
clientId: userId,
|
|
1029
|
-
capability: JSON.stringify({ [subscriptionChannelName(userId)]: ['subscribe'] }),
|
|
1030
|
-
ttl: 60 * 60 * 1000,
|
|
1031
|
-
}) as Promise<RealtimeTokenRequest>;
|
|
1032
|
-
},
|
|
1033
|
-
async publishSubscriptionChanged(change) {
|
|
1034
|
-
const event: SubscriptionChangedEvent = {
|
|
1035
|
-
type: 'subscription.changed',
|
|
1036
|
-
revision: change.revision,
|
|
1037
|
-
};
|
|
1038
|
-
await client.channels.get(subscriptionChannelName(change.userId)).publish('subscription.changed', event);
|
|
1039
|
-
},
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
`;
|
|
1043
|
-
}
|
|
1044
|
-
function billingSource() {
|
|
1045
|
-
return `import crypto from 'node:crypto';
|
|
1046
|
-
import { and, asc, eq, isNull, lt, or, sql } from 'drizzle-orm';
|
|
1047
|
-
import { db, realtimeOutbox, sqlClient, subscriptions, webhookEvents } from '@shared/db';
|
|
1048
|
-
import type { SubscriptionMutation } from './realtime';
|
|
1049
|
-
|
|
1050
|
-
const CLAIM_LEASE_MS = 5 * 60 * 1000;
|
|
1051
|
-
const REVENUECAT_REQUEST_TIMEOUT_MS = 5_000;
|
|
1052
|
-
|
|
1053
|
-
export type RevenueCatWebhookClaim =
|
|
1054
|
-
| { status: 'claimed'; token: string }
|
|
1055
|
-
| { status: 'processed' }
|
|
1056
|
-
| { status: 'in_progress' };
|
|
1057
|
-
|
|
1058
|
-
export interface RevenueCatSubscriptionUpdate {
|
|
1059
|
-
userId: string;
|
|
1060
|
-
entitlement: string;
|
|
1061
|
-
status: 'active' | 'expired';
|
|
1062
|
-
expiresAt: Date | null;
|
|
1063
|
-
eventTimestamp: Date;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
export interface RevenueCatSubscriber {
|
|
1067
|
-
entitlements: Record<string, unknown>;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
export interface RevenueCatClient {
|
|
1071
|
-
getSubscriber(appUserId: string): Promise<RevenueCatSubscriber>;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
export interface RevenueCatWebhookStore {
|
|
1075
|
-
claim(providerEventId: string, payload: Record<string, unknown>): Promise<RevenueCatWebhookClaim>;
|
|
1076
|
-
reconcile(
|
|
1077
|
-
providerEventId: string,
|
|
1078
|
-
claimToken: string,
|
|
1079
|
-
updates: readonly RevenueCatSubscriptionUpdate[],
|
|
1080
|
-
): Promise<readonly SubscriptionMutation[]>;
|
|
1081
|
-
refresh(update: RevenueCatSubscriptionUpdate): Promise<SubscriptionMutation | null>;
|
|
1082
|
-
getEntitlement(userId: string): Promise<{
|
|
1083
|
-
entitlement: string;
|
|
1084
|
-
status: 'active' | 'expired';
|
|
1085
|
-
expiresAt: Date | null;
|
|
1086
|
-
revision: number;
|
|
1087
|
-
} | null>;
|
|
1088
|
-
pendingRealtime(limit?: number): Promise<readonly SubscriptionMutation[]>;
|
|
1089
|
-
markRealtimeDelivered(change: SubscriptionMutation): Promise<void>;
|
|
1090
|
-
markRealtimeFailed(change: SubscriptionMutation, message: string): Promise<void>;
|
|
1091
|
-
fail(providerEventId: string, claimToken: string, message: string): Promise<void>;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1095
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
function revenueCatDate(value: unknown, field: string): Date | null {
|
|
1099
|
-
if (value === null || value === undefined) return null;
|
|
1100
|
-
if (typeof value !== 'string') throw new Error('Invalid RevenueCat ' + field);
|
|
1101
|
-
const parsed = new Date(value);
|
|
1102
|
-
if (Number.isNaN(parsed.getTime())) throw new Error('Invalid RevenueCat ' + field);
|
|
1103
|
-
return parsed;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
export function projectRevenueCatSubscriber(
|
|
1107
|
-
subscriber: RevenueCatSubscriber,
|
|
1108
|
-
userId: string,
|
|
1109
|
-
entitlementId: string,
|
|
1110
|
-
eventTimestamp: Date,
|
|
1111
|
-
now = new Date(),
|
|
1112
|
-
): RevenueCatSubscriptionUpdate {
|
|
1113
|
-
const rawEntitlement = subscriber.entitlements[entitlementId];
|
|
1114
|
-
if (rawEntitlement === undefined) {
|
|
1115
|
-
return { userId, entitlement: 'free', status: 'expired', expiresAt: null, eventTimestamp };
|
|
1116
|
-
}
|
|
1117
|
-
if (!isRecord(rawEntitlement) || !Object.hasOwn(rawEntitlement, 'expires_date')) {
|
|
1118
|
-
throw new Error('Invalid RevenueCat entitlement response');
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
const expiresAt = revenueCatDate(rawEntitlement.expires_date, 'expires_date');
|
|
1122
|
-
const gracePeriodExpiresAt = revenueCatDate(
|
|
1123
|
-
rawEntitlement.grace_period_expires_date,
|
|
1124
|
-
'grace_period_expires_date',
|
|
1125
|
-
);
|
|
1126
|
-
// RevenueCat represents non-consumable/promotional lifetime access with a null expiration.
|
|
1127
|
-
if (expiresAt === null) {
|
|
1128
|
-
return { userId, entitlement: entitlementId, status: 'active', expiresAt: null, eventTimestamp };
|
|
1129
|
-
}
|
|
1130
|
-
const accessUntil = gracePeriodExpiresAt && gracePeriodExpiresAt.getTime() > expiresAt.getTime()
|
|
1131
|
-
? gracePeriodExpiresAt
|
|
1132
|
-
: expiresAt;
|
|
1133
|
-
const active = accessUntil.getTime() > now.getTime();
|
|
1134
|
-
return {
|
|
1135
|
-
userId,
|
|
1136
|
-
entitlement: active ? entitlementId : 'free',
|
|
1137
|
-
status: active ? 'active' : 'expired',
|
|
1138
|
-
expiresAt: accessUntil,
|
|
1139
|
-
eventTimestamp,
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
export function createRevenueCatClient(
|
|
1144
|
-
secretApiKey: string,
|
|
1145
|
-
fetchImpl: typeof fetch = globalThis.fetch,
|
|
1146
|
-
): RevenueCatClient {
|
|
1147
|
-
return {
|
|
1148
|
-
async getSubscriber(appUserId) {
|
|
1149
|
-
const controller = new AbortController();
|
|
1150
|
-
const timeout = setTimeout(() => controller.abort(), REVENUECAT_REQUEST_TIMEOUT_MS);
|
|
1151
|
-
try {
|
|
1152
|
-
const response = await fetchImpl(
|
|
1153
|
-
'https://api.revenuecat.com/v1/subscribers/' + encodeURIComponent(appUserId),
|
|
1154
|
-
{
|
|
1155
|
-
method: 'GET',
|
|
1156
|
-
headers: {
|
|
1157
|
-
accept: 'application/json',
|
|
1158
|
-
authorization: 'Bearer ' + secretApiKey,
|
|
1159
|
-
},
|
|
1160
|
-
signal: controller.signal,
|
|
1161
|
-
},
|
|
1162
|
-
);
|
|
1163
|
-
if (!response.ok) {
|
|
1164
|
-
throw new Error('RevenueCat subscriber lookup failed with status ' + response.status);
|
|
1165
|
-
}
|
|
1166
|
-
const payload: unknown = await response.json();
|
|
1167
|
-
if (!isRecord(payload) || !isRecord(payload.subscriber) || !isRecord(payload.subscriber.entitlements)) {
|
|
1168
|
-
throw new Error('Invalid RevenueCat subscriber response');
|
|
1169
|
-
}
|
|
1170
|
-
return { entitlements: payload.subscriber.entitlements };
|
|
1171
|
-
} finally {
|
|
1172
|
-
clearTimeout(timeout);
|
|
1173
|
-
}
|
|
1174
|
-
},
|
|
1175
|
-
};
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
export function effectiveEntitlementStatus(
|
|
1179
|
-
storedStatus: string,
|
|
1180
|
-
expiresAt: Date | null,
|
|
1181
|
-
now = new Date(),
|
|
1182
|
-
): 'active' | 'expired' {
|
|
1183
|
-
return storedStatus === 'active' && (!expiresAt || expiresAt.getTime() > now.getTime())
|
|
1184
|
-
? 'active'
|
|
1185
|
-
: 'expired';
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
export const revenueCatWebhookStore: RevenueCatWebhookStore = {
|
|
1189
|
-
async claim(providerEventId, payload) {
|
|
1190
|
-
await db.insert(webhookEvents).values({
|
|
1191
|
-
providerEventId,
|
|
1192
|
-
provider: 'revenuecat',
|
|
1193
|
-
payload,
|
|
1194
|
-
status: 'pending',
|
|
1195
|
-
}).onConflictDoNothing();
|
|
1196
|
-
|
|
1197
|
-
const now = new Date();
|
|
1198
|
-
const token = crypto.randomUUID();
|
|
1199
|
-
const [claimed] = await db.update(webhookEvents).set({
|
|
1200
|
-
status: 'processing',
|
|
1201
|
-
attempts: sql\`\${webhookEvents.attempts} + 1\`,
|
|
1202
|
-
claimToken: token,
|
|
1203
|
-
claimedAt: now,
|
|
1204
|
-
processedAt: null,
|
|
1205
|
-
lastError: null,
|
|
1206
|
-
}).where(and(
|
|
1207
|
-
eq(webhookEvents.providerEventId, providerEventId),
|
|
1208
|
-
or(
|
|
1209
|
-
eq(webhookEvents.status, 'pending'),
|
|
1210
|
-
eq(webhookEvents.status, 'failed'),
|
|
1211
|
-
and(
|
|
1212
|
-
eq(webhookEvents.status, 'processing'),
|
|
1213
|
-
lt(webhookEvents.claimedAt, new Date(now.getTime() - CLAIM_LEASE_MS)),
|
|
1214
|
-
),
|
|
1215
|
-
),
|
|
1216
|
-
)).returning({ claimToken: webhookEvents.claimToken });
|
|
1217
|
-
|
|
1218
|
-
if (claimed) return { status: 'claimed', token };
|
|
1219
|
-
const [existing] = await db.select({ status: webhookEvents.status })
|
|
1220
|
-
.from(webhookEvents)
|
|
1221
|
-
.where(eq(webhookEvents.providerEventId, providerEventId))
|
|
1222
|
-
.limit(1);
|
|
1223
|
-
return existing?.status === 'processed' ? { status: 'processed' } : { status: 'in_progress' };
|
|
1224
|
-
},
|
|
1225
|
-
|
|
1226
|
-
async reconcile(providerEventId, claimToken, updates) {
|
|
1227
|
-
if (updates.length === 0) throw new Error('RevenueCat reconciliation requires at least one update');
|
|
1228
|
-
const subscriptionQueries = updates.map((input) => sqlClient\`
|
|
1229
|
-
WITH "owned_claim" AS (
|
|
1230
|
-
SELECT 1 FROM "webhook_events"
|
|
1231
|
-
WHERE "provider_event_id" = \${providerEventId}
|
|
1232
|
-
AND "claim_token" = \${claimToken}
|
|
1233
|
-
AND "status" = 'processing'
|
|
1234
|
-
FOR UPDATE
|
|
1235
|
-
)
|
|
1236
|
-
, "updated_subscription" AS (
|
|
1237
|
-
INSERT INTO "subscriptions" (
|
|
1238
|
-
"id", "user_id", "entitlement", "status", "expires_at", "event_timestamp", "revision", "updated_at"
|
|
1239
|
-
)
|
|
1240
|
-
SELECT
|
|
1241
|
-
\${input.userId}, \${input.userId}, \${input.entitlement}, \${input.status},
|
|
1242
|
-
\${input.expiresAt}, \${input.eventTimestamp}, 1, now()
|
|
1243
|
-
FROM "owned_claim"
|
|
1244
|
-
ON CONFLICT ("user_id") DO UPDATE SET
|
|
1245
|
-
"id" = excluded."id",
|
|
1246
|
-
"entitlement" = excluded."entitlement",
|
|
1247
|
-
"status" = excluded."status",
|
|
1248
|
-
"expires_at" = excluded."expires_at",
|
|
1249
|
-
"event_timestamp" = excluded."event_timestamp",
|
|
1250
|
-
"revision" = "subscriptions"."revision" + 1,
|
|
1251
|
-
"updated_at" = now()
|
|
1252
|
-
WHERE "subscriptions"."event_timestamp" <= excluded."event_timestamp"
|
|
1253
|
-
RETURNING "user_id", "revision"
|
|
1254
|
-
)
|
|
1255
|
-
INSERT INTO "realtime_outbox" ("id", "user_id", "topic", "revision", "created_at")
|
|
1256
|
-
SELECT \${crypto.randomUUID()}, "user_id", 'subscription.changed', "revision", now()
|
|
1257
|
-
FROM "updated_subscription"
|
|
1258
|
-
RETURNING "user_id" AS "userId", "revision"
|
|
1259
|
-
\`);
|
|
1260
|
-
const results = await sqlClient.transaction([
|
|
1261
|
-
...subscriptionQueries,
|
|
1262
|
-
sqlClient\`
|
|
1263
|
-
UPDATE "webhook_events" SET
|
|
1264
|
-
"status" = 'processed',
|
|
1265
|
-
"claim_token" = NULL,
|
|
1266
|
-
"claimed_at" = NULL,
|
|
1267
|
-
"processed_at" = now(),
|
|
1268
|
-
"last_error" = NULL
|
|
1269
|
-
WHERE "provider_event_id" = \${providerEventId}
|
|
1270
|
-
AND "claim_token" = \${claimToken}
|
|
1271
|
-
AND "status" = 'processing'
|
|
1272
|
-
RETURNING "provider_event_id" AS "providerEventId"
|
|
1273
|
-
\`,
|
|
1274
|
-
], { isolationLevel: 'ReadCommitted' });
|
|
1275
|
-
const completed = results.at(-1) as Array<{ providerEventId: string }> | undefined;
|
|
1276
|
-
if (!completed?.[0]) throw new Error('RevenueCat webhook claim lease was lost');
|
|
1277
|
-
return (results.slice(0, -1) as Array<Array<SubscriptionMutation>>).flat();
|
|
1278
|
-
},
|
|
1279
|
-
|
|
1280
|
-
async refresh(input) {
|
|
1281
|
-
const rows = await sqlClient\`
|
|
1282
|
-
WITH "updated_subscription" AS (
|
|
1283
|
-
INSERT INTO "subscriptions" (
|
|
1284
|
-
"id", "user_id", "entitlement", "status", "expires_at", "event_timestamp", "revision", "updated_at"
|
|
1285
|
-
) VALUES (
|
|
1286
|
-
\${input.userId}, \${input.userId}, \${input.entitlement}, \${input.status},
|
|
1287
|
-
\${input.expiresAt}, \${input.eventTimestamp}, 1, now()
|
|
1288
|
-
)
|
|
1289
|
-
ON CONFLICT ("user_id") DO UPDATE SET
|
|
1290
|
-
"entitlement" = excluded."entitlement",
|
|
1291
|
-
"status" = excluded."status",
|
|
1292
|
-
"expires_at" = excluded."expires_at",
|
|
1293
|
-
"event_timestamp" = excluded."event_timestamp",
|
|
1294
|
-
"revision" = "subscriptions"."revision" + 1,
|
|
1295
|
-
"updated_at" = now()
|
|
1296
|
-
WHERE "subscriptions"."event_timestamp" <= excluded."event_timestamp"
|
|
1297
|
-
RETURNING "user_id", "revision"
|
|
1298
|
-
)
|
|
1299
|
-
INSERT INTO "realtime_outbox" ("id", "user_id", "topic", "revision", "created_at")
|
|
1300
|
-
SELECT \${crypto.randomUUID()}, "user_id", 'subscription.changed', "revision", now()
|
|
1301
|
-
FROM "updated_subscription"
|
|
1302
|
-
RETURNING "user_id" AS "userId", "revision"
|
|
1303
|
-
\` as Array<SubscriptionMutation>;
|
|
1304
|
-
return rows[0] ?? null;
|
|
1305
|
-
},
|
|
1306
|
-
|
|
1307
|
-
async getEntitlement(userId) {
|
|
1308
|
-
const [subscription] = await db.select({
|
|
1309
|
-
entitlement: subscriptions.entitlement,
|
|
1310
|
-
status: subscriptions.status,
|
|
1311
|
-
expiresAt: subscriptions.expiresAt,
|
|
1312
|
-
revision: subscriptions.revision,
|
|
1313
|
-
}).from(subscriptions).where(eq(subscriptions.userId, userId)).limit(1);
|
|
1314
|
-
if (!subscription) return null;
|
|
1315
|
-
return {
|
|
1316
|
-
entitlement: subscription.entitlement,
|
|
1317
|
-
status: effectiveEntitlementStatus(subscription.status, subscription.expiresAt),
|
|
1318
|
-
expiresAt: subscription.expiresAt,
|
|
1319
|
-
revision: subscription.revision,
|
|
1320
|
-
};
|
|
1321
|
-
},
|
|
1322
|
-
|
|
1323
|
-
async pendingRealtime(limit = 100) {
|
|
1324
|
-
return db.select({ userId: realtimeOutbox.userId, revision: realtimeOutbox.revision })
|
|
1325
|
-
.from(realtimeOutbox)
|
|
1326
|
-
.where(isNull(realtimeOutbox.deliveredAt))
|
|
1327
|
-
.orderBy(asc(realtimeOutbox.createdAt))
|
|
1328
|
-
.limit(Math.max(1, Math.min(limit, 500)));
|
|
1329
|
-
},
|
|
1330
|
-
|
|
1331
|
-
async markRealtimeDelivered(change) {
|
|
1332
|
-
await db.update(realtimeOutbox).set({ deliveredAt: new Date(), lastError: null })
|
|
1333
|
-
.where(and(
|
|
1334
|
-
eq(realtimeOutbox.userId, change.userId),
|
|
1335
|
-
eq(realtimeOutbox.revision, change.revision),
|
|
1336
|
-
isNull(realtimeOutbox.deliveredAt),
|
|
1337
|
-
));
|
|
1338
|
-
},
|
|
1339
|
-
|
|
1340
|
-
async markRealtimeFailed(change, message) {
|
|
1341
|
-
await db.update(realtimeOutbox).set({
|
|
1342
|
-
attempts: sql\`\${realtimeOutbox.attempts} + 1\`,
|
|
1343
|
-
lastError: message.slice(0, 1000),
|
|
1344
|
-
}).where(and(
|
|
1345
|
-
eq(realtimeOutbox.userId, change.userId),
|
|
1346
|
-
eq(realtimeOutbox.revision, change.revision),
|
|
1347
|
-
isNull(realtimeOutbox.deliveredAt),
|
|
1348
|
-
));
|
|
1349
|
-
},
|
|
1350
|
-
|
|
1351
|
-
async fail(providerEventId, claimToken, message) {
|
|
1352
|
-
await db.update(webhookEvents).set({
|
|
1353
|
-
status: 'failed',
|
|
1354
|
-
claimToken: null,
|
|
1355
|
-
claimedAt: null,
|
|
1356
|
-
lastError: message.slice(0, 1000),
|
|
1357
|
-
}).where(and(
|
|
1358
|
-
eq(webhookEvents.providerEventId, providerEventId),
|
|
1359
|
-
eq(webhookEvents.claimToken, claimToken),
|
|
1360
|
-
eq(webhookEvents.status, 'processing'),
|
|
1361
|
-
));
|
|
1362
|
-
},
|
|
1363
|
-
};
|
|
1364
|
-
`;
|
|
1365
|
-
}
|
|
1366
|
-
function routesSource(options) {
|
|
1367
|
-
const imports = [
|
|
1368
|
-
"import type { FastifyPluginAsync } from 'fastify';",
|
|
1369
|
-
"import { appRoutes } from './routes/app';",
|
|
1370
|
-
options.features.billing || options.features.storage ? "import { timingSafeEqual } from 'node:crypto';" : null,
|
|
1371
|
-
options.features.auth ? "import { authenticatedUserId } from './auth';" : null,
|
|
1372
|
-
options.features.storage ? "import { ConfirmUploadRequestSchema, CreateUploadRequestSchema, UploadParamsSchema } from '@shared/contracts';" : null,
|
|
1373
|
-
options.features.storage ? "import { createStorageService, type StorageService } from './storage';" : null,
|
|
1374
|
-
options.features.billing ? "import { createRevenueCatClient, projectRevenueCatSubscriber, revenueCatWebhookStore, type RevenueCatClient, type RevenueCatSubscriptionUpdate, type RevenueCatWebhookStore } from './billing';" : null,
|
|
1375
|
-
options.features.billing ? "import { createRealtimeService, type RealtimeService, type SubscriptionMutation } from './realtime';" : null,
|
|
1376
|
-
].filter((value) => value !== null);
|
|
1377
|
-
const routes = [
|
|
1378
|
-
`app.get('/health', async () => ({ ok: true as const, service: 'api' }));`,
|
|
1379
|
-
`app.get('/ready', async (_request, reply) => {
|
|
1380
|
-
const status = await serviceState.readiness();
|
|
1381
|
-
const ready = status === 'ready';
|
|
1382
|
-
return reply.code(ready ? 200 : 503).send({
|
|
1383
|
-
ok: ready,
|
|
1384
|
-
service: 'api',
|
|
1385
|
-
status,
|
|
1386
|
-
});
|
|
1387
|
-
});`,
|
|
1388
|
-
options.features.auth ? `app.get('/auth/me', async (request) => ({ user: { id: authenticatedUserId(request, env) } }));` : null,
|
|
1389
|
-
options.features.billing ? `app.get('/subscriptions/me', async (request) => {
|
|
1390
|
-
const subscription = await billingStore.getEntitlement(authenticatedUserId(request, env));
|
|
1391
|
-
return subscription
|
|
1392
|
-
? {
|
|
1393
|
-
entitlement: subscription.entitlement,
|
|
1394
|
-
status: subscription.status,
|
|
1395
|
-
expiresAt: subscription.expiresAt?.toISOString() ?? null,
|
|
1396
|
-
revision: subscription.revision,
|
|
1397
|
-
}
|
|
1398
|
-
: { entitlement: 'free', status: 'free' as const, expiresAt: null, revision: 0 };
|
|
1399
|
-
});
|
|
1400
|
-
|
|
1401
|
-
app.post('/subscriptions/refresh', {
|
|
1402
|
-
config: {
|
|
1403
|
-
rateLimit: {
|
|
1404
|
-
max: 10,
|
|
1405
|
-
timeWindow: '1 minute',
|
|
1406
|
-
keyGenerator: (request) => authenticatedUserId(request, env),
|
|
1407
|
-
},
|
|
1408
|
-
},
|
|
1409
|
-
}, async (request, reply) => {
|
|
1410
|
-
const userId = authenticatedUserId(request, env);
|
|
1411
|
-
if (!revenueCatClient) return reply.code(503).send({ error: 'billing_not_configured', message: 'Billing is not configured' });
|
|
1412
|
-
const now = new Date();
|
|
1413
|
-
const update = projectRevenueCatSubscriber(
|
|
1414
|
-
await revenueCatClient.getSubscriber(userId),
|
|
1415
|
-
userId,
|
|
1416
|
-
env.RC_ENTITLEMENT_ID,
|
|
1417
|
-
now,
|
|
1418
|
-
now,
|
|
1419
|
-
);
|
|
1420
|
-
const change = await billingStore.refresh(update);
|
|
1421
|
-
if (change) await publishSubscriptionChanges([change]);
|
|
1422
|
-
const subscription = await billingStore.getEntitlement(userId);
|
|
1423
|
-
return subscription
|
|
1424
|
-
? {
|
|
1425
|
-
entitlement: subscription.entitlement,
|
|
1426
|
-
status: subscription.status,
|
|
1427
|
-
expiresAt: subscription.expiresAt?.toISOString() ?? null,
|
|
1428
|
-
revision: subscription.revision,
|
|
1429
|
-
}
|
|
1430
|
-
: { entitlement: 'free', status: 'free' as const, expiresAt: null, revision: 0 };
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
app.post('/realtime/token', async (request, reply) => {
|
|
1434
|
-
if (!env.ABLY_API_KEY) return reply.code(503).send({ error: 'realtime_not_configured', message: 'Realtime is not configured' });
|
|
1435
|
-
return realtimeService.createTokenRequest(authenticatedUserId(request, env));
|
|
1436
|
-
});
|
|
1437
|
-
|
|
1438
|
-
app.post('/webhooks/revenuecat', {
|
|
1439
|
-
bodyLimit: 256 * 1024,
|
|
1440
|
-
config: {
|
|
1441
|
-
rateLimit: {
|
|
1442
|
-
max: 600,
|
|
1443
|
-
timeWindow: '1 minute',
|
|
1444
|
-
groupId: 'revenuecat-webhooks',
|
|
1445
|
-
keyGenerator: (request) => env.RC_WEBHOOK_SECRET
|
|
1446
|
-
&& matchesBearerSecret(request.headers.authorization, env.RC_WEBHOOK_SECRET)
|
|
1447
|
-
? 'revenuecat-provider'
|
|
1448
|
-
: request.ip,
|
|
1449
|
-
},
|
|
1450
|
-
},
|
|
1451
|
-
}, async (request, reply) => {
|
|
1452
|
-
const authorization = request.headers.authorization;
|
|
1453
|
-
if (!env.RC_WEBHOOK_SECRET || !matchesBearerSecret(authorization, env.RC_WEBHOOK_SECRET)) {
|
|
1454
|
-
return reply.code(401).send({ ok: false, error: 'invalid_authorization' });
|
|
1455
|
-
}
|
|
1456
|
-
const payload = request.body as Record<string, unknown> | null;
|
|
1457
|
-
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
1458
|
-
return reply.code(400).send({ ok: false, error: 'invalid_payload' });
|
|
1459
|
-
}
|
|
1460
|
-
const event = payload.event && typeof payload.event === 'object'
|
|
1461
|
-
? payload.event as Record<string, unknown>
|
|
1462
|
-
: null;
|
|
1463
|
-
const rawProviderEventId = event?.id;
|
|
1464
|
-
const providerEventId = validRevenueCatIdentifier(rawProviderEventId) ? rawProviderEventId : null;
|
|
1465
|
-
if (!providerEventId) return reply.code(400).send({ ok: false, error: 'missing_event_id' });
|
|
1466
|
-
const rawEventType = event?.type;
|
|
1467
|
-
const eventType = validRevenueCatIdentifier(rawEventType) ? rawEventType : null;
|
|
1468
|
-
if (!eventType) return reply.code(400).send({ ok: false, error: 'missing_event_type' });
|
|
1469
|
-
const eventTimestampMs = typeof event?.event_timestamp_ms === 'number'
|
|
1470
|
-
&& Number.isSafeInteger(event.event_timestamp_ms)
|
|
1471
|
-
&& event.event_timestamp_ms >= 0
|
|
1472
|
-
? event.event_timestamp_ms
|
|
1473
|
-
: null;
|
|
1474
|
-
const eventTimestamp = eventTimestampMs === null ? null : new Date(eventTimestampMs);
|
|
1475
|
-
if (!eventTimestamp || Number.isNaN(eventTimestamp.getTime())) {
|
|
1476
|
-
return reply.code(400).send({ ok: false, error: 'invalid_event_timestamp' });
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
const rawUserId = event?.app_user_id;
|
|
1480
|
-
const userId = validRevenueCatIdentifier(rawUserId) ? rawUserId : null;
|
|
1481
|
-
const transferredFrom = eventType === 'TRANSFER' ? revenueCatIdentityList(event?.transferred_from) : null;
|
|
1482
|
-
const transferredTo = eventType === 'TRANSFER' ? revenueCatIdentityList(event?.transferred_to) : null;
|
|
1483
|
-
if (eventType === 'TRANSFER' && (!transferredFrom || !transferredTo)) {
|
|
1484
|
-
return reply.code(400).send({ ok: false, error: 'invalid_transfer_identities' });
|
|
1485
|
-
}
|
|
1486
|
-
if (eventType !== 'TRANSFER' && !userId) {
|
|
1487
|
-
return reply.code(400).send({ ok: false, error: 'missing_app_user_id' });
|
|
1488
|
-
}
|
|
1489
|
-
if (!revenueCatClient) return reply.code(503).send({ ok: false, error: 'billing_not_configured' });
|
|
1490
|
-
|
|
1491
|
-
const claim = await billingStore.claim(providerEventId, payload);
|
|
1492
|
-
if (claim.status === 'processed') return reply.send({ ok: true, duplicate: true });
|
|
1493
|
-
if (claim.status === 'in_progress') {
|
|
1494
|
-
return reply.header('retry-after', '5').code(503).send({ ok: false, error: 'webhook_in_progress' });
|
|
1495
|
-
}
|
|
1496
|
-
try {
|
|
1497
|
-
const reconciliationNow = new Date();
|
|
1498
|
-
let subscriptionUpdates: RevenueCatSubscriptionUpdate[];
|
|
1499
|
-
if (eventType === 'TRANSFER' && transferredFrom && transferredTo) {
|
|
1500
|
-
// Finish every authoritative lookup before mutating local state. A provider failure
|
|
1501
|
-
// releases the claim without leaving a destination only partially reconciled.
|
|
1502
|
-
const destinationUpdates = await Promise.all(transferredTo.map(async (destinationUserId) => (
|
|
1503
|
-
projectRevenueCatSubscriber(
|
|
1504
|
-
await revenueCatClient.getSubscriber(destinationUserId),
|
|
1505
|
-
destinationUserId,
|
|
1506
|
-
env.RC_ENTITLEMENT_ID,
|
|
1507
|
-
eventTimestamp,
|
|
1508
|
-
reconciliationNow,
|
|
1509
|
-
)
|
|
1510
|
-
)));
|
|
1511
|
-
const destinationIds = new Set(transferredTo);
|
|
1512
|
-
const sourceUpdates = transferredFrom
|
|
1513
|
-
.filter((sourceUserId) => !destinationIds.has(sourceUserId))
|
|
1514
|
-
.map((sourceUserId): RevenueCatSubscriptionUpdate => ({
|
|
1515
|
-
userId: sourceUserId,
|
|
1516
|
-
entitlement: 'free',
|
|
1517
|
-
status: 'expired',
|
|
1518
|
-
expiresAt: null,
|
|
1519
|
-
eventTimestamp,
|
|
1520
|
-
}));
|
|
1521
|
-
subscriptionUpdates = [...sourceUpdates, ...destinationUpdates];
|
|
1522
|
-
} else if (userId) {
|
|
1523
|
-
subscriptionUpdates = [projectRevenueCatSubscriber(
|
|
1524
|
-
await revenueCatClient.getSubscriber(userId),
|
|
1525
|
-
userId,
|
|
1526
|
-
env.RC_ENTITLEMENT_ID,
|
|
1527
|
-
eventTimestamp,
|
|
1528
|
-
reconciliationNow,
|
|
1529
|
-
)];
|
|
1530
|
-
} else {
|
|
1531
|
-
throw new Error('RevenueCat reconciliation did not resolve a target identity');
|
|
1532
|
-
}
|
|
1533
|
-
const changes = await billingStore.reconcile(providerEventId, claim.token, subscriptionUpdates);
|
|
1534
|
-
await publishSubscriptionChanges(changes);
|
|
1535
|
-
return reply.send({ ok: true });
|
|
1536
|
-
} catch (error) {
|
|
1537
|
-
const message = error instanceof Error ? error.message : 'Unknown RevenueCat webhook failure';
|
|
1538
|
-
try {
|
|
1539
|
-
await billingStore.fail(providerEventId, claim.token, message);
|
|
1540
|
-
} catch (releaseError) {
|
|
1541
|
-
request.log.error({
|
|
1542
|
-
msg: 'revenuecat_webhook_claim_release_failed',
|
|
1543
|
-
error: releaseError instanceof Error ? releaseError.message : 'Unknown claim release failure',
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
request.log.error({ msg: 'revenuecat_webhook_failed', error: message });
|
|
1547
|
-
return reply.code(503).send({ ok: false, error: 'webhook_processing_failed' });
|
|
1548
|
-
}
|
|
1549
|
-
});` : null,
|
|
1550
|
-
options.features.billing ? `app.get('/internal/realtime/flush', async (request, reply) => {
|
|
1551
|
-
if (!env.CRON_SECRET || !matchesBearerSecret(request.headers.authorization, env.CRON_SECRET)) {
|
|
1552
|
-
return reply.code(401).send({ ok: false, error: 'invalid_authorization' });
|
|
1553
|
-
}
|
|
1554
|
-
const pending = await billingStore.pendingRealtime(100);
|
|
1555
|
-
await publishSubscriptionChanges(pending);
|
|
1556
|
-
return { ok: true, processed: pending.length };
|
|
1557
|
-
});` : null,
|
|
1558
|
-
options.features.storage ? `app.post('/storage/uploads', {
|
|
1559
|
-
bodyLimit: 16 * 1024,
|
|
1560
|
-
config: {
|
|
1561
|
-
rateLimit: {
|
|
1562
|
-
max: 10,
|
|
1563
|
-
timeWindow: '1 minute',
|
|
1564
|
-
keyGenerator: (request) => authenticatedUserId(request, env),
|
|
1565
|
-
},
|
|
1566
|
-
},
|
|
1567
|
-
}, async (request, reply) => {
|
|
1568
|
-
const userId = authenticatedUserId(request, env);
|
|
1569
|
-
const parsed = CreateUploadRequestSchema.safeParse(request.body);
|
|
1570
|
-
if (!parsed.success) {
|
|
1571
|
-
return reply.code(400).send({
|
|
1572
|
-
error: 'invalid_upload_request',
|
|
1573
|
-
message: 'Use an allowed content type and a size no larger than 10 MiB',
|
|
1574
|
-
details: parsed.error.flatten(),
|
|
1575
|
-
});
|
|
1576
|
-
}
|
|
1577
|
-
return storageService.createUpload(userId, parsed.data);
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
app.post('/storage/uploads/confirm', { bodyLimit: 16 * 1024 }, async (request, reply) => {
|
|
1581
|
-
const parsed = ConfirmUploadRequestSchema.safeParse(request.body);
|
|
1582
|
-
if (!parsed.success) {
|
|
1583
|
-
return reply.code(400).send({ error: 'invalid_upload_confirmation', message: 'A valid uploadId is required' });
|
|
1584
|
-
}
|
|
1585
|
-
return storageService.confirmUpload(authenticatedUserId(request, env), parsed.data.uploadId);
|
|
1586
|
-
});
|
|
1587
|
-
|
|
1588
|
-
app.get('/storage/uploads/:uploadId', async (request, reply) => {
|
|
1589
|
-
const parsed = UploadParamsSchema.safeParse(request.params);
|
|
1590
|
-
if (!parsed.success) return reply.code(400).send({ error: 'invalid_upload_id', message: 'A valid uploadId is required' });
|
|
1591
|
-
return storageService.getUpload(authenticatedUserId(request, env), parsed.data.uploadId);
|
|
1592
|
-
});
|
|
1593
|
-
|
|
1594
|
-
app.get('/storage/uploads/:uploadId/read-url', async (request, reply) => {
|
|
1595
|
-
const parsed = UploadParamsSchema.safeParse(request.params);
|
|
1596
|
-
if (!parsed.success) return reply.code(400).send({ error: 'invalid_upload_id', message: 'A valid uploadId is required' });
|
|
1597
|
-
return storageService.createReadUrl(authenticatedUserId(request, env), parsed.data.uploadId);
|
|
1598
|
-
});
|
|
1599
|
-
|
|
1600
|
-
app.get('/internal/storage/cleanup', async (request, reply) => {
|
|
1601
|
-
if (!env.CRON_SECRET || !matchesBearerSecret(request.headers.authorization, env.CRON_SECRET)) {
|
|
1602
|
-
return reply.code(401).send({ ok: false, error: 'invalid_authorization' });
|
|
1603
|
-
}
|
|
1604
|
-
const result = await storageService.cleanupUploads();
|
|
1605
|
-
const unsuccessful = result.failed > 0 || result.timeLimitReached;
|
|
1606
|
-
return reply.code(unsuccessful ? 503 : 200).send({ ok: !unsuccessful, ...result });
|
|
1607
|
-
});` : null,
|
|
1608
|
-
].filter((value) => value !== null);
|
|
1609
|
-
const dependencyFields = [
|
|
1610
|
-
options.features.billing ? ' revenueCatWebhookStore?: RevenueCatWebhookStore;' : null,
|
|
1611
|
-
options.features.billing ? ' revenueCatClient?: RevenueCatClient;' : null,
|
|
1612
|
-
options.features.billing ? ' realtimeService?: RealtimeService;' : null,
|
|
1613
|
-
options.features.storage ? ' storageService?: StorageService;' : null,
|
|
1614
|
-
" serviceState?: { readiness(): Promise<'ready' | 'unavailable' | 'shutting_down'> };",
|
|
1615
|
-
].filter((value) => value !== null);
|
|
1616
|
-
const billingStore = options.features.billing
|
|
1617
|
-
? ` const billingStore = dependencies.revenueCatWebhookStore ?? revenueCatWebhookStore;
|
|
1618
|
-
const revenueCatClient = dependencies.revenueCatClient ?? (env.RC_SECRET_API_KEY ? createRevenueCatClient(env.RC_SECRET_API_KEY) : null);
|
|
1619
|
-
const realtimeService = dependencies.realtimeService ?? createRealtimeService(env.ABLY_API_KEY);
|
|
1620
|
-
const publishSubscriptionChanges = async (changes: readonly SubscriptionMutation[]) => {
|
|
1621
|
-
for (const change of changes) {
|
|
1622
|
-
try {
|
|
1623
|
-
await realtimeService.publishSubscriptionChanged(change);
|
|
1624
|
-
await billingStore.markRealtimeDelivered(change);
|
|
1625
|
-
} catch (error) {
|
|
1626
|
-
await billingStore.markRealtimeFailed(
|
|
1627
|
-
change,
|
|
1628
|
-
error instanceof Error ? error.message : 'Realtime publish failed',
|
|
1629
|
-
);
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
};
|
|
1633
|
-
`
|
|
1634
|
-
: '';
|
|
1635
|
-
const storageService = options.features.storage
|
|
1636
|
-
? ' const storageService = dependencies.storageService ?? createStorageService(env);\n'
|
|
1637
|
-
: '';
|
|
1638
|
-
const appRoutesRegistration = options.features.database && options.features.auth
|
|
1639
|
-
? 'await app.register(appRoutes(env));'
|
|
1640
|
-
: 'await app.register(appRoutes);';
|
|
1641
|
-
return `${imports.join('\n')}
|
|
1642
|
-
import type { AppEnv } from './env';
|
|
1643
|
-
|
|
1644
|
-
${options.features.billing ? `function validRevenueCatIdentifier(value: unknown): value is string {
|
|
1645
|
-
return typeof value === 'string'
|
|
1646
|
-
&& value.length > 0
|
|
1647
|
-
&& value.length <= 512
|
|
1648
|
-
&& value.trim() === value;
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
function revenueCatIdentityList(value: unknown): string[] | null {
|
|
1652
|
-
if (!Array.isArray(value) || value.length === 0 || value.length > 20
|
|
1653
|
-
|| !value.every(validRevenueCatIdentifier)) return null;
|
|
1654
|
-
return [...new Set(value)];
|
|
1655
|
-
}
|
|
1656
|
-
` : ''}
|
|
1657
|
-
${options.features.billing || options.features.storage ? `function matchesBearerSecret(header: string | undefined, secret: string): boolean {
|
|
1658
|
-
if (!header) return false;
|
|
1659
|
-
const provided = Buffer.from(header);
|
|
1660
|
-
const expected = Buffer.from(\`Bearer \${secret}\`);
|
|
1661
|
-
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
1662
|
-
}
|
|
1663
|
-
` : ''}
|
|
1664
|
-
|
|
1665
|
-
export interface RouteDependencies {
|
|
1666
|
-
${dependencyFields.join('\n')}
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
export function routes(env: AppEnv, dependencies: RouteDependencies = {}): FastifyPluginAsync {
|
|
1670
|
-
return async function registerRoutes(app) {
|
|
1671
|
-
${billingStore}${storageService} const serviceState = dependencies.serviceState ?? { readiness: async () => 'ready' as const };
|
|
1672
|
-
${routes.join('\n\n ')}
|
|
1673
|
-
|
|
1674
|
-
${appRoutesRegistration}
|
|
1675
|
-
};
|
|
1676
|
-
}
|
|
1677
|
-
`;
|
|
1678
|
-
}
|
|
1679
|
-
function billingRouteTestSource(options) {
|
|
1680
|
-
return `import rateLimit from '@fastify/rate-limit';
|
|
1681
|
-
import Fastify from 'fastify';
|
|
1682
|
-
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
1683
|
-
import type { AppEnv } from '../src/env';
|
|
1684
|
-
import type {
|
|
1685
|
-
RevenueCatClient,
|
|
1686
|
-
RevenueCatSubscriber,
|
|
1687
|
-
RevenueCatSubscriptionUpdate,
|
|
1688
|
-
RevenueCatWebhookStore,
|
|
1689
|
-
} from '../src/billing';
|
|
1690
|
-
|
|
1691
|
-
const syntheticClerkKey = (kind: 'pk' | 'sk') => [kind, 'live', 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'].join('_');
|
|
1692
|
-
const syntheticSecret = (label: string) => [label, 'A1b2C3d4E5f6G7h8', 'I9j0K1l2M3n4O5p6'].join('_');
|
|
1693
|
-
const syntheticDatabaseUrl = (host: string) => [
|
|
1694
|
-
'postgresql://', 'app_owner', ':', 'S3cureRandomDatabaseCredential123', '@', host, '/app?sslmode=require',
|
|
1695
|
-
].join('');
|
|
1696
|
-
const webhookSecret = ['whsec', '7f4c7e93d8f24a779fb1d1f42148b995'].join('_');
|
|
1697
|
-
|
|
1698
|
-
const env: AppEnv = {
|
|
1699
|
-
NODE_ENV: 'test',
|
|
1700
|
-
PORT: 8787,
|
|
1701
|
-
TRUST_PROXY_HOPS: 0,
|
|
1702
|
-
CORS_ORIGINS: [],
|
|
1703
|
-
ANHEDRAL_DEMO: 'true',
|
|
1704
|
-
DATABASE_URL: 'postgresql://user:pass@localhost:5432/test',
|
|
1705
|
-
RC_WEBHOOK_SECRET: webhookSecret,
|
|
1706
|
-
RC_SECRET_API_KEY: syntheticSecret('revenuecat'),
|
|
1707
|
-
RC_ENTITLEMENT_ID: 'pro',
|
|
1708
|
-
ABLY_API_KEY: undefined,
|
|
1709
|
-
${options.features.storage ? " R2_PREFIX: 'storage',\n R2_PROXY_READ_URL_TTL_SECONDS: 600,\n" : ''} CRON_SECRET: syntheticSecret('cron'),
|
|
1710
|
-
};
|
|
1711
|
-
|
|
1712
|
-
const payload = {
|
|
1713
|
-
event: {
|
|
1714
|
-
id: 'event-1',
|
|
1715
|
-
type: 'INITIAL_PURCHASE',
|
|
1716
|
-
app_user_id: 'user_123',
|
|
1717
|
-
event_timestamp_ms: 4_102_444_800_000,
|
|
1718
|
-
entitlement_ids: ['pro'],
|
|
1719
|
-
},
|
|
1720
|
-
};
|
|
1721
|
-
|
|
1722
|
-
const activeSubscriber: RevenueCatSubscriber = {
|
|
1723
|
-
entitlements: {
|
|
1724
|
-
pro: {
|
|
1725
|
-
expires_date: '2099-08-14T21:07:40Z',
|
|
1726
|
-
grace_period_expires_date: null,
|
|
1727
|
-
product_identifier: 'annual-pro',
|
|
1728
|
-
},
|
|
1729
|
-
},
|
|
1730
|
-
};
|
|
1731
|
-
|
|
1732
|
-
beforeAll(() => {
|
|
1733
|
-
process.env.DATABASE_URL ||= env.DATABASE_URL;
|
|
1734
|
-
});
|
|
1735
|
-
|
|
1736
|
-
function createStore(overrides: Partial<RevenueCatWebhookStore> = {}): RevenueCatWebhookStore {
|
|
1737
|
-
return {
|
|
1738
|
-
claim: vi.fn(async () => ({ status: 'claimed' as const, token: 'claim-token' })),
|
|
1739
|
-
reconcile: vi.fn(async () => []),
|
|
1740
|
-
refresh: vi.fn(async () => null),
|
|
1741
|
-
getEntitlement: vi.fn(async () => null),
|
|
1742
|
-
pendingRealtime: vi.fn(async () => []),
|
|
1743
|
-
markRealtimeDelivered: vi.fn(async () => undefined),
|
|
1744
|
-
markRealtimeFailed: vi.fn(async () => undefined),
|
|
1745
|
-
fail: vi.fn(async () => undefined),
|
|
1746
|
-
...overrides,
|
|
1747
|
-
};
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
function createClient(
|
|
1751
|
-
getSubscriber = vi.fn(async () => activeSubscriber),
|
|
1752
|
-
): RevenueCatClient {
|
|
1753
|
-
return { getSubscriber };
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
async function webhookApp(
|
|
1757
|
-
store: RevenueCatWebhookStore,
|
|
1758
|
-
client: RevenueCatClient = createClient(),
|
|
1759
|
-
globalRateLimit = 120,
|
|
1760
|
-
) {
|
|
1761
|
-
const { routes } = await import('../src/routes');
|
|
1762
|
-
const app = Fastify();
|
|
1763
|
-
await app.register(rateLimit, { max: globalRateLimit, timeWindow: '1 minute' });
|
|
1764
|
-
await app.register(routes(env, {
|
|
1765
|
-
revenueCatWebhookStore: store,
|
|
1766
|
-
revenueCatClient: client,
|
|
1767
|
-
}), { prefix: '/api' });
|
|
1768
|
-
return app;
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
async function postWebhook(
|
|
1772
|
-
store: RevenueCatWebhookStore,
|
|
1773
|
-
body: object = payload,
|
|
1774
|
-
authorization: string | null | undefined = 'Bearer ' + webhookSecret,
|
|
1775
|
-
client: RevenueCatClient = createClient(),
|
|
1776
|
-
) {
|
|
1777
|
-
const app = await webhookApp(store, client);
|
|
1778
|
-
const response = await app.inject({
|
|
1779
|
-
method: 'POST',
|
|
1780
|
-
url: '/api/webhooks/revenuecat',
|
|
1781
|
-
headers: authorization ? { authorization } : {},
|
|
1782
|
-
payload: body,
|
|
1783
|
-
});
|
|
1784
|
-
await app.close();
|
|
1785
|
-
return response;
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
describe('RevenueCat webhook', () => {
|
|
1789
|
-
it('treats a persisted active entitlement as expired after its known expiration', async () => {
|
|
1790
|
-
const { effectiveEntitlementStatus } = await import('../src/billing');
|
|
1791
|
-
const now = new Date('2026-07-15T12:00:00.000Z');
|
|
1792
|
-
expect(effectiveEntitlementStatus('active', new Date('2026-07-15T11:59:59.000Z'), now)).toBe('expired');
|
|
1793
|
-
expect(effectiveEntitlementStatus('active', new Date('2026-07-15T12:00:01.000Z'), now)).toBe('active');
|
|
1794
|
-
expect(effectiveEntitlementStatus('active', null, now)).toBe('active');
|
|
1795
|
-
});
|
|
1796
|
-
|
|
1797
|
-
it('requires strong non-placeholder RevenueCat server secrets in production', async () => {
|
|
1798
|
-
const { loadEnv } = await import('../src/env');
|
|
1799
|
-
const production = {
|
|
1800
|
-
NODE_ENV: 'production',
|
|
1801
|
-
ANHEDRAL_DEMO: 'false',
|
|
1802
|
-
CORS_ORIGINS: 'https://app.acme.dev,null',
|
|
1803
|
-
DATABASE_URL: syntheticDatabaseUrl('ep-bright-cloud-a1b2c3.us-east-2.aws.neon.tech'),
|
|
1804
|
-
CLERK_PUBLISHABLE_KEY: syntheticClerkKey('pk'),
|
|
1805
|
-
CLERK_SECRET_KEY: syntheticClerkKey('sk'),
|
|
1806
|
-
RC_ENTITLEMENT_ID: 'pro',
|
|
1807
|
-
ABLY_API_KEY: syntheticSecret('ably'),
|
|
1808
|
-
R2_ACCOUNT_ID: 'a'.repeat(32),
|
|
1809
|
-
R2_ACCESS_KEY_ID: 'b'.repeat(32),
|
|
1810
|
-
R2_SECRET_ACCESS_KEY: 'c'.repeat(64),
|
|
1811
|
-
BASE_URL: 'https://app.acme.dev',
|
|
1812
|
-
R2_BUCKET_NAME: 'production-uploads',
|
|
1813
|
-
R2_PREFIX: 'storage',
|
|
1814
|
-
R2_PROXY_READ_URL_TTL_SECONDS: '600',
|
|
1815
|
-
CLOUDFLARE_API_TOKEN: undefined,
|
|
1816
|
-
CRON_SECRET: syntheticSecret('cron'),
|
|
1817
|
-
};
|
|
1818
|
-
expect(() => loadEnv({
|
|
1819
|
-
...production,
|
|
1820
|
-
RC_WEBHOOK_SECRET: 'webhook-secret',
|
|
1821
|
-
RC_SECRET_API_KEY: env.RC_SECRET_API_KEY,
|
|
1822
|
-
})).toThrow(/RC_WEBHOOK_SECRET must be at least 32 characters/);
|
|
1823
|
-
expect(() => loadEnv({
|
|
1824
|
-
...production,
|
|
1825
|
-
RC_WEBHOOK_SECRET: webhookSecret,
|
|
1826
|
-
RC_SECRET_API_KEY: 'x'.repeat(40),
|
|
1827
|
-
})).toThrow(/RC_SECRET_API_KEY must be at least 32 characters/);
|
|
1828
|
-
expect(() => loadEnv({
|
|
1829
|
-
...production,
|
|
1830
|
-
RC_WEBHOOK_SECRET: webhookSecret,
|
|
1831
|
-
RC_SECRET_API_KEY: env.RC_SECRET_API_KEY,
|
|
1832
|
-
})).not.toThrow();
|
|
1833
|
-
});
|
|
1834
|
-
|
|
1835
|
-
it('rejects missing and incorrect bearer secrets before comparing payloads', async () => {
|
|
1836
|
-
const store = createStore();
|
|
1837
|
-
const missing = await postWebhook(store, payload, null);
|
|
1838
|
-
const incorrect = await postWebhook(store, payload, 'Bearer wrong-secret-that-is-not-valid');
|
|
1839
|
-
|
|
1840
|
-
expect(missing.statusCode).toBe(401);
|
|
1841
|
-
expect(incorrect.statusCode).toBe(401);
|
|
1842
|
-
expect(store.claim).not.toHaveBeenCalled();
|
|
1843
|
-
});
|
|
1844
|
-
|
|
1845
|
-
it('rejects non-object payloads before claiming an event', async () => {
|
|
1846
|
-
const store = createStore();
|
|
1847
|
-
const response = await postWebhook(store, []);
|
|
1848
|
-
|
|
1849
|
-
expect(response.statusCode).toBe(400);
|
|
1850
|
-
expect(response.json()).toEqual({ ok: false, error: 'invalid_payload' });
|
|
1851
|
-
expect(store.claim).not.toHaveBeenCalled();
|
|
1852
|
-
});
|
|
1853
|
-
|
|
1854
|
-
it('reconciles ordinary events even when entitlement_ids is absent', async () => {
|
|
1855
|
-
const store = createStore();
|
|
1856
|
-
const client = createClient();
|
|
1857
|
-
const response = await postWebhook(store, {
|
|
1858
|
-
event: { ...payload.event, entitlement_ids: undefined },
|
|
1859
|
-
}, undefined, client);
|
|
1860
|
-
|
|
1861
|
-
expect(response.statusCode).toBe(200);
|
|
1862
|
-
expect(client.getSubscriber).toHaveBeenCalledWith('user_123');
|
|
1863
|
-
expect(store.reconcile).toHaveBeenCalledWith('event-1', 'claim-token', [expect.objectContaining({
|
|
1864
|
-
userId: 'user_123', entitlement: 'pro', status: 'active',
|
|
1865
|
-
})]);
|
|
1866
|
-
expect(store.fail).not.toHaveBeenCalled();
|
|
1867
|
-
});
|
|
1868
|
-
|
|
1869
|
-
it('revokes transfer sources and authoritatively reconciles every destination', async () => {
|
|
1870
|
-
const store = createStore();
|
|
1871
|
-
const client = createClient(vi.fn(async () => activeSubscriber));
|
|
1872
|
-
const response = await postWebhook(store, {
|
|
1873
|
-
event: {
|
|
1874
|
-
id: 'event-transfer',
|
|
1875
|
-
type: 'TRANSFER',
|
|
1876
|
-
event_timestamp_ms: 1_800_000_000_000,
|
|
1877
|
-
transferred_from: ['source-user', 'shared-user'],
|
|
1878
|
-
transferred_to: ['destination-user', 'shared-user'],
|
|
1879
|
-
},
|
|
1880
|
-
}, undefined, client);
|
|
1881
|
-
|
|
1882
|
-
expect(response.statusCode).toBe(200);
|
|
1883
|
-
expect(client.getSubscriber).toHaveBeenCalledTimes(2);
|
|
1884
|
-
expect(client.getSubscriber).toHaveBeenCalledWith('destination-user');
|
|
1885
|
-
expect(client.getSubscriber).toHaveBeenCalledWith('shared-user');
|
|
1886
|
-
const updates = vi.mocked(store.reconcile).mock.calls[0]?.[2] ?? [];
|
|
1887
|
-
expect(updates).toContainEqual(expect.objectContaining({
|
|
1888
|
-
userId: 'source-user', entitlement: 'free', status: 'expired',
|
|
1889
|
-
}));
|
|
1890
|
-
expect(updates).toContainEqual(expect.objectContaining({
|
|
1891
|
-
userId: 'destination-user', entitlement: 'pro', status: 'active',
|
|
1892
|
-
}));
|
|
1893
|
-
expect(updates).toContainEqual(expect.objectContaining({
|
|
1894
|
-
userId: 'shared-user', entitlement: 'pro', status: 'active',
|
|
1895
|
-
}));
|
|
1896
|
-
expect(updates.filter((update) => update.userId === 'shared-user')).toHaveLength(1);
|
|
1897
|
-
});
|
|
1898
|
-
|
|
1899
|
-
it('rejects malformed transfer identities and timestamps before claiming', async () => {
|
|
1900
|
-
const store = createStore();
|
|
1901
|
-
const invalidIdentities = await postWebhook(store, {
|
|
1902
|
-
event: {
|
|
1903
|
-
id: 'event-transfer', type: 'TRANSFER', event_timestamp_ms: 1_800_000_000_000,
|
|
1904
|
-
transferred_from: ['source-user', ''], transferred_to: ['destination-user'],
|
|
1905
|
-
},
|
|
1906
|
-
});
|
|
1907
|
-
const invalidTimestamp = await postWebhook(store, {
|
|
1908
|
-
event: { ...payload.event, id: 'event-invalid-time', event_timestamp_ms: Number.NaN },
|
|
1909
|
-
});
|
|
1910
|
-
|
|
1911
|
-
expect(invalidIdentities.statusCode).toBe(400);
|
|
1912
|
-
expect(invalidIdentities.json()).toEqual({ ok: false, error: 'invalid_transfer_identities' });
|
|
1913
|
-
expect(invalidTimestamp.statusCode).toBe(400);
|
|
1914
|
-
expect(invalidTimestamp.json()).toEqual({ ok: false, error: 'invalid_event_timestamp' });
|
|
1915
|
-
expect(store.claim).not.toHaveBeenCalled();
|
|
1916
|
-
});
|
|
1917
|
-
|
|
1918
|
-
it('uses the configured authoritative entitlement across overlapping products', async () => {
|
|
1919
|
-
const { projectRevenueCatSubscriber } = await import('../src/billing');
|
|
1920
|
-
const eventTimestamp = new Date('2027-01-01T00:00:00.000Z');
|
|
1921
|
-
const subscriber = {
|
|
1922
|
-
entitlements: {
|
|
1923
|
-
pro: { expires_date: '2028-01-01T00:00:00.000Z', grace_period_expires_date: null },
|
|
1924
|
-
team: { expires_date: '2029-01-01T00:00:00.000Z', grace_period_expires_date: null },
|
|
1925
|
-
},
|
|
1926
|
-
subscriptions: {
|
|
1927
|
-
'expired-monthly': { expires_date: '2025-01-01T00:00:00.000Z' },
|
|
1928
|
-
'active-annual': { expires_date: '2028-01-01T00:00:00.000Z' },
|
|
1929
|
-
},
|
|
1930
|
-
} as RevenueCatSubscriber;
|
|
1931
|
-
expect(projectRevenueCatSubscriber(
|
|
1932
|
-
subscriber, 'user_123', 'pro', eventTimestamp, new Date('2026-01-01T00:00:00.000Z'),
|
|
1933
|
-
)).toEqual({
|
|
1934
|
-
userId: 'user_123', entitlement: 'pro', status: 'active',
|
|
1935
|
-
expiresAt: new Date('2028-01-01T00:00:00.000Z'), eventTimestamp,
|
|
1936
|
-
});
|
|
1937
|
-
});
|
|
1938
|
-
|
|
1939
|
-
it('honors grace-period and lifetime entitlement access', async () => {
|
|
1940
|
-
const { projectRevenueCatSubscriber } = await import('../src/billing');
|
|
1941
|
-
const eventTimestamp = new Date('2026-01-01T00:00:00.000Z');
|
|
1942
|
-
const duringGrace = projectRevenueCatSubscriber({ entitlements: { pro: {
|
|
1943
|
-
expires_date: '2026-07-01T00:00:00.000Z',
|
|
1944
|
-
grace_period_expires_date: '2026-07-20T00:00:00.000Z',
|
|
1945
|
-
} } }, 'user_123', 'pro', eventTimestamp, new Date('2026-07-15T00:00:00.000Z'));
|
|
1946
|
-
const lifetime = projectRevenueCatSubscriber({ entitlements: { pro: {
|
|
1947
|
-
expires_date: null, grace_period_expires_date: null,
|
|
1948
|
-
} } }, 'user_123', 'pro', eventTimestamp, new Date('2099-01-01T00:00:00.000Z'));
|
|
1949
|
-
expect(duringGrace).toEqual(expect.objectContaining({
|
|
1950
|
-
status: 'active', entitlement: 'pro', expiresAt: new Date('2026-07-20T00:00:00.000Z'),
|
|
1951
|
-
}));
|
|
1952
|
-
expect(lifetime).toEqual(expect.objectContaining({ status: 'active', entitlement: 'pro', expiresAt: null }));
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
it('does not let stale webhook delivery overwrite newer reconciled state', async () => {
|
|
1956
|
-
let current: RevenueCatSubscriptionUpdate | null = null;
|
|
1957
|
-
const store = createStore({
|
|
1958
|
-
reconcile: vi.fn(async (_providerEventId, _claimToken, updates) => {
|
|
1959
|
-
for (const update of updates) {
|
|
1960
|
-
if (!current || update.eventTimestamp.getTime() >= current.eventTimestamp.getTime()) current = update;
|
|
1961
|
-
}
|
|
1962
|
-
return [];
|
|
1963
|
-
}),
|
|
1964
|
-
});
|
|
1965
|
-
const client = createClient(vi.fn()
|
|
1966
|
-
.mockResolvedValueOnce(activeSubscriber)
|
|
1967
|
-
.mockResolvedValueOnce({ entitlements: {} }));
|
|
1968
|
-
await postWebhook(store, {
|
|
1969
|
-
event: { ...payload.event, id: 'newer-event', event_timestamp_ms: 1_800_000_001_000 },
|
|
1970
|
-
}, undefined, client);
|
|
1971
|
-
await postWebhook(store, {
|
|
1972
|
-
event: { ...payload.event, id: 'older-event', event_timestamp_ms: 1_800_000_000_000 },
|
|
1973
|
-
}, undefined, client);
|
|
1974
|
-
|
|
1975
|
-
expect(current).toEqual(expect.objectContaining({ entitlement: 'pro', status: 'active' }));
|
|
1976
|
-
});
|
|
1977
|
-
|
|
1978
|
-
it('returns a retryable response while another worker owns the claim', async () => {
|
|
1979
|
-
const store = createStore({ claim: vi.fn(async () => ({ status: 'in_progress' as const })) });
|
|
1980
|
-
const response = await postWebhook(store);
|
|
1981
|
-
|
|
1982
|
-
expect(response.statusCode).toBe(503);
|
|
1983
|
-
expect(response.headers['retry-after']).toBe('5');
|
|
1984
|
-
expect(response.json()).toEqual({ ok: false, error: 'webhook_in_progress' });
|
|
1985
|
-
expect(store.reconcile).not.toHaveBeenCalled();
|
|
1986
|
-
});
|
|
1987
|
-
|
|
1988
|
-
it('acknowledges a processed duplicate without applying it again', async () => {
|
|
1989
|
-
const store = createStore({ claim: vi.fn(async () => ({ status: 'processed' as const })) });
|
|
1990
|
-
const response = await postWebhook(store);
|
|
1991
|
-
|
|
1992
|
-
expect(response.statusCode).toBe(200);
|
|
1993
|
-
expect(response.json()).toEqual({ ok: true, duplicate: true });
|
|
1994
|
-
expect(store.reconcile).not.toHaveBeenCalled();
|
|
1995
|
-
});
|
|
1996
|
-
|
|
1997
|
-
it('releases a failed claim when the RevenueCat REST lookup fails', async () => {
|
|
1998
|
-
const store = createStore();
|
|
1999
|
-
const client = createClient(vi.fn(async () => { throw new Error('RevenueCat unavailable'); }));
|
|
2000
|
-
const response = await postWebhook(store, payload, undefined, client);
|
|
2001
|
-
|
|
2002
|
-
expect(response.statusCode).toBe(503);
|
|
2003
|
-
expect(store.reconcile).not.toHaveBeenCalled();
|
|
2004
|
-
expect(store.fail).toHaveBeenCalledWith('event-1', 'claim-token', 'RevenueCat unavailable');
|
|
2005
|
-
});
|
|
2006
|
-
|
|
2007
|
-
it('retries the complete reconciliation after an atomic database write failure', async () => {
|
|
2008
|
-
const reconcile = vi.fn()
|
|
2009
|
-
.mockRejectedValueOnce(new Error('database transaction aborted'))
|
|
2010
|
-
.mockResolvedValueOnce([]);
|
|
2011
|
-
const store = createStore({ reconcile });
|
|
2012
|
-
const first = await postWebhook(store);
|
|
2013
|
-
const retry = await postWebhook(store);
|
|
2014
|
-
|
|
2015
|
-
expect(first.statusCode).toBe(503);
|
|
2016
|
-
expect(retry.statusCode).toBe(200);
|
|
2017
|
-
expect(reconcile).toHaveBeenCalledTimes(2);
|
|
2018
|
-
expect(store.fail).toHaveBeenCalledWith('event-1', 'claim-token', 'database transaction aborted');
|
|
2019
|
-
});
|
|
2020
|
-
|
|
2021
|
-
it('batches every entitlement mutation and claim completion in one database transaction', async () => {
|
|
2022
|
-
const { sqlClient } = await import('@shared/db');
|
|
2023
|
-
const { revenueCatWebhookStore } = await import('../src/billing');
|
|
2024
|
-
type TransactionOwner = {
|
|
2025
|
-
transaction(queries: readonly unknown[], options: { isolationLevel: string }): Promise<unknown[]>;
|
|
2026
|
-
};
|
|
2027
|
-
const transaction = vi.spyOn(sqlClient as unknown as TransactionOwner, 'transaction')
|
|
2028
|
-
.mockResolvedValue([
|
|
2029
|
-
[{ userId: 'source-user', revision: 2 }],
|
|
2030
|
-
[{ userId: 'destination-user', revision: 3 }],
|
|
2031
|
-
[{ providerEventId: 'event-transfer' }],
|
|
2032
|
-
]);
|
|
2033
|
-
const eventTimestamp = new Date('2027-01-01T00:00:00.000Z');
|
|
2034
|
-
try {
|
|
2035
|
-
const completed = await revenueCatWebhookStore.reconcile('event-transfer', 'claim-token', [
|
|
2036
|
-
{ userId: 'source-user', entitlement: 'free', status: 'expired', expiresAt: null, eventTimestamp },
|
|
2037
|
-
{ userId: 'destination-user', entitlement: 'pro', status: 'active', expiresAt: null, eventTimestamp },
|
|
2038
|
-
]);
|
|
2039
|
-
expect(completed).toEqual([
|
|
2040
|
-
{ userId: 'source-user', revision: 2 },
|
|
2041
|
-
{ userId: 'destination-user', revision: 3 },
|
|
2042
|
-
]);
|
|
2043
|
-
expect(transaction).toHaveBeenCalledOnce();
|
|
2044
|
-
expect(transaction.mock.calls[0]?.[0]).toHaveLength(3);
|
|
2045
|
-
expect(transaction.mock.calls[0]?.[1]).toEqual({ isolationLevel: 'ReadCommitted' });
|
|
2046
|
-
} finally {
|
|
2047
|
-
transaction.mockRestore();
|
|
2048
|
-
}
|
|
2049
|
-
});
|
|
2050
|
-
|
|
2051
|
-
it('uses a webhook-specific limiter instead of consuming the global IP bucket', async () => {
|
|
2052
|
-
const store = createStore({ claim: vi.fn(async () => ({ status: 'processed' as const })) });
|
|
2053
|
-
const app = await webhookApp(store, createClient(), 1);
|
|
2054
|
-
const first = await app.inject({
|
|
2055
|
-
method: 'POST', url: '/api/webhooks/revenuecat',
|
|
2056
|
-
headers: { authorization: 'Bearer ' + webhookSecret }, payload,
|
|
2057
|
-
});
|
|
2058
|
-
const second = await app.inject({
|
|
2059
|
-
method: 'POST', url: '/api/webhooks/revenuecat',
|
|
2060
|
-
headers: { authorization: 'Bearer ' + webhookSecret },
|
|
2061
|
-
payload: { event: { ...payload.event, id: 'event-2' } },
|
|
2062
|
-
});
|
|
2063
|
-
expect(first.statusCode).toBe(200);
|
|
2064
|
-
expect(second.statusCode).toBe(200);
|
|
2065
|
-
await app.close();
|
|
2066
|
-
});
|
|
2067
|
-
|
|
2068
|
-
it('URL-encodes subscriber IDs and authenticates the server-side REST request', async () => {
|
|
2069
|
-
const { createRevenueCatClient } = await import('../src/billing');
|
|
2070
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ subscriber: activeSubscriber }), {
|
|
2071
|
-
status: 200,
|
|
2072
|
-
headers: { 'content-type': 'application/json' },
|
|
2073
|
-
}));
|
|
2074
|
-
const apiKey = syntheticSecret('revenuecat');
|
|
2075
|
-
const client = createRevenueCatClient(apiKey, fetchMock as typeof fetch);
|
|
2076
|
-
await client.getSubscriber('user/a b');
|
|
2077
|
-
|
|
2078
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
2079
|
-
'https://api.revenuecat.com/v1/subscribers/user%2Fa%20b',
|
|
2080
|
-
expect.objectContaining({
|
|
2081
|
-
method: 'GET',
|
|
2082
|
-
headers: expect.objectContaining({ authorization: 'Bearer ' + apiKey }),
|
|
2083
|
-
}),
|
|
2084
|
-
);
|
|
2085
|
-
});
|
|
2086
|
-
});
|
|
2087
|
-
`;
|
|
2088
|
-
}
|
|
2089
|
-
function storageRouteTestSource(options) {
|
|
2090
|
-
return `import rateLimit from '@fastify/rate-limit';
|
|
2091
|
-
import Fastify from 'fastify';
|
|
2092
|
-
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2093
|
-
import type { AppEnv } from '../src/env';
|
|
2094
|
-
import type { ObjectGateway, StorageService, UploadStore } from '../src/storage';
|
|
2095
|
-
|
|
2096
|
-
const env: AppEnv = {
|
|
2097
|
-
NODE_ENV: 'test',
|
|
2098
|
-
PORT: 8787,
|
|
2099
|
-
TRUST_PROXY_HOPS: 0,
|
|
2100
|
-
CORS_ORIGINS: [],
|
|
2101
|
-
ANHEDRAL_DEMO: 'true',
|
|
2102
|
-
DATABASE_URL: 'postgresql://user:pass@localhost:5432/test',
|
|
2103
|
-
CLERK_PUBLISHABLE_KEY: undefined,
|
|
2104
|
-
CLERK_SECRET_KEY: undefined,
|
|
2105
|
-
${options.features.billing ? " RC_WEBHOOK_SECRET: undefined,\n RC_SECRET_API_KEY: undefined,\n RC_ENTITLEMENT_ID: 'pro',\n ABLY_API_KEY: undefined,\n" : ''} R2_ACCOUNT_ID: 'account',
|
|
2106
|
-
R2_ACCESS_KEY_ID: 'access',
|
|
2107
|
-
R2_SECRET_ACCESS_KEY: 'secret',
|
|
2108
|
-
BASE_URL: 'http://localhost:8787',
|
|
2109
|
-
R2_BUCKET_NAME: 'bucket',
|
|
2110
|
-
R2_PREFIX: 'storage',
|
|
2111
|
-
R2_PROXY_READ_URL_TTL_SECONDS: 600,
|
|
2112
|
-
CLOUDFLARE_API_TOKEN: undefined,
|
|
2113
|
-
CRON_SECRET: 'test-cron-secret-1234',
|
|
2114
|
-
};
|
|
2115
|
-
|
|
2116
|
-
beforeAll(() => {
|
|
2117
|
-
process.env.DATABASE_URL ||= env.DATABASE_URL;
|
|
2118
|
-
});
|
|
2119
|
-
|
|
2120
|
-
async function routeApp(storageService: StorageService) {
|
|
2121
|
-
const { routes } = await import('../src/routes');
|
|
2122
|
-
const app = Fastify();
|
|
2123
|
-
await app.register(rateLimit, { max: 120, timeWindow: '1 minute' });
|
|
2124
|
-
await app.register(routes(env, { storageService }), { prefix: '/api' });
|
|
2125
|
-
return app;
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2128
|
-
describe('storage routes', () => {
|
|
2129
|
-
it('rejects disallowed MIME types and oversized declared uploads before minting a URL', async () => {
|
|
2130
|
-
const service: StorageService = {
|
|
2131
|
-
createUpload: vi.fn(),
|
|
2132
|
-
confirmUpload: vi.fn(),
|
|
2133
|
-
getUpload: vi.fn(),
|
|
2134
|
-
createReadUrl: vi.fn(),
|
|
2135
|
-
cleanupUploads: vi.fn(),
|
|
2136
|
-
};
|
|
2137
|
-
const app = await routeApp(service);
|
|
2138
|
-
const disallowed = await app.inject({
|
|
2139
|
-
method: 'POST',
|
|
2140
|
-
url: '/api/storage/uploads',
|
|
2141
|
-
payload: { fileName: 'payload.exe', contentType: 'application/octet-stream', sizeBytes: 1024 },
|
|
2142
|
-
});
|
|
2143
|
-
const oversized = await app.inject({
|
|
2144
|
-
method: 'POST',
|
|
2145
|
-
url: '/api/storage/uploads',
|
|
2146
|
-
payload: { fileName: 'large.pdf', contentType: 'application/pdf', sizeBytes: 10 * 1024 * 1024 + 1 },
|
|
2147
|
-
});
|
|
2148
|
-
expect(disallowed.statusCode).toBe(400);
|
|
2149
|
-
expect(oversized.statusCode).toBe(400);
|
|
2150
|
-
expect(service.createUpload).not.toHaveBeenCalled();
|
|
2151
|
-
await app.close();
|
|
2152
|
-
});
|
|
2153
|
-
|
|
2154
|
-
it('applies a small route body limit and a per-user URL-mint rate limit', async () => {
|
|
2155
|
-
const uploadId = '11111111-1111-4111-8111-111111111111';
|
|
2156
|
-
const service: StorageService = {
|
|
2157
|
-
createUpload: vi.fn(async (_userId, input) => ({
|
|
2158
|
-
uploadId,
|
|
2159
|
-
stagingObjectKey: 'storage/staging/demo-object',
|
|
2160
|
-
uploadUrl: 'https://storage.example/upload',
|
|
2161
|
-
expiresIn: 120,
|
|
2162
|
-
requiredHeaders: { 'content-type': input.contentType },
|
|
2163
|
-
sizeBytes: input.sizeBytes,
|
|
2164
|
-
signedContentLength: input.sizeBytes,
|
|
2165
|
-
metadataValidationRequired: true as const,
|
|
2166
|
-
})),
|
|
2167
|
-
confirmUpload: vi.fn(),
|
|
2168
|
-
getUpload: vi.fn(),
|
|
2169
|
-
createReadUrl: vi.fn(),
|
|
2170
|
-
cleanupUploads: vi.fn(async () => ({
|
|
2171
|
-
processed: 0,
|
|
2172
|
-
cleaned: 0,
|
|
2173
|
-
failed: 0,
|
|
2174
|
-
batches: 0,
|
|
2175
|
-
backlogRemaining: false,
|
|
2176
|
-
oldestBacklogCreatedAt: null,
|
|
2177
|
-
itemLimitReached: false,
|
|
2178
|
-
timeLimitReached: false,
|
|
2179
|
-
})),
|
|
2180
|
-
};
|
|
2181
|
-
const app = await routeApp(service);
|
|
2182
|
-
const tooLarge = await app.inject({
|
|
2183
|
-
method: 'POST',
|
|
2184
|
-
url: '/api/storage/uploads',
|
|
2185
|
-
headers: { 'content-type': 'application/json' },
|
|
2186
|
-
payload: JSON.stringify({ padding: 'x'.repeat(17 * 1024) }),
|
|
2187
|
-
});
|
|
2188
|
-
expect(tooLarge.statusCode).toBe(413);
|
|
2189
|
-
await app.close();
|
|
2190
|
-
|
|
2191
|
-
const rateLimitedApp = await routeApp(service);
|
|
2192
|
-
const responses = [];
|
|
2193
|
-
for (let attempt = 0; attempt < 11; attempt += 1) {
|
|
2194
|
-
responses.push(await rateLimitedApp.inject({
|
|
2195
|
-
method: 'POST',
|
|
2196
|
-
url: '/api/storage/uploads',
|
|
2197
|
-
payload: { fileName: \`photo-\${attempt}.jpg\`, contentType: 'image/jpeg', sizeBytes: 1024 },
|
|
2198
|
-
}));
|
|
2199
|
-
}
|
|
2200
|
-
expect(responses.slice(0, 10).every((response) => response.statusCode === 200)).toBe(true);
|
|
2201
|
-
expect(responses[10]?.statusCode).toBe(429);
|
|
2202
|
-
await rateLimitedApp.close();
|
|
2203
|
-
});
|
|
2204
|
-
|
|
2205
|
-
it('protects bounded storage cleanup with the cron bearer secret', async () => {
|
|
2206
|
-
const service: StorageService = {
|
|
2207
|
-
createUpload: vi.fn(),
|
|
2208
|
-
confirmUpload: vi.fn(),
|
|
2209
|
-
getUpload: vi.fn(),
|
|
2210
|
-
createReadUrl: vi.fn(),
|
|
2211
|
-
cleanupUploads: vi.fn(async () => ({
|
|
2212
|
-
processed: 2,
|
|
2213
|
-
cleaned: 2,
|
|
2214
|
-
failed: 0,
|
|
2215
|
-
batches: 2,
|
|
2216
|
-
backlogRemaining: true,
|
|
2217
|
-
oldestBacklogCreatedAt: '2026-01-01T00:00:00.000Z',
|
|
2218
|
-
itemLimitReached: true,
|
|
2219
|
-
timeLimitReached: false,
|
|
2220
|
-
})),
|
|
2221
|
-
};
|
|
2222
|
-
const app = await routeApp(service);
|
|
2223
|
-
const denied = await app.inject({ method: 'GET', url: '/api/internal/storage/cleanup' });
|
|
2224
|
-
const allowed = await app.inject({
|
|
2225
|
-
method: 'GET',
|
|
2226
|
-
url: '/api/internal/storage/cleanup',
|
|
2227
|
-
headers: { authorization: 'Bearer test-cron-secret-1234' },
|
|
2228
|
-
});
|
|
2229
|
-
expect(denied.statusCode).toBe(401);
|
|
2230
|
-
expect(allowed.statusCode).toBe(200);
|
|
2231
|
-
expect(allowed.json()).toEqual({
|
|
2232
|
-
ok: true,
|
|
2233
|
-
processed: 2,
|
|
2234
|
-
cleaned: 2,
|
|
2235
|
-
failed: 0,
|
|
2236
|
-
batches: 2,
|
|
2237
|
-
backlogRemaining: true,
|
|
2238
|
-
oldestBacklogCreatedAt: '2026-01-01T00:00:00.000Z',
|
|
2239
|
-
itemLimitReached: true,
|
|
2240
|
-
timeLimitReached: false,
|
|
2241
|
-
});
|
|
2242
|
-
vi.mocked(service.cleanupUploads).mockResolvedValueOnce({
|
|
2243
|
-
processed: 1,
|
|
2244
|
-
cleaned: 1,
|
|
2245
|
-
failed: 0,
|
|
2246
|
-
batches: 1,
|
|
2247
|
-
backlogRemaining: true,
|
|
2248
|
-
oldestBacklogCreatedAt: '2026-01-01T00:00:00.000Z',
|
|
2249
|
-
itemLimitReached: false,
|
|
2250
|
-
timeLimitReached: true,
|
|
2251
|
-
});
|
|
2252
|
-
const timedOut = await app.inject({
|
|
2253
|
-
method: 'GET',
|
|
2254
|
-
url: '/api/internal/storage/cleanup',
|
|
2255
|
-
headers: { authorization: 'Bearer test-cron-secret-1234' },
|
|
2256
|
-
});
|
|
2257
|
-
expect(timedOut.statusCode).toBe(503);
|
|
2258
|
-
expect(timedOut.json()).toEqual(expect.objectContaining({
|
|
2259
|
-
ok: false,
|
|
2260
|
-
failed: 0,
|
|
2261
|
-
timeLimitReached: true,
|
|
2262
|
-
backlogRemaining: true,
|
|
2263
|
-
}));
|
|
2264
|
-
expect(service.cleanupUploads).toHaveBeenCalledTimes(2);
|
|
2265
|
-
await app.close();
|
|
2266
|
-
});
|
|
2267
|
-
|
|
2268
|
-
it('returns an authenticated short-lived private read URL for an owned upload', async () => {
|
|
2269
|
-
const uploadId = '11111111-1111-4111-8111-111111111111';
|
|
2270
|
-
const service: StorageService = {
|
|
2271
|
-
createUpload: vi.fn(),
|
|
2272
|
-
confirmUpload: vi.fn(),
|
|
2273
|
-
getUpload: vi.fn(),
|
|
2274
|
-
createReadUrl: vi.fn(async () => ({
|
|
2275
|
-
url: 'https://account.r2.cloudflarestorage.com/bucket/object?X-Amz-Signature=test',
|
|
2276
|
-
expiresIn: 600,
|
|
2277
|
-
})),
|
|
2278
|
-
cleanupUploads: vi.fn(),
|
|
2279
|
-
};
|
|
2280
|
-
const app = await routeApp(service);
|
|
2281
|
-
const response = await app.inject({
|
|
2282
|
-
method: 'GET',
|
|
2283
|
-
url: \`/api/storage/uploads/\${uploadId}/read-url\`,
|
|
2284
|
-
});
|
|
2285
|
-
expect(response.statusCode).toBe(200);
|
|
2286
|
-
expect(response.json()).toEqual(expect.objectContaining({ expiresIn: 600 }));
|
|
2287
|
-
expect(service.createReadUrl).toHaveBeenCalledWith('demo-user', uploadId);
|
|
2288
|
-
await app.close();
|
|
2289
|
-
});
|
|
2290
|
-
});
|
|
2291
|
-
|
|
2292
|
-
describe('storage reservation policy', () => {
|
|
2293
|
-
it('expires stale pending rows under the user lock and distinguishes issuance-rate from quota limits', async () => {
|
|
2294
|
-
const { sqlClient } = await import('@shared/db');
|
|
2295
|
-
const {
|
|
2296
|
-
MAX_UPLOAD_URL_ISSUANCES_PER_USER_PER_HOUR,
|
|
2297
|
-
StorageError,
|
|
2298
|
-
UPLOAD_CLEANUP_GRACE_MS,
|
|
2299
|
-
uploadStore,
|
|
2300
|
-
} = await import('../src/storage');
|
|
2301
|
-
type QueryToken = { text: string; values: unknown[] };
|
|
2302
|
-
type TransactionOwner = {
|
|
2303
|
-
transaction(
|
|
2304
|
-
build: (txn: (strings: TemplateStringsArray, ...values: unknown[]) => QueryToken) => QueryToken[],
|
|
2305
|
-
options: { isolationLevel: string },
|
|
2306
|
-
): Promise<unknown[]>;
|
|
2307
|
-
};
|
|
2308
|
-
const transactionOwner = sqlClient as unknown as TransactionOwner;
|
|
2309
|
-
const transaction = vi.spyOn(transactionOwner, 'transaction');
|
|
2310
|
-
const reservation = {
|
|
2311
|
-
id: '11111111-1111-4111-8111-111111111111',
|
|
2312
|
-
userId: 'demo-user',
|
|
2313
|
-
stagingObjectKey: 'storage/staging/demo-user/new--photo.jpg',
|
|
2314
|
-
contentType: 'image/jpeg',
|
|
2315
|
-
expectedSize: 1024,
|
|
2316
|
-
uploadUrlExpiresAt: new Date(Date.now() + 120_000),
|
|
2317
|
-
};
|
|
2318
|
-
let statements: QueryToken[] = [];
|
|
2319
|
-
const respondWith = (results: unknown[]) => {
|
|
2320
|
-
transaction.mockImplementationOnce(async (build) => {
|
|
2321
|
-
statements = build((strings, ...values) => ({ text: strings.join('?'), values }));
|
|
2322
|
-
return results;
|
|
2323
|
-
});
|
|
2324
|
-
};
|
|
2325
|
-
|
|
2326
|
-
try {
|
|
2327
|
-
respondWith([[], [], [{ recentIssuances: 4 }], [{ id: reservation.id }]]);
|
|
2328
|
-
await uploadStore.reserve(reservation);
|
|
2329
|
-
expect(statements).toHaveLength(4);
|
|
2330
|
-
expect(statements[0]?.text).toContain('pg_advisory_xact_lock');
|
|
2331
|
-
expect(statements[1]?.text).toContain('SET "status"');
|
|
2332
|
-
expect(statements[1]?.text).toContain('"rejection_reason"');
|
|
2333
|
-
expect(statements[1]?.text).toContain('"upload_url_expires_at" <= now()');
|
|
2334
|
-
expect(statements[1]?.values).toContain(UPLOAD_CLEANUP_GRACE_MS);
|
|
2335
|
-
expect(statements[2]?.text).toContain("interval '1 hour'");
|
|
2336
|
-
expect(statements[3]?.text).toContain('"status"');
|
|
2337
|
-
|
|
2338
|
-
respondWith([[], [], [{ recentIssuances: MAX_UPLOAD_URL_ISSUANCES_PER_USER_PER_HOUR }], []]);
|
|
2339
|
-
await expect(uploadStore.reserve(reservation)).rejects.toMatchObject({
|
|
2340
|
-
code: 'upload_rate_exceeded',
|
|
2341
|
-
statusCode: 429,
|
|
2342
|
-
} satisfies Partial<InstanceType<typeof StorageError>>);
|
|
2343
|
-
|
|
2344
|
-
respondWith([[], [], [{ recentIssuances: 2 }], []]);
|
|
2345
|
-
await expect(uploadStore.reserve(reservation)).rejects.toMatchObject({
|
|
2346
|
-
code: 'upload_quota_exceeded',
|
|
2347
|
-
statusCode: 429,
|
|
2348
|
-
} satisfies Partial<InstanceType<typeof StorageError>>);
|
|
2349
|
-
} finally {
|
|
2350
|
-
transaction.mockRestore();
|
|
2351
|
-
}
|
|
2352
|
-
});
|
|
2353
|
-
});
|
|
2354
|
-
|
|
2355
|
-
describe('R2 upload confirmation', () => {
|
|
2356
|
-
it('cryptographically binds the declared Content-Length into the presigned PUT', async () => {
|
|
2357
|
-
const { createObjectGateway } = await import('../src/storage');
|
|
2358
|
-
const signedUrl = await createObjectGateway(env).signPut('storage/staging/demo-user/object', 'image/jpeg', 1024, 120);
|
|
2359
|
-
const signedHeaders = new URL(signedUrl).searchParams.get('X-Amz-SignedHeaders')?.split(';') ?? [];
|
|
2360
|
-
expect(signedHeaders).toEqual(expect.arrayContaining(['content-length', 'content-type']));
|
|
2361
|
-
});
|
|
2362
|
-
|
|
2363
|
-
it('authorizes private reads by upload owner and applies the configured TTL', async () => {
|
|
2364
|
-
const confirmed = {
|
|
2365
|
-
id: '11111111-1111-4111-8111-111111111111',
|
|
2366
|
-
userId: 'demo-user',
|
|
2367
|
-
stagingObjectKey: 'storage/staging/demo-user/source--photo.jpg',
|
|
2368
|
-
objectKey: 'storage/confirmed/demo-user/11111111-1111-4111-8111-111111111111--photo.jpg',
|
|
2369
|
-
contentType: 'image/jpeg',
|
|
2370
|
-
expectedSize: 1024,
|
|
2371
|
-
actualSize: 1024,
|
|
2372
|
-
status: 'confirmed',
|
|
2373
|
-
rejectionReason: null,
|
|
2374
|
-
uploadUrlExpiresAt: new Date(),
|
|
2375
|
-
stagingDeletedAt: new Date(),
|
|
2376
|
-
confirmedAt: new Date(),
|
|
2377
|
-
createdAt: new Date(),
|
|
2378
|
-
} satisfies NonNullable<Awaited<ReturnType<UploadStore['find']>>>;
|
|
2379
|
-
const store: UploadStore = {
|
|
2380
|
-
reserve: vi.fn(),
|
|
2381
|
-
find: vi.fn(async (id, userId) => id === confirmed.id && userId === confirmed.userId ? confirmed : null),
|
|
2382
|
-
confirm: vi.fn(),
|
|
2383
|
-
reject: vi.fn(),
|
|
2384
|
-
listCleanupCandidates: vi.fn(async () => []),
|
|
2385
|
-
completeStagingCleanup: vi.fn(),
|
|
2386
|
-
};
|
|
2387
|
-
const signGet = vi.fn(async () => 'https://account.r2.cloudflarestorage.com/signed');
|
|
2388
|
-
const objects: ObjectGateway = {
|
|
2389
|
-
signPut: vi.fn(),
|
|
2390
|
-
signGet,
|
|
2391
|
-
head: vi.fn(),
|
|
2392
|
-
copy: vi.fn(),
|
|
2393
|
-
delete: vi.fn(),
|
|
2394
|
-
};
|
|
2395
|
-
const { createStorageService, StorageError } = await import('../src/storage');
|
|
2396
|
-
const service = createStorageService(env, { store, objects });
|
|
2397
|
-
await expect(service.createReadUrl('other-user', confirmed.id)).rejects.toMatchObject({
|
|
2398
|
-
code: 'asset_not_found',
|
|
2399
|
-
statusCode: 404,
|
|
2400
|
-
} satisfies Partial<InstanceType<typeof StorageError>>);
|
|
2401
|
-
await expect(service.createReadUrl(confirmed.userId, confirmed.id)).resolves.toEqual({
|
|
2402
|
-
url: 'https://account.r2.cloudflarestorage.com/signed',
|
|
2403
|
-
expiresIn: 600,
|
|
2404
|
-
});
|
|
2405
|
-
expect(signGet).toHaveBeenCalledWith(confirmed.objectKey, 600);
|
|
2406
|
-
});
|
|
2407
|
-
|
|
2408
|
-
it('keeps invalid objects pending until deletion succeeds, then rejects the upload', async () => {
|
|
2409
|
-
let row: Awaited<ReturnType<UploadStore['find']>> = null;
|
|
2410
|
-
const store: UploadStore = {
|
|
2411
|
-
reserve: vi.fn(async (input) => {
|
|
2412
|
-
row = {
|
|
2413
|
-
...input,
|
|
2414
|
-
objectKey: null,
|
|
2415
|
-
actualSize: null,
|
|
2416
|
-
status: 'pending',
|
|
2417
|
-
rejectionReason: null,
|
|
2418
|
-
stagingDeletedAt: null,
|
|
2419
|
-
confirmedAt: null,
|
|
2420
|
-
createdAt: new Date(),
|
|
2421
|
-
};
|
|
2422
|
-
}),
|
|
2423
|
-
find: vi.fn(async () => row),
|
|
2424
|
-
confirm: vi.fn(async () => { throw new Error('confirm should not be called'); }),
|
|
2425
|
-
reject: vi.fn(async () => undefined),
|
|
2426
|
-
listCleanupCandidates: vi.fn(async () => []),
|
|
2427
|
-
completeStagingCleanup: vi.fn(async () => undefined),
|
|
2428
|
-
};
|
|
2429
|
-
const objects: ObjectGateway = {
|
|
2430
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2431
|
-
head: vi.fn(async () => ({ sizeBytes: 2048, contentType: 'image/jpeg', etag: 'etag-1' })),
|
|
2432
|
-
copy: vi.fn(async () => undefined),
|
|
2433
|
-
delete: vi.fn()
|
|
2434
|
-
.mockRejectedValueOnce(new Error('temporary R2 delete failure'))
|
|
2435
|
-
.mockResolvedValue(undefined),
|
|
2436
|
-
};
|
|
2437
|
-
const { createStorageService, StorageError } = await import('../src/storage');
|
|
2438
|
-
const service = createStorageService(env, { store, objects });
|
|
2439
|
-
const created = await service.createUpload('demo-user', {
|
|
2440
|
-
fileName: 'photo.jpg',
|
|
2441
|
-
contentType: 'image/jpeg',
|
|
2442
|
-
sizeBytes: 1024,
|
|
2443
|
-
});
|
|
2444
|
-
expect(created.expiresIn).toBe(120);
|
|
2445
|
-
expect(created.requiredHeaders).toEqual({ 'content-type': 'image/jpeg' });
|
|
2446
|
-
expect(created.signedContentLength).toBe(1024);
|
|
2447
|
-
expect(created.metadataValidationRequired).toBe(true);
|
|
2448
|
-
expect(objects.signPut).toHaveBeenCalledWith(created.stagingObjectKey, 'image/jpeg', 1024, 120);
|
|
2449
|
-
|
|
2450
|
-
await expect(service.confirmUpload('demo-user', created.uploadId)).rejects.toMatchObject({
|
|
2451
|
-
code: 'upload_cleanup_failed',
|
|
2452
|
-
statusCode: 503,
|
|
2453
|
-
} satisfies Partial<InstanceType<typeof StorageError>>);
|
|
2454
|
-
expect(store.reject).not.toHaveBeenCalled();
|
|
2455
|
-
|
|
2456
|
-
await expect(service.confirmUpload('demo-user', created.uploadId)).rejects.toMatchObject({
|
|
2457
|
-
code: 'upload_metadata_mismatch',
|
|
2458
|
-
statusCode: 422,
|
|
2459
|
-
} satisfies Partial<InstanceType<typeof StorageError>>);
|
|
2460
|
-
expect(objects.delete).toHaveBeenCalledTimes(2);
|
|
2461
|
-
expect(objects.delete).toHaveBeenLastCalledWith(created.stagingObjectKey);
|
|
2462
|
-
expect(store.reject).toHaveBeenCalledWith(created.uploadId, 'demo-user', 2048, 'size_mismatch');
|
|
2463
|
-
});
|
|
2464
|
-
|
|
2465
|
-
it('promotes the validated ETag to a distinct immutable key and deletes staging', async () => {
|
|
2466
|
-
let row: Awaited<ReturnType<UploadStore['find']>> = null;
|
|
2467
|
-
const store: UploadStore = {
|
|
2468
|
-
reserve: vi.fn(async (input) => {
|
|
2469
|
-
row = {
|
|
2470
|
-
...input,
|
|
2471
|
-
objectKey: null,
|
|
2472
|
-
actualSize: null,
|
|
2473
|
-
status: 'pending',
|
|
2474
|
-
rejectionReason: null,
|
|
2475
|
-
stagingDeletedAt: null,
|
|
2476
|
-
confirmedAt: null,
|
|
2477
|
-
createdAt: new Date(),
|
|
2478
|
-
};
|
|
2479
|
-
}),
|
|
2480
|
-
find: vi.fn(async () => row),
|
|
2481
|
-
confirm: vi.fn(async (_id, _userId, finalObjectKey, actualSize) => {
|
|
2482
|
-
if (!row) throw new Error('missing upload');
|
|
2483
|
-
row = { ...row, objectKey: finalObjectKey, actualSize, status: 'confirmed', confirmedAt: new Date() };
|
|
2484
|
-
return row;
|
|
2485
|
-
}),
|
|
2486
|
-
reject: vi.fn(async () => undefined),
|
|
2487
|
-
listCleanupCandidates: vi.fn(async () => []),
|
|
2488
|
-
completeStagingCleanup: vi.fn(async () => undefined),
|
|
2489
|
-
};
|
|
2490
|
-
const objects: ObjectGateway = {
|
|
2491
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2492
|
-
head: vi.fn(async () => ({ sizeBytes: 1024, contentType: 'image/jpeg', etag: 'etag-confirmed' })),
|
|
2493
|
-
copy: vi.fn(async () => undefined),
|
|
2494
|
-
delete: vi.fn(async () => undefined),
|
|
2495
|
-
};
|
|
2496
|
-
const { createStorageService } = await import('../src/storage');
|
|
2497
|
-
const service = createStorageService(env, { store, objects });
|
|
2498
|
-
const created = await service.createUpload('demo-user', {
|
|
2499
|
-
fileName: 'photo.jpg',
|
|
2500
|
-
contentType: 'image/jpeg',
|
|
2501
|
-
sizeBytes: 1024,
|
|
2502
|
-
});
|
|
2503
|
-
const result = await service.confirmUpload('demo-user', created.uploadId);
|
|
2504
|
-
const copyCall = vi.mocked(objects.copy).mock.calls[0];
|
|
2505
|
-
expect(copyCall?.[0]).toBe(created.stagingObjectKey);
|
|
2506
|
-
expect(copyCall?.[1]).not.toBe(created.stagingObjectKey);
|
|
2507
|
-
expect(copyCall?.[1]).toMatch(/^storage\\/confirmed\\//);
|
|
2508
|
-
expect(copyCall?.[2]).toBe('etag-confirmed');
|
|
2509
|
-
expect(result.upload.objectKey).toBe(copyCall?.[1]);
|
|
2510
|
-
expect(result.upload.contentTrust).toBe('untrusted');
|
|
2511
|
-
expect(objects.delete).toHaveBeenCalledWith(created.stagingObjectKey);
|
|
2512
|
-
});
|
|
2513
|
-
|
|
2514
|
-
it('returns the committed upload when another confirmer removes staging first', async () => {
|
|
2515
|
-
let row: Awaited<ReturnType<UploadStore['find']>> = null;
|
|
2516
|
-
const store: UploadStore = {
|
|
2517
|
-
reserve: vi.fn(async (input) => {
|
|
2518
|
-
row = {
|
|
2519
|
-
...input,
|
|
2520
|
-
objectKey: null,
|
|
2521
|
-
actualSize: null,
|
|
2522
|
-
status: 'pending',
|
|
2523
|
-
rejectionReason: null,
|
|
2524
|
-
stagingDeletedAt: null,
|
|
2525
|
-
confirmedAt: null,
|
|
2526
|
-
createdAt: new Date(),
|
|
2527
|
-
};
|
|
2528
|
-
}),
|
|
2529
|
-
find: vi.fn(async () => row),
|
|
2530
|
-
confirm: vi.fn(async () => { throw new Error('the other confirmer already committed'); }),
|
|
2531
|
-
reject: vi.fn(async () => undefined),
|
|
2532
|
-
listCleanupCandidates: vi.fn(async () => []),
|
|
2533
|
-
completeStagingCleanup: vi.fn(async () => undefined),
|
|
2534
|
-
};
|
|
2535
|
-
const objects: ObjectGateway = {
|
|
2536
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2537
|
-
head: vi.fn(async () => ({ sizeBytes: 1024, contentType: 'image/jpeg', etag: 'etag-concurrent' })),
|
|
2538
|
-
copy: vi.fn(async (_source, destination) => {
|
|
2539
|
-
if (!row) throw new Error('missing upload');
|
|
2540
|
-
row = { ...row, objectKey: destination, actualSize: 1024, status: 'confirmed', confirmedAt: new Date() };
|
|
2541
|
-
throw Object.assign(new Error('staging no longer exists'), { name: 'NoSuchKey' });
|
|
2542
|
-
}),
|
|
2543
|
-
delete: vi.fn(async () => undefined),
|
|
2544
|
-
};
|
|
2545
|
-
const { createStorageService } = await import('../src/storage');
|
|
2546
|
-
const service = createStorageService(env, { store, objects });
|
|
2547
|
-
const created = await service.createUpload('demo-user', {
|
|
2548
|
-
fileName: 'photo.jpg',
|
|
2549
|
-
contentType: 'image/jpeg',
|
|
2550
|
-
sizeBytes: 1024,
|
|
2551
|
-
});
|
|
2552
|
-
|
|
2553
|
-
const result = await service.confirmUpload('demo-user', created.uploadId);
|
|
2554
|
-
expect(result.upload.status).toBe('confirmed');
|
|
2555
|
-
expect(result.upload.objectKey).toMatch(/^storage\\/confirmed\\//);
|
|
2556
|
-
expect(result.upload.contentTrust).toBe('untrusted');
|
|
2557
|
-
expect(store.confirm).not.toHaveBeenCalled();
|
|
2558
|
-
expect(objects.delete).toHaveBeenCalledWith(created.stagingObjectKey);
|
|
2559
|
-
});
|
|
2560
|
-
|
|
2561
|
-
it('deletes an unreferenced promoted object when a concurrent rejection wins the database CAS', async () => {
|
|
2562
|
-
const { createStorageService, StorageError } = await import('../src/storage');
|
|
2563
|
-
let row: Awaited<ReturnType<UploadStore['find']>> = null;
|
|
2564
|
-
const store: UploadStore = {
|
|
2565
|
-
reserve: vi.fn(async (input) => {
|
|
2566
|
-
row = {
|
|
2567
|
-
...input,
|
|
2568
|
-
objectKey: null,
|
|
2569
|
-
actualSize: null,
|
|
2570
|
-
status: 'pending',
|
|
2571
|
-
rejectionReason: null,
|
|
2572
|
-
stagingDeletedAt: null,
|
|
2573
|
-
confirmedAt: null,
|
|
2574
|
-
createdAt: new Date(),
|
|
2575
|
-
};
|
|
2576
|
-
}),
|
|
2577
|
-
find: vi.fn(async () => row),
|
|
2578
|
-
confirm: vi.fn(async () => {
|
|
2579
|
-
if (!row) throw new Error('missing upload');
|
|
2580
|
-
row = { ...row, status: 'rejected', rejectionReason: 'concurrent_rejection' };
|
|
2581
|
-
throw new StorageError('Upload state changed while it was being finalized', 409, 'upload_state_changed');
|
|
2582
|
-
}),
|
|
2583
|
-
reject: vi.fn(async () => undefined),
|
|
2584
|
-
listCleanupCandidates: vi.fn(async () => []),
|
|
2585
|
-
completeStagingCleanup: vi.fn(async () => undefined),
|
|
2586
|
-
};
|
|
2587
|
-
const objects: ObjectGateway = {
|
|
2588
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2589
|
-
head: vi.fn(async () => ({ sizeBytes: 1024, contentType: 'image/jpeg', etag: 'etag-race' })),
|
|
2590
|
-
copy: vi.fn(async () => undefined),
|
|
2591
|
-
delete: vi.fn(async () => undefined),
|
|
2592
|
-
};
|
|
2593
|
-
const service = createStorageService(env, { store, objects });
|
|
2594
|
-
const created = await service.createUpload('demo-user', {
|
|
2595
|
-
fileName: 'photo.jpg',
|
|
2596
|
-
contentType: 'image/jpeg',
|
|
2597
|
-
sizeBytes: 1024,
|
|
2598
|
-
});
|
|
2599
|
-
|
|
2600
|
-
await expect(service.confirmUpload('demo-user', created.uploadId)).rejects.toMatchObject({
|
|
2601
|
-
code: 'upload_rejected',
|
|
2602
|
-
statusCode: 409,
|
|
2603
|
-
});
|
|
2604
|
-
const promotedKey = vi.mocked(objects.copy).mock.calls[0]?.[1];
|
|
2605
|
-
expect(promotedKey).toMatch(/^storage\\/confirmed\\//);
|
|
2606
|
-
expect(objects.delete).toHaveBeenCalledWith(promotedKey);
|
|
2607
|
-
});
|
|
2608
|
-
|
|
2609
|
-
it('retains the promoted object when a database failure has an indeterminate commit outcome', async () => {
|
|
2610
|
-
let row: Awaited<ReturnType<UploadStore['find']>> = null;
|
|
2611
|
-
const store: UploadStore = {
|
|
2612
|
-
reserve: vi.fn(async (input) => {
|
|
2613
|
-
row = {
|
|
2614
|
-
...input,
|
|
2615
|
-
objectKey: null,
|
|
2616
|
-
actualSize: null,
|
|
2617
|
-
status: 'pending',
|
|
2618
|
-
rejectionReason: null,
|
|
2619
|
-
stagingDeletedAt: null,
|
|
2620
|
-
confirmedAt: null,
|
|
2621
|
-
createdAt: new Date(),
|
|
2622
|
-
};
|
|
2623
|
-
}),
|
|
2624
|
-
find: vi.fn(async () => row),
|
|
2625
|
-
confirm: vi.fn(async () => { throw new Error('database connection reset'); }),
|
|
2626
|
-
reject: vi.fn(async (_id, _userId, actualSize, reason) => {
|
|
2627
|
-
if (row?.status === 'pending') row = { ...row, status: 'rejected', actualSize, rejectionReason: reason };
|
|
2628
|
-
}),
|
|
2629
|
-
listCleanupCandidates: vi.fn(async (expiredBefore) => (
|
|
2630
|
-
row && !row.stagingDeletedAt && row.uploadUrlExpiresAt.getTime() <= expiredBefore.getTime() ? [row] : []
|
|
2631
|
-
)),
|
|
2632
|
-
completeStagingCleanup: vi.fn(async (_upload, completedAt) => {
|
|
2633
|
-
if (row) row = { ...row, stagingDeletedAt: completedAt };
|
|
2634
|
-
}),
|
|
2635
|
-
};
|
|
2636
|
-
const objects: ObjectGateway = {
|
|
2637
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2638
|
-
head: vi.fn(async () => ({ sizeBytes: 1024, contentType: 'image/jpeg', etag: 'etag-uncertain' })),
|
|
2639
|
-
copy: vi.fn(async () => undefined),
|
|
2640
|
-
delete: vi.fn(async () => undefined),
|
|
2641
|
-
};
|
|
2642
|
-
const { createStorageService } = await import('../src/storage');
|
|
2643
|
-
const service = createStorageService(env, { store, objects });
|
|
2644
|
-
const created = await service.createUpload('demo-user', {
|
|
2645
|
-
fileName: 'photo.jpg',
|
|
2646
|
-
contentType: 'image/jpeg',
|
|
2647
|
-
sizeBytes: 1024,
|
|
2648
|
-
});
|
|
2649
|
-
|
|
2650
|
-
await expect(service.confirmUpload('demo-user', created.uploadId)).rejects.toMatchObject({
|
|
2651
|
-
code: 'upload_reconciliation_required',
|
|
2652
|
-
statusCode: 503,
|
|
2653
|
-
});
|
|
2654
|
-
expect(objects.copy).toHaveBeenCalledOnce();
|
|
2655
|
-
expect(objects.delete).not.toHaveBeenCalled();
|
|
2656
|
-
|
|
2657
|
-
const promotedKey = vi.mocked(objects.copy).mock.calls[0]?.[1];
|
|
2658
|
-
const reservedRow = await store.find(created.uploadId, 'demo-user');
|
|
2659
|
-
if (!reservedRow) throw new Error('expected reserved upload row');
|
|
2660
|
-
row = {
|
|
2661
|
-
...reservedRow,
|
|
2662
|
-
uploadUrlExpiresAt: new Date(Date.now() - 20 * 60_000),
|
|
2663
|
-
createdAt: new Date(Date.now() - 30 * 60_000),
|
|
2664
|
-
};
|
|
2665
|
-
const cleanup = await service.cleanupUploads();
|
|
2666
|
-
expect(cleanup).toEqual(expect.objectContaining({ cleaned: 1, failed: 0, backlogRemaining: false }));
|
|
2667
|
-
expect(objects.delete).toHaveBeenCalledWith(created.stagingObjectKey);
|
|
2668
|
-
expect(objects.delete).toHaveBeenCalledWith(promotedKey);
|
|
2669
|
-
expect(store.reject).toHaveBeenCalledWith(created.uploadId, 'demo-user', null, 'upload_expired');
|
|
2670
|
-
});
|
|
2671
|
-
|
|
2672
|
-
it('bounds scheduled cleanup and records deletion of expired pending staging objects', async () => {
|
|
2673
|
-
const expired = {
|
|
2674
|
-
id: '11111111-1111-4111-8111-111111111111',
|
|
2675
|
-
userId: 'demo-user',
|
|
2676
|
-
stagingObjectKey: 'storage/staging/demo-user/expired--photo.jpg',
|
|
2677
|
-
objectKey: null,
|
|
2678
|
-
contentType: 'image/jpeg',
|
|
2679
|
-
expectedSize: 1024,
|
|
2680
|
-
actualSize: null,
|
|
2681
|
-
status: 'pending',
|
|
2682
|
-
rejectionReason: null,
|
|
2683
|
-
uploadUrlExpiresAt: new Date(Date.now() - 11 * 60_000),
|
|
2684
|
-
stagingDeletedAt: null,
|
|
2685
|
-
confirmedAt: null,
|
|
2686
|
-
createdAt: new Date(Date.now() - 120_000),
|
|
2687
|
-
} satisfies NonNullable<Awaited<ReturnType<UploadStore['find']>>>;
|
|
2688
|
-
let remaining = [expired];
|
|
2689
|
-
const store: UploadStore = {
|
|
2690
|
-
reserve: vi.fn(async () => undefined),
|
|
2691
|
-
find: vi.fn(async () => expired),
|
|
2692
|
-
confirm: vi.fn(async () => { throw new Error('confirm should not be called'); }),
|
|
2693
|
-
reject: vi.fn(async () => undefined),
|
|
2694
|
-
listCleanupCandidates: vi.fn(async (_now, limit, after) => remaining.filter((upload) => (
|
|
2695
|
-
!after
|
|
2696
|
-
|| upload.createdAt.getTime() > after.createdAt.getTime()
|
|
2697
|
-
|| (upload.createdAt.getTime() === after.createdAt.getTime() && upload.id > after.id)
|
|
2698
|
-
)).slice(0, limit)),
|
|
2699
|
-
completeStagingCleanup: vi.fn(async (upload) => {
|
|
2700
|
-
remaining = remaining.filter((candidate) => candidate.id !== upload.id);
|
|
2701
|
-
}),
|
|
2702
|
-
};
|
|
2703
|
-
const objects: ObjectGateway = {
|
|
2704
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2705
|
-
head: vi.fn(async () => null),
|
|
2706
|
-
copy: vi.fn(async () => undefined),
|
|
2707
|
-
delete: vi.fn(async () => undefined),
|
|
2708
|
-
};
|
|
2709
|
-
const {
|
|
2710
|
-
createStorageService,
|
|
2711
|
-
MAX_UPLOAD_CLEANUP_BATCH_SIZE,
|
|
2712
|
-
UPLOAD_CLEANUP_GRACE_MS,
|
|
2713
|
-
} = await import('../src/storage');
|
|
2714
|
-
const service = createStorageService(env, { store, objects });
|
|
2715
|
-
const result = await service.cleanupUploads(500);
|
|
2716
|
-
|
|
2717
|
-
expect(result).toEqual({
|
|
2718
|
-
processed: 1,
|
|
2719
|
-
cleaned: 1,
|
|
2720
|
-
failed: 0,
|
|
2721
|
-
batches: 1,
|
|
2722
|
-
backlogRemaining: false,
|
|
2723
|
-
oldestBacklogCreatedAt: null,
|
|
2724
|
-
itemLimitReached: false,
|
|
2725
|
-
timeLimitReached: false,
|
|
2726
|
-
});
|
|
2727
|
-
expect(store.listCleanupCandidates).toHaveBeenNthCalledWith(
|
|
2728
|
-
1,
|
|
2729
|
-
expect.any(Date),
|
|
2730
|
-
MAX_UPLOAD_CLEANUP_BATCH_SIZE,
|
|
2731
|
-
undefined,
|
|
2732
|
-
);
|
|
2733
|
-
expect(store.listCleanupCandidates).toHaveBeenLastCalledWith(expect.any(Date), 1);
|
|
2734
|
-
const cutoff = vi.mocked(store.listCleanupCandidates).mock.calls[0]?.[0];
|
|
2735
|
-
expect(Date.now() - (cutoff?.getTime() ?? Date.now())).toBeGreaterThanOrEqual(UPLOAD_CLEANUP_GRACE_MS);
|
|
2736
|
-
expect(objects.delete).toHaveBeenCalledWith(expired.stagingObjectKey);
|
|
2737
|
-
expect(store.completeStagingCleanup).toHaveBeenCalledWith(expired, expect.any(Date));
|
|
2738
|
-
});
|
|
2739
|
-
|
|
2740
|
-
it('drains multiple keyset batches while reporting the oldest remaining backlog item', async () => {
|
|
2741
|
-
const baseCreatedAt = Date.parse('2026-01-01T00:00:00.000Z');
|
|
2742
|
-
const rows = Array.from({ length: 52 }, (_, index) => ({
|
|
2743
|
-
id: String(index).padStart(4, '0'),
|
|
2744
|
-
userId: 'demo-user',
|
|
2745
|
-
stagingObjectKey: \`storage/staging/demo-user/\${index}--photo.jpg\`,
|
|
2746
|
-
objectKey: null,
|
|
2747
|
-
contentType: 'image/jpeg',
|
|
2748
|
-
expectedSize: 1024,
|
|
2749
|
-
actualSize: null,
|
|
2750
|
-
status: 'rejected',
|
|
2751
|
-
rejectionReason: 'upload_expired',
|
|
2752
|
-
uploadUrlExpiresAt: new Date(baseCreatedAt - 20 * 60_000),
|
|
2753
|
-
stagingDeletedAt: null,
|
|
2754
|
-
confirmedAt: null,
|
|
2755
|
-
createdAt: new Date(baseCreatedAt + index * 1000),
|
|
2756
|
-
})) satisfies Array<NonNullable<Awaited<ReturnType<UploadStore['find']>>>>;
|
|
2757
|
-
let remaining = [...rows];
|
|
2758
|
-
const store: UploadStore = {
|
|
2759
|
-
reserve: vi.fn(async () => undefined),
|
|
2760
|
-
find: vi.fn(async () => null),
|
|
2761
|
-
confirm: vi.fn(async () => { throw new Error('confirm should not be called'); }),
|
|
2762
|
-
reject: vi.fn(async () => undefined),
|
|
2763
|
-
listCleanupCandidates: vi.fn(async (_now, limit, after) => remaining.filter((upload) => (
|
|
2764
|
-
!after
|
|
2765
|
-
|| upload.createdAt.getTime() > after.createdAt.getTime()
|
|
2766
|
-
|| (upload.createdAt.getTime() === after.createdAt.getTime() && upload.id > after.id)
|
|
2767
|
-
)).slice(0, limit)),
|
|
2768
|
-
completeStagingCleanup: vi.fn(async (upload) => {
|
|
2769
|
-
remaining = remaining.filter((candidate) => candidate.id !== upload.id);
|
|
2770
|
-
}),
|
|
2771
|
-
};
|
|
2772
|
-
const objects: ObjectGateway = {
|
|
2773
|
-
signPut: vi.fn(async () => 'https://storage.example/upload'),
|
|
2774
|
-
head: vi.fn(async () => null),
|
|
2775
|
-
copy: vi.fn(async () => undefined),
|
|
2776
|
-
delete: vi.fn(async () => undefined),
|
|
2777
|
-
};
|
|
2778
|
-
const { createStorageService, MAX_UPLOAD_CLEANUP_BATCH_SIZE } = await import('../src/storage');
|
|
2779
|
-
const service = createStorageService(env, { store, objects });
|
|
2780
|
-
const result = await service.cleanupUploads(MAX_UPLOAD_CLEANUP_BATCH_SIZE + 1);
|
|
2781
|
-
|
|
2782
|
-
expect(result).toEqual({
|
|
2783
|
-
processed: 51,
|
|
2784
|
-
cleaned: 51,
|
|
2785
|
-
failed: 0,
|
|
2786
|
-
batches: 2,
|
|
2787
|
-
backlogRemaining: true,
|
|
2788
|
-
oldestBacklogCreatedAt: rows[51]?.createdAt.toISOString(),
|
|
2789
|
-
itemLimitReached: true,
|
|
2790
|
-
timeLimitReached: false,
|
|
2791
|
-
});
|
|
2792
|
-
expect(vi.mocked(store.listCleanupCandidates).mock.calls.map((call) => call[1])).toEqual([
|
|
2793
|
-
MAX_UPLOAD_CLEANUP_BATCH_SIZE,
|
|
2794
|
-
1,
|
|
2795
|
-
1,
|
|
2796
|
-
]);
|
|
2797
|
-
expect(objects.delete).toHaveBeenCalledTimes(102);
|
|
2798
|
-
expect(remaining).toEqual([rows[51]]);
|
|
2799
|
-
});
|
|
2800
|
-
});
|
|
2801
|
-
`;
|
|
2802
|
-
}
|
|
2803
|
-
function applicationSource(options, displayName) {
|
|
2804
|
-
const authImport = options.features.auth ? "import { registerAuth } from './auth';\n" : '';
|
|
2805
|
-
const authRegistration = options.features.auth ? ' await registerAuth(app, env);\n' : '';
|
|
2806
|
-
const databaseImport = options.features.database ? "import { sqlClient } from '@shared/db';\n" : '';
|
|
2807
|
-
const readinessTimeout = options.features.database ? `
|
|
2808
|
-
const CRITICAL_DEPENDENCY_TIMEOUT_MS = 2_000;
|
|
2809
|
-
|
|
2810
|
-
async function checkDatabaseReadiness(): Promise<void> {
|
|
2811
|
-
let timeout: NodeJS.Timeout | undefined;
|
|
2812
|
-
try {
|
|
2813
|
-
await Promise.race([
|
|
2814
|
-
sqlClient\`SELECT 1\`,
|
|
2815
|
-
new Promise<never>((_resolve, reject) => {
|
|
2816
|
-
timeout = setTimeout(
|
|
2817
|
-
() => reject(new Error('Database readiness check timed out')),
|
|
2818
|
-
CRITICAL_DEPENDENCY_TIMEOUT_MS,
|
|
2819
|
-
);
|
|
2820
|
-
timeout.unref();
|
|
2821
|
-
}),
|
|
2822
|
-
]);
|
|
2823
|
-
} finally {
|
|
2824
|
-
if (timeout) clearTimeout(timeout);
|
|
2825
|
-
}
|
|
2826
|
-
}
|
|
2827
|
-
` : '';
|
|
2828
|
-
const defaultReadinessCheck = options.features.database ? 'checkDatabaseReadiness' : '(async () => undefined)';
|
|
2829
|
-
return `import Fastify, { type FastifyInstance } from 'fastify';
|
|
2830
|
-
import cors from '@fastify/cors';
|
|
2831
|
-
import compress from '@fastify/compress';
|
|
2832
|
-
import helmet from '@fastify/helmet';
|
|
2833
|
-
import rateLimit from '@fastify/rate-limit';
|
|
2834
|
-
import { loadEnv } from './env';
|
|
2835
|
-
import { routes } from './routes';
|
|
2836
|
-
${authImport}${databaseImport}${readinessTimeout}
|
|
2837
|
-
export type Application = FastifyInstance & { beginShutdown(): void };
|
|
2838
|
-
export interface ApplicationDependencies { checkReadiness?: () => Promise<void> }
|
|
2839
|
-
|
|
2840
|
-
export async function buildApp(dependencies: ApplicationDependencies = {}): Promise<Application> {
|
|
2841
|
-
const env = loadEnv();
|
|
2842
|
-
let ready = false;
|
|
2843
|
-
const app = Fastify({
|
|
2844
|
-
logger: env.NODE_ENV === 'test'
|
|
2845
|
-
? false
|
|
2846
|
-
: { level: env.NODE_ENV === 'production' ? 'warn' : 'info', redact: ['req.headers.authorization'] },
|
|
2847
|
-
trustProxy: env.TRUST_PROXY_HOPS > 0 ? env.TRUST_PROXY_HOPS : false,
|
|
2848
|
-
bodyLimit: 1024 * 1024,
|
|
2849
|
-
}) as unknown as Application;
|
|
2850
|
-
const checkReadiness = dependencies.checkReadiness ?? ${defaultReadinessCheck};
|
|
2851
|
-
const serviceState = {
|
|
2852
|
-
async readiness(): Promise<'ready' | 'unavailable' | 'shutting_down'> {
|
|
2853
|
-
if (!ready) return 'shutting_down';
|
|
2854
|
-
try {
|
|
2855
|
-
await checkReadiness();
|
|
2856
|
-
return 'ready';
|
|
2857
|
-
} catch (error) {
|
|
2858
|
-
const failure = error instanceof Error ? error : new Error('Unknown readiness failure');
|
|
2859
|
-
app.log.warn({ err: failure }, 'critical_dependency_readiness_failed');
|
|
2860
|
-
return 'unavailable';
|
|
2861
|
-
}
|
|
2862
|
-
},
|
|
2863
|
-
};
|
|
2864
|
-
app.beginShutdown = () => { ready = false; };
|
|
2865
|
-
await app.register(cors, { origin: env.CORS_ORIGINS.length > 0 ? env.CORS_ORIGINS : false });
|
|
2866
|
-
await app.register(compress, { global: true });
|
|
2867
|
-
await app.register(helmet, { crossOriginEmbedderPolicy: false });
|
|
2868
|
-
await app.register(rateLimit, { max: 120, timeWindow: '1 minute' });
|
|
2869
|
-
${authRegistration} await app.register(routes(env, { serviceState }), { prefix: '/api' });
|
|
2870
|
-
app.addHook('onClose', async () => { app.beginShutdown(); });
|
|
2871
|
-
app.setErrorHandler((error, request, reply) => {
|
|
2872
|
-
const failure = error instanceof Error ? error : new Error('Unknown request failure');
|
|
2873
|
-
const candidate = error as { statusCode?: unknown; code?: unknown; details?: unknown };
|
|
2874
|
-
const statusCode = typeof candidate.statusCode === 'number'
|
|
2875
|
-
&& Number.isInteger(candidate.statusCode)
|
|
2876
|
-
&& candidate.statusCode >= 400
|
|
2877
|
-
&& candidate.statusCode <= 599
|
|
2878
|
-
? candidate.statusCode
|
|
2879
|
-
: 500;
|
|
2880
|
-
request.log[statusCode >= 500 ? 'error' : 'warn']({
|
|
2881
|
-
err: failure,
|
|
2882
|
-
statusCode,
|
|
2883
|
-
errorCode: typeof candidate.code === 'string' ? candidate.code : failure.name,
|
|
2884
|
-
requestId: request.id,
|
|
2885
|
-
}, 'request_failed');
|
|
2886
|
-
return reply.code(statusCode).send({
|
|
2887
|
-
error: statusCode >= 500
|
|
2888
|
-
? 'server_error'
|
|
2889
|
-
: (typeof candidate.code === 'string' ? candidate.code : failure.name),
|
|
2890
|
-
message: statusCode >= 500 ? 'An unexpected error occurred' : failure.message,
|
|
2891
|
-
...(statusCode < 500 && candidate.details !== undefined ? { details: candidate.details } : {}),
|
|
2892
|
-
});
|
|
2893
|
-
});
|
|
2894
|
-
ready = true;
|
|
2895
|
-
return app;
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
export const serviceName = ${jsString(`${displayName} API`)};
|
|
2899
|
-
`;
|
|
2900
|
-
}
|
|
2901
|
-
export async function scaffoldApi(root, options) {
|
|
2902
|
-
const dir = path.join(root, 'apps/api');
|
|
2903
|
-
const selected = dependenciesFor(options);
|
|
2904
|
-
const coverageBranchThreshold = Object.values(options.features).some(Boolean) ? 55 : 45;
|
|
2905
|
-
anhedralPrint.section('API (Fastify)');
|
|
2906
|
-
anhedralPrint.step('Writing modular Fastify API');
|
|
2907
|
-
writeFile(path.join(dir, 'package.json'), JSON.stringify({
|
|
2908
|
-
name: childPackageName(options.projectName, 'api'),
|
|
2909
|
-
version: '0.1.0',
|
|
2910
|
-
private: true,
|
|
2911
|
-
type: 'module',
|
|
2912
|
-
scripts: {
|
|
2913
|
-
dev: 'tsx --env-file=.env --watch src/index.ts',
|
|
2914
|
-
build: 'pnpm typecheck',
|
|
2915
|
-
typecheck: 'tsc --noEmit',
|
|
2916
|
-
test: 'vitest run',
|
|
2917
|
-
'test:coverage': 'vitest run --coverage',
|
|
2918
|
-
},
|
|
2919
|
-
dependencies: selected.dependencies,
|
|
2920
|
-
devDependencies: selected.devDependencies,
|
|
2921
|
-
}, null, 2) + '\n');
|
|
2922
|
-
writeFile(path.join(dir, 'vitest.config.ts'), `import { defineConfig } from 'vitest/config';
|
|
2923
|
-
|
|
2924
|
-
export default defineConfig({
|
|
2925
|
-
test: {
|
|
2926
|
-
coverage: {
|
|
2927
|
-
provider: 'v8',
|
|
2928
|
-
include: ['src/**/*.ts'],
|
|
2929
|
-
exclude: ['src/index.ts'],
|
|
2930
|
-
reporter: ['text', 'json-summary'],
|
|
2931
|
-
thresholds: {
|
|
2932
|
-
lines: 68,
|
|
2933
|
-
statements: 65,
|
|
2934
|
-
functions: 60,
|
|
2935
|
-
branches: ${coverageBranchThreshold},
|
|
2936
|
-
},
|
|
2937
|
-
},
|
|
2938
|
-
},
|
|
2939
|
-
});
|
|
2940
|
-
`);
|
|
2941
|
-
writeFile(path.join(dir, 'src/env.ts'), envSource(options));
|
|
2942
|
-
if (options.features.auth)
|
|
2943
|
-
writeFile(path.join(dir, 'src/auth.ts'), authSource());
|
|
2944
|
-
if (options.features.billing)
|
|
2945
|
-
writeFile(path.join(dir, 'src/realtime.ts'), realtimeSource());
|
|
2946
|
-
if (options.features.storage)
|
|
2947
|
-
writeFile(path.join(dir, 'src/storage.ts'), storageSource());
|
|
2948
|
-
if (options.features.billing)
|
|
2949
|
-
writeFile(path.join(dir, 'src/billing.ts'), billingSource());
|
|
2950
|
-
const appRouteAuthImports = options.features.auth
|
|
2951
|
-
? `import { authenticatedUserId } from '../auth';
|
|
2952
|
-
import type { AppEnv } from '../env';
|
|
2953
|
-
`
|
|
2954
|
-
: '';
|
|
2955
|
-
const appRouteRegistrationStart = options.features.auth
|
|
2956
|
-
? `export function appRoutes(env: AppEnv): FastifyPluginAsync {
|
|
2957
|
-
return async function registerAppRoutes(app) {`
|
|
2958
|
-
: 'export const appRoutes: FastifyPluginAsync = async (app) => {';
|
|
2959
|
-
const appRouteRegistrationEnd = options.features.auth ? ' };\n}' : '};';
|
|
2960
|
-
const listOwner = options.features.auth
|
|
2961
|
-
? ` const userId = authenticatedUserId(request, env);
|
|
2962
|
-
const rows = await db.select().from(items)
|
|
2963
|
-
.where(eq(items.userId, userId))
|
|
2964
|
-
.orderBy(desc(items.createdAt))
|
|
2965
|
-
.limit(50);`
|
|
2966
|
-
: ` const rows = await db.select().from(items)
|
|
2967
|
-
.orderBy(desc(items.createdAt))
|
|
2968
|
-
.limit(50);`;
|
|
2969
|
-
const createOwner = options.features.auth
|
|
2970
|
-
? ' userId: authenticatedUserId(request, env),\n'
|
|
2971
|
-
: '';
|
|
2972
|
-
writeFile(path.join(dir, 'src/routes/app.ts'), options.features.database
|
|
2973
|
-
? `import { randomUUID } from 'node:crypto';
|
|
2974
|
-
import type { FastifyPluginAsync } from 'fastify';
|
|
2975
|
-
import { CreateItemRequestSchema, ItemListSchema, ItemSchema } from '@shared/contracts';
|
|
2976
|
-
import { db } from '@shared/db';
|
|
2977
|
-
import { items } from '@shared/db/schema';
|
|
2978
|
-
import { desc${options.features.auth ? ', eq' : ''} } from 'drizzle-orm';
|
|
2979
|
-
${appRouteAuthImports}
|
|
2980
|
-
|
|
2981
|
-
// A complete starter feature. Keep the boundary or replace it with your product model.
|
|
2982
|
-
${appRouteRegistrationStart}
|
|
2983
|
-
app.get('/items', async (request) => {
|
|
2984
|
-
${listOwner}
|
|
2985
|
-
return ItemListSchema.parse(rows.map((item) => ({
|
|
2986
|
-
...item,
|
|
2987
|
-
createdAt: item.createdAt.toISOString(),
|
|
2988
|
-
})));
|
|
2989
|
-
});
|
|
2990
|
-
|
|
2991
|
-
app.post('/items', async (request, reply) => {
|
|
2992
|
-
const parsed = CreateItemRequestSchema.safeParse(request.body);
|
|
2993
|
-
if (!parsed.success) {
|
|
2994
|
-
return reply.code(400).send({
|
|
2995
|
-
error: 'invalid_item_request',
|
|
2996
|
-
message: 'Item name must contain 1 to 120 characters',
|
|
2997
|
-
details: parsed.error.flatten(),
|
|
2998
|
-
});
|
|
2999
|
-
}
|
|
3000
|
-
const [created] = await db.insert(items).values({
|
|
3001
|
-
id: randomUUID(),
|
|
3002
|
-
${createOwner} name: parsed.data.name,
|
|
3003
|
-
}).returning();
|
|
3004
|
-
if (!created) throw new Error('Database did not return the created item');
|
|
3005
|
-
return reply.code(201).send(ItemSchema.parse({
|
|
3006
|
-
...created,
|
|
3007
|
-
createdAt: created.createdAt.toISOString(),
|
|
3008
|
-
}));
|
|
3009
|
-
});
|
|
3010
|
-
${appRouteRegistrationEnd}
|
|
3011
|
-
`
|
|
3012
|
-
: `import type { FastifyPluginAsync } from 'fastify';
|
|
3013
|
-
|
|
3014
|
-
// Register product-owned routes here. Anhedral never rewrites this file after creation.
|
|
3015
|
-
export const appRoutes: FastifyPluginAsync = async (_app) => {};
|
|
3016
|
-
`);
|
|
3017
|
-
writeFile(path.join(dir, 'src/routes.ts'), routesSource(options));
|
|
3018
|
-
writeFile(path.join(dir, 'src/application.ts'), applicationSource(options, options.displayName));
|
|
3019
|
-
writeFile(path.join(dir, 'src/index.ts'), `import { buildApp } from './application';
|
|
3020
|
-
|
|
3021
|
-
const app = await buildApp();
|
|
3022
|
-
let shuttingDown = false;
|
|
3023
|
-
const SHUTDOWN_DEADLINE_MS = 10_000;
|
|
3024
|
-
|
|
3025
|
-
async function shutdown(signal: string) {
|
|
3026
|
-
if (shuttingDown) return;
|
|
3027
|
-
shuttingDown = true;
|
|
3028
|
-
app.beginShutdown();
|
|
3029
|
-
app.log.info({ signal }, 'graceful_shutdown_started');
|
|
3030
|
-
const deadline = setTimeout(() => {
|
|
3031
|
-
app.log.fatal({ signal, timeoutMs: SHUTDOWN_DEADLINE_MS }, 'graceful_shutdown_deadline_exceeded');
|
|
3032
|
-
process.exit(1);
|
|
3033
|
-
}, SHUTDOWN_DEADLINE_MS);
|
|
3034
|
-
deadline.unref();
|
|
3035
|
-
try {
|
|
3036
|
-
await app.close();
|
|
3037
|
-
} catch (error) {
|
|
3038
|
-
const failure = error instanceof Error ? error : new Error('Unknown shutdown failure');
|
|
3039
|
-
app.log.error({ err: failure, signal }, 'graceful_shutdown_failed');
|
|
3040
|
-
process.exitCode = 1;
|
|
3041
|
-
} finally {
|
|
3042
|
-
clearTimeout(deadline);
|
|
3043
|
-
}
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
3047
|
-
process.once(signal, () => { void shutdown(signal); });
|
|
3048
|
-
}
|
|
3049
|
-
|
|
3050
|
-
try {
|
|
3051
|
-
await app.listen({ host: '0.0.0.0', port: Number(process.env.PORT || 8787) });
|
|
3052
|
-
} catch (error) {
|
|
3053
|
-
const failure = error instanceof Error ? error : new Error('Unknown startup failure');
|
|
3054
|
-
app.log.error({ err: failure }, 'api_startup_failed');
|
|
3055
|
-
await app.close().catch(() => undefined);
|
|
3056
|
-
process.exitCode = 1;
|
|
3057
|
-
}
|
|
3058
|
-
`);
|
|
3059
|
-
writeFile(path.join(dir, 'tests/health.test.ts'), `import { beforeAll, describe, expect, it } from 'vitest';
|
|
3060
|
-
|
|
3061
|
-
const allowedOrigins: string[] = ${JSON.stringify(corsOrigins(options))};
|
|
3062
|
-
|
|
3063
|
-
beforeAll(() => {
|
|
3064
|
-
process.env.NODE_ENV = 'test';
|
|
3065
|
-
process.env.ANHEDRAL_DEMO = 'true';
|
|
3066
|
-
process.env.DATABASE_URL ||= 'postgresql://user:pass@localhost:5432/test';
|
|
3067
|
-
${options.features.storage ? "process.env.BASE_URL = 'http://localhost:8787';" : ''}
|
|
3068
|
-
});
|
|
3069
|
-
|
|
3070
|
-
describe('health', () => {
|
|
3071
|
-
it('responds without provider credentials', async () => {
|
|
3072
|
-
const { buildApp } = await import('../src/application');
|
|
3073
|
-
const app = await buildApp({ checkReadiness: async () => undefined });
|
|
3074
|
-
const response = await app.inject({ method: 'GET', url: '/api/health' });
|
|
3075
|
-
const readiness = await app.inject({ method: 'GET', url: '/api/ready' });
|
|
3076
|
-
expect(response.statusCode).toBe(200);
|
|
3077
|
-
expect(response.json()).toEqual({ ok: true, service: 'api' });
|
|
3078
|
-
expect(readiness.statusCode).toBe(200);
|
|
3079
|
-
expect(readiness.json()).toEqual({ ok: true, service: 'api', status: 'ready' });
|
|
3080
|
-
app.beginShutdown();
|
|
3081
|
-
const draining = await app.inject({ method: 'GET', url: '/api/ready' });
|
|
3082
|
-
expect(draining.statusCode).toBe(503);
|
|
3083
|
-
expect(draining.json()).toEqual({ ok: false, service: 'api', status: 'shutting_down' });
|
|
3084
|
-
await app.close();
|
|
3085
|
-
});
|
|
3086
|
-
|
|
3087
|
-
it('reports critical dependency failures without claiming to be ready', async () => {
|
|
3088
|
-
const { buildApp } = await import('../src/application');
|
|
3089
|
-
const app = await buildApp({ checkReadiness: async () => {
|
|
3090
|
-
throw new Error('database unavailable');
|
|
3091
|
-
} });
|
|
3092
|
-
const readiness = await app.inject({ method: 'GET', url: '/api/ready' });
|
|
3093
|
-
expect(readiness.statusCode).toBe(503);
|
|
3094
|
-
expect(readiness.json()).toEqual({ ok: false, service: 'api', status: 'unavailable' });
|
|
3095
|
-
await app.close();
|
|
3096
|
-
});
|
|
3097
|
-
|
|
3098
|
-
it('keeps server failures private and preserves safe client-error details', async () => {
|
|
3099
|
-
const { buildApp } = await import('../src/application');
|
|
3100
|
-
const app = await buildApp({ checkReadiness: async () => undefined });
|
|
3101
|
-
app.get('/api/test-server-error', async () => {
|
|
3102
|
-
throw Object.assign(new Error('database connection included internal credentials'), {
|
|
3103
|
-
statusCode: 503,
|
|
3104
|
-
code: 'database_connection_failed',
|
|
3105
|
-
details: { connection: 'internal-only' },
|
|
3106
|
-
});
|
|
3107
|
-
});
|
|
3108
|
-
app.get('/api/test-invalid-status', async () => {
|
|
3109
|
-
throw Object.assign(new Error('invalid status must not become a success'), {
|
|
3110
|
-
statusCode: 200,
|
|
3111
|
-
details: { internal: true },
|
|
3112
|
-
});
|
|
3113
|
-
});
|
|
3114
|
-
app.get('/api/test-client-error', async () => {
|
|
3115
|
-
throw Object.assign(new Error('The submitted field is invalid'), {
|
|
3116
|
-
statusCode: 422,
|
|
3117
|
-
code: 'invalid_field',
|
|
3118
|
-
details: { field: 'name' },
|
|
3119
|
-
});
|
|
3120
|
-
});
|
|
3121
|
-
|
|
3122
|
-
const serverFailure = await app.inject({ method: 'GET', url: '/api/test-server-error' });
|
|
3123
|
-
expect(serverFailure.statusCode).toBe(503);
|
|
3124
|
-
expect(serverFailure.json()).toEqual({
|
|
3125
|
-
error: 'server_error',
|
|
3126
|
-
message: 'An unexpected error occurred',
|
|
3127
|
-
});
|
|
3128
|
-
const invalidStatus = await app.inject({ method: 'GET', url: '/api/test-invalid-status' });
|
|
3129
|
-
expect(invalidStatus.statusCode).toBe(500);
|
|
3130
|
-
expect(invalidStatus.json()).toEqual({
|
|
3131
|
-
error: 'server_error',
|
|
3132
|
-
message: 'An unexpected error occurred',
|
|
3133
|
-
});
|
|
3134
|
-
const clientFailure = await app.inject({ method: 'GET', url: '/api/test-client-error' });
|
|
3135
|
-
expect(clientFailure.statusCode).toBe(422);
|
|
3136
|
-
expect(clientFailure.json()).toEqual({
|
|
3137
|
-
error: 'invalid_field',
|
|
3138
|
-
message: 'The submitted field is invalid',
|
|
3139
|
-
details: { field: 'name' },
|
|
3140
|
-
});
|
|
3141
|
-
await app.close();
|
|
3142
|
-
});
|
|
3143
|
-
|
|
3144
|
-
it('allows only configured browser origins', async () => {
|
|
3145
|
-
const { buildApp } = await import('../src/application');
|
|
3146
|
-
const app = await buildApp();
|
|
3147
|
-
for (const origin of allowedOrigins) {
|
|
3148
|
-
const response = await app.inject({
|
|
3149
|
-
method: 'OPTIONS',
|
|
3150
|
-
url: '/api/health',
|
|
3151
|
-
headers: { origin, 'access-control-request-method': 'GET' },
|
|
3152
|
-
});
|
|
3153
|
-
expect(response.headers['access-control-allow-origin']).toBe(origin);
|
|
3154
|
-
}
|
|
3155
|
-
const denied = await app.inject({
|
|
3156
|
-
method: 'OPTIONS',
|
|
3157
|
-
url: '/api/health',
|
|
3158
|
-
headers: { origin: 'https://untrusted.example', 'access-control-request-method': 'GET' },
|
|
3159
|
-
});
|
|
3160
|
-
expect(denied.headers['access-control-allow-origin']).toBeUndefined();
|
|
3161
|
-
await app.close();
|
|
3162
|
-
});
|
|
3163
|
-
});
|
|
3164
|
-
`);
|
|
3165
|
-
writeFile(path.join(dir, 'tests/env.test.ts'), envTestSource(options));
|
|
3166
|
-
if (options.features.billing) {
|
|
3167
|
-
writeFile(path.join(dir, 'tests/revenuecat-webhook.test.ts'), billingRouteTestSource(options));
|
|
3168
|
-
}
|
|
3169
|
-
if (options.features.storage) {
|
|
3170
|
-
writeFile(path.join(dir, 'tests/storage.test.ts'), storageRouteTestSource(options));
|
|
3171
|
-
}
|
|
3172
|
-
const envLines = [
|
|
3173
|
-
'NODE_ENV=development',
|
|
3174
|
-
'PORT=8787',
|
|
3175
|
-
'TRUST_PROXY_HOPS=0',
|
|
3176
|
-
`# In production, list every exact browser origin explicitly and use HTTPS; literal null remains valid for the desktop file origin.\nCORS_ORIGINS=${corsOrigins(options).join(',')}`,
|
|
3177
|
-
'ANHEDRAL_DEMO=false',
|
|
3178
|
-
options.features.database ? '# Production requires a postgres/postgresql URL: paste the exact pooled URL from managed Neon. Anhedral never starts local Postgres.\nDATABASE_URL=YOUR_NEON_POSTGRES_URL' : null,
|
|
3179
|
-
options.features.auth ? '# Production requires Clerk keys from the live instance (pk_live_ / sk_live_).\nCLERK_PUBLISHABLE_KEY=pk_test_***\nCLERK_SECRET_KEY=sk_test_***' : null,
|
|
3180
|
-
options.features.billing ? '# Generate a dedicated high-entropy webhook authorization value (32+ characters).\nRC_WEBHOOK_SECRET=\n# Server-only RevenueCat secret key used to reconcile GET /v1/subscribers/{app_user_id}.\nRC_SECRET_API_KEY=\nRC_ENTITLEMENT_ID=pro\n# Server-only Ably API key; clients receive scoped, short-lived token requests.\nABLY_API_KEY=' : null,
|
|
3181
|
-
options.features.storage ? '# Canonical application/API origin used when constructing protected storage links. Use HTTPS in production.\nBASE_URL=http://localhost:8787\n# R2 presigned PUTs bind exact Content-Length and require the declared Content-Type; configure bucket CORS for each client origin.\n# Production expects the 32-hex account ID, 32-hex access key ID, and 64-hex secret issued by Cloudflare.\nR2_ACCOUNT_ID=\nR2_ACCESS_KEY_ID=\nR2_SECRET_ACCESS_KEY=\n# Bucket names are 3-63 lowercase letters, numbers, or hyphens and cannot begin or end with a hyphen.\nR2_BUCKET_NAME=\n# Keep every application object inside one top-level namespace.\nR2_PREFIX=storage\n# Authenticated read URLs are clamped to 60-604800 seconds.\nR2_PROXY_READ_URL_TTL_SECONDS=600\n# Operations/CI only. Wrangler reads this automatically; do not expose it to clients or the Worker.\nCLOUDFLARE_API_TOKEN=' : null,
|
|
3182
|
-
options.features.billing || options.features.storage ? '# Vercel sends this as Authorization: Bearer <CRON_SECRET>; use a non-placeholder value of at least 32 characters.\nCRON_SECRET=' : null,
|
|
3183
|
-
].filter((value) => value !== null);
|
|
3184
|
-
writeFile(path.join(dir, '.env.example'), envLines.join('\n') + '\n');
|
|
3185
|
-
appendGitignore(dir, ['.env', '.env.*', '!.env.example', 'node_modules', 'coverage', 'dist', '*.tsbuildinfo']);
|
|
3186
|
-
anhedralPrint.done('Modular Fastify API written');
|
|
3187
|
-
}
|