anhedral 0.1.6 → 0.1.8

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.
@@ -54,6 +54,7 @@ function writePackageJson(dir, projectName) {
54
54
  'db:generate': 'drizzle-kit generate',
55
55
  'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
56
56
  'db:studio': 'drizzle-kit studio',
57
+ 'db:check': 'drizzle-kit check',
57
58
  'db:push': 'drizzle-kit push',
58
59
  lint: 'eslint . --ext .js,.ts',
59
60
  test: 'vitest run',
@@ -659,11 +660,6 @@ export interface RevenueCatEntitlement {
659
660
  cancelAtPeriodEnd?: boolean;
660
661
  }
661
662
 
662
- export interface GrantPromoResult {
663
- success: boolean;
664
- expiresAt: Date;
665
- }
666
-
667
663
  const RC_CACHE_TTL_MS = process.env.NODE_ENV === 'production' ? 60_000 : 10_000;
668
664
  const rcEntitlementCache = new LRUCache<RevenueCatEntitlement>({ maxSize: 100_000, ttlMs: RC_CACHE_TTL_MS });
669
665
  const inflightCached = new Map<string, Promise<RevenueCatEntitlement>>();
@@ -673,17 +669,6 @@ export function invalidateRcEntitlementCache(appUserId: string, entitlementId: s
673
669
  rcEntitlementCache.invalidate(\`\${entitlementId}:\${appUserId}\`);
674
670
  }
675
671
 
676
- function addMonthsClamped(date: Date, months: number): Date {
677
- const m = Number.isFinite(months) ? months : 0;
678
- if (m === 0) return new Date(date.getTime());
679
- const yr = date.getFullYear(), mo = date.getMonth(), day = date.getDate();
680
- const targetMo = mo + m;
681
- const targetYr = yr + Math.floor(targetMo / 12);
682
- const finalMo = ((targetMo % 12) + 12) % 12;
683
- const lastDay = new Date(targetYr, finalMo + 1, 0).getDate();
684
- return new Date(targetYr, finalMo, Math.min(day, lastDay), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
685
- }
686
-
687
672
  interface RcSubscriberResponse {
688
673
  subscriber?: {
689
674
  entitlements?: Record<string, { expires_date?: string; purchase_date?: string; product_identifier?: string; will_renew?: boolean | null; unsubscribe_detected_at?: string | null }>;
@@ -778,30 +763,12 @@ export function verifyRevenueCatWebhookAuthorization(authHeader: string | undefi
778
763
  const normalize = (v: string) => v.trim().replace(/^bearer\\s+/i, '');
779
764
  return normalize(authHeader) === normalize(secret);
780
765
  }
781
-
782
- export async function grantPromotionalEntitlement(
783
- appUserId: string, entitlementId: string, months: number, apiKey: string
784
- ): Promise<GrantPromoResult> {
785
- const safeMonths = Number.isFinite(months) && months > 0 ? months : 1;
786
- const startsAt = new Date();
787
- const expiresAt = addMonthsClamped(startsAt, safeMonths);
788
- const url = \`https://api.revenuecat.com/v1/subscribers/\${encodeURIComponent(appUserId)}/entitlements/\${encodeURIComponent(entitlementId)}/promotional\`;
789
- const res = await fetchWithTimeout(url, {
790
- method: 'POST',
791
- headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: \`Bearer \${apiKey}\` },
792
- body: JSON.stringify({ duration: \`\${safeMonths}m\`, end_time_ms: expiresAt.getTime() }),
793
- timeout: 60_000,
794
- });
795
- if (!res.ok) throw new Error(\`RevenueCat promo grant failed: \${res.status} \${await res.text()}\`);
796
- return { success: true, expiresAt };
797
- }
798
766
  `);
799
- writeFile(path.join(dir, 'src/lib/r2.ts'), `import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
767
+ writeFile(path.join(dir, 'src/lib/r2.ts'), `import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
800
768
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
801
769
 
802
- type UploadAvatarInput = {
770
+ type UploadObjectInput = {
803
771
  objectKey: string;
804
- body: Buffer;
805
772
  contentType: string;
806
773
  };
807
774
 
@@ -841,29 +808,40 @@ export function isR2Configured() {
841
808
  return hasR2Config();
842
809
  }
843
810
 
844
- export async function uploadAvatarToR2(input: UploadAvatarInput) {
811
+ export async function createSignedUploadUrl(input: UploadObjectInput, expiresIn = 60 * 10) {
845
812
  const client = getR2Client();
846
813
  const bucket = getBucket();
847
814
 
848
- await client.send(new PutObjectCommand({
815
+ const uploadUrl = await getSignedUrl(client, new PutObjectCommand({
849
816
  Bucket: bucket,
850
817
  Key: input.objectKey,
851
- Body: input.body,
852
818
  ContentType: input.contentType,
853
- }));
819
+ }), { expiresIn });
854
820
 
855
821
  return {
856
822
  bucket,
857
823
  objectKey: input.objectKey,
824
+ uploadUrl,
825
+ expiresIn,
858
826
  };
859
827
  }
860
828
 
861
- export async function createSignedAvatarUrl(objectKey: string) {
829
+ export async function createSignedDownloadUrl(objectKey: string, expiresIn = 60 * 10) {
862
830
  const client = getR2Client();
863
- return getSignedUrl(client, new GetObjectCommand({
831
+ const downloadUrl = await getSignedUrl(client, new GetObjectCommand({
864
832
  Bucket: getBucket(),
865
833
  Key: objectKey,
866
- }), { expiresIn: 60 * 10 });
834
+ }), { expiresIn });
835
+
836
+ return { objectKey, downloadUrl, expiresIn };
837
+ }
838
+
839
+ export async function deleteObjectFromR2(objectKey: string) {
840
+ const client = getR2Client();
841
+ await client.send(new DeleteObjectCommand({
842
+ Bucket: getBucket(),
843
+ Key: objectKey,
844
+ }));
867
845
  }
868
846
  `);
869
847
  writeFile(path.join(dir, 'src/services/SubscriptionService.ts'), `import type { FastifyInstance, FastifyRequest } from 'fastify';
@@ -1382,12 +1360,13 @@ export class CorsConfig {
1382
1360
  // Repositories
1383
1361
  // ═══════════════════════════════════════════════════════════════════════════
1384
1362
  function writeRepositories(dir) {
1385
- writeFile(path.join(dir, 'src/repositories/UserRepository.ts'), `import { eq } from 'drizzle-orm';
1363
+ writeFile(path.join(dir, 'src/repositories/UserRepository.ts'), `import crypto from 'node:crypto';
1364
+ import { eq } from 'drizzle-orm';
1386
1365
  import type { Database } from '../db/index.js';
1387
1366
  import { users } from '../db/schema.js';
1388
1367
  import type { NewUsers, SubscriptionStatus, SubscriptionTier } from '../db/schema.js';
1389
1368
  import { LRUCache } from '../lib/lruCache.js';
1390
- import { subscriptions } from '../db/schema.js';
1369
+ import { subscriptions, uploads } from '../db/schema.js';
1391
1370
 
1392
1371
  export type UserAuthData = {
1393
1372
  id: string;
@@ -1446,27 +1425,13 @@ export class UserRepository {
1446
1425
  return { created: true };
1447
1426
  }
1448
1427
 
1449
- async updateAvatar(userId: string, input: {
1450
- avatarObjectKey: string;
1451
- avatarMimeType: string;
1452
- }): Promise<void> {
1453
- await this.db.update(users).set({
1454
- avatarObjectKey: input.avatarObjectKey,
1455
- avatarMimeType: input.avatarMimeType,
1456
- }).where(eq(users.id, userId));
1457
- authPluginCache.invalidate(\`auth:\${userId}\`);
1458
- }
1459
-
1460
- async getDashboardProfile(userId: string) {
1428
+ async getProfile(userId: string) {
1461
1429
  const rows = await this.db
1462
1430
  .select({
1463
1431
  id: users.id,
1464
1432
  email: users.email,
1465
1433
  displayName: users.displayName,
1466
1434
  profileImageUrl: users.profileImageUrl,
1467
- avatarObjectKey: users.avatarObjectKey,
1468
- avatarMimeType: users.avatarMimeType,
1469
- creditsBalance: users.creditsBalance,
1470
1435
  subscriptionTier: subscriptions.tier,
1471
1436
  subscriptionStatus: subscriptions.status,
1472
1437
  })
@@ -1483,6 +1448,19 @@ export class UserRepository {
1483
1448
  authPluginCache.invalidate(\`auth:\${userId}\`);
1484
1449
  }
1485
1450
 
1451
+ async createUploadRecord(
1452
+ userId: string,
1453
+ input: { objectKey: string; bucket: string; contentType: string | null },
1454
+ ): Promise<void> {
1455
+ await this.db.insert(uploads).values({
1456
+ id: crypto.randomUUID(),
1457
+ userId,
1458
+ objectKey: input.objectKey,
1459
+ bucket: input.bucket,
1460
+ contentType: input.contentType,
1461
+ });
1462
+ }
1463
+
1486
1464
  async deleteById(userId: string): Promise<void> {
1487
1465
  await this.db.delete(users).where(eq(users.id, userId));
1488
1466
  authPluginCache.invalidate(\`auth:\${userId}\`);
@@ -1646,77 +1624,27 @@ export class SubscriptionEventRepository {
1646
1624
  return results.reduce((acc, r) => { acc[r.eventType] = r.count; return acc; }, {} as Record<string, number>);
1647
1625
  }
1648
1626
  }
1649
- `);
1650
- writeFile(path.join(dir, 'src/repositories/PromoCodeRepository.ts'), `import { eq, and, lt, sql } from 'drizzle-orm';
1651
- import crypto from 'node:crypto';
1652
- import type { Database } from '../db/index.js';
1653
- import { promoCodes, promoRedemptions } from '../db/schema.js';
1654
- import type { PromoCodes } from '../db/schema.js';
1655
-
1656
- export interface ValidateCodeResult {
1657
- valid: boolean;
1658
- error?: string;
1659
- promoCode?: PromoCodes;
1660
- }
1661
-
1662
- export class PromoCodeRepository {
1663
- constructor(private db: Database) {}
1664
-
1665
- async validateCode(code: string, userId: string): Promise<ValidateCodeResult> {
1666
- const [promoCode] = await this.db.select().from(promoCodes)
1667
- .where(eq(promoCodes.code, code.toUpperCase())).limit(1);
1668
- if (!promoCode) return { valid: false, error: 'invalid_code' };
1669
-
1670
- const now = new Date();
1671
- if (promoCode.expiresAt && promoCode.expiresAt < now) return { valid: false, error: 'code_expired' };
1672
- if (promoCode.redeemedCount >= promoCode.maxRedemptions) return { valid: false, error: 'code_fully_used' };
1673
-
1674
- const [existing] = await this.db.select().from(promoRedemptions)
1675
- .where(and(eq(promoRedemptions.promoCodeId, promoCode.id), eq(promoRedemptions.userId, userId))).limit(1);
1676
- if (existing) return { valid: false, error: 'already_redeemed' };
1677
-
1678
- return { valid: true, promoCode };
1679
- }
1680
-
1681
- async recordRedemption(promoCodeId: string, userId: string, entitlementExpiresAt: Date): Promise<void> {
1682
- await this.db.insert(promoRedemptions).values({
1683
- id: crypto.randomUUID(), promoCodeId, userId, entitlementExpiresAt,
1684
- });
1685
- await this.db.update(promoCodes)
1686
- .set({ redeemedCount: sql\`\${promoCodes.redeemedCount} + 1\` })
1687
- .where(eq(promoCodes.id, promoCodeId));
1688
- }
1689
-
1690
- async findExpiredRedemptions(userId: string): Promise<typeof promoRedemptions.$inferSelect[]> {
1691
- return this.db.select().from(promoRedemptions)
1692
- .where(and(eq(promoRedemptions.userId, userId), lt(promoRedemptions.entitlementExpiresAt, new Date())));
1693
- }
1694
- }
1695
1627
  `);
1696
1628
  writeFile(path.join(dir, 'src/repositories/index.ts'), `import type { Database } from '../db/index.js';
1697
1629
  import { UserRepository } from './UserRepository.js';
1698
1630
  import { SubscriptionRepository } from './SubscriptionRepository.js';
1699
1631
  import { SubscriptionEventRepository } from './SubscriptionEventRepository.js';
1700
- import { PromoCodeRepository } from './PromoCodeRepository.js';
1701
1632
 
1702
1633
  export class Repositories {
1703
1634
  public readonly users: UserRepository;
1704
1635
  public readonly subscriptions: SubscriptionRepository;
1705
1636
  public readonly subscriptionEvents: SubscriptionEventRepository;
1706
- public readonly promoCodes: PromoCodeRepository;
1707
1637
 
1708
1638
  constructor(db: Database) {
1709
1639
  this.users = new UserRepository(db);
1710
1640
  this.subscriptions = new SubscriptionRepository(db);
1711
1641
  this.subscriptionEvents = new SubscriptionEventRepository(db);
1712
- this.promoCodes = new PromoCodeRepository(db);
1713
1642
  }
1714
1643
  }
1715
1644
 
1716
1645
  export { UserRepository } from './UserRepository.js';
1717
1646
  export { SubscriptionRepository } from './SubscriptionRepository.js';
1718
1647
  export { SubscriptionEventRepository } from './SubscriptionEventRepository.js';
1719
- export { PromoCodeRepository } from './PromoCodeRepository.js';
1720
1648
  export type { UserAuthData } from './UserRepository.js';
1721
1649
  export type { RecordEventParams } from './SubscriptionEventRepository.js';
1722
1650
  `);
@@ -1752,7 +1680,6 @@ export default healthRoutes;
1752
1680
  import { clerkClient } from '@clerk/fastify';
1753
1681
  import { AuthError } from '../errors/index.js';
1754
1682
  import { createAuthHook } from '../lib/routeHelpers.js';
1755
- import { createSignedAvatarUrl, isR2Configured, uploadAvatarToR2 } from '../lib/r2.js';
1756
1683
 
1757
1684
  function getDisplayName(input: {
1758
1685
  firstName?: string | null;
@@ -1765,28 +1692,6 @@ function getDisplayName(input: {
1765
1692
  || (input.email ? input.email.split('@')[0] : 'Builder');
1766
1693
  }
1767
1694
 
1768
- async function resolveAvatarUrl(input: {
1769
- avatarObjectKey?: string | null;
1770
- fallbackUrl?: string | null;
1771
- }) {
1772
- if (input.avatarObjectKey && isR2Configured()) {
1773
- try {
1774
- return await createSignedAvatarUrl(input.avatarObjectKey);
1775
- } catch {
1776
- return input.fallbackUrl ?? null;
1777
- }
1778
- }
1779
-
1780
- return input.fallbackUrl ?? null;
1781
- }
1782
-
1783
- function sanitizeFileName(fileName: string) {
1784
- return fileName
1785
- .toLowerCase()
1786
- .replace(/[^a-z0-9.-]+/g, '-')
1787
- .replace(/^-+|-+$/g, '');
1788
- }
1789
-
1790
1695
  const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1791
1696
  fastify.get('/me', {
1792
1697
  preHandler: createAuthHook(fastify),
@@ -1802,16 +1707,12 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1802
1707
  lastName: 'Builder',
1803
1708
  displayName: '${displayName} Demo',
1804
1709
  imageUrl: null,
1805
- avatarUrl: null,
1806
- creditsBalance: 250,
1807
- subscriptionTier: 'pro',
1808
- subscriptionStatus: 'active',
1809
1710
  },
1810
1711
  });
1811
1712
  }
1812
1713
 
1813
1714
  const clerkUser = await clerkClient.users.getUser(req.user.id);
1814
- const userData = await fastify.repos.users.getDashboardProfile(req.user.id);
1715
+ const userData = await fastify.repos.users.getProfile(req.user.id);
1815
1716
  const primaryEmail = clerkUser.emailAddresses.find(
1816
1717
  (e: { id: string }) => e.id === clerkUser.primaryEmailAddressId
1817
1718
  );
@@ -1822,10 +1723,6 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1822
1723
  email,
1823
1724
  fallback: userData?.displayName ?? null,
1824
1725
  });
1825
- const avatarUrl = await resolveAvatarUrl({
1826
- avatarObjectKey: userData?.avatarObjectKey ?? null,
1827
- fallbackUrl: userData?.profileImageUrl ?? clerkUser.imageUrl ?? null,
1828
- });
1829
1726
 
1830
1727
  return reply.send({
1831
1728
  user: {
@@ -1835,10 +1732,6 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1835
1732
  lastName: clerkUser.lastName,
1836
1733
  displayName,
1837
1734
  imageUrl: userData?.profileImageUrl ?? clerkUser.imageUrl,
1838
- avatarUrl,
1839
- creditsBalance: userData?.creditsBalance ?? 250,
1840
- subscriptionTier: userData?.subscriptionTier || 'free',
1841
- subscriptionStatus: userData?.subscriptionStatus || 'active',
1842
1735
  },
1843
1736
  });
1844
1737
  });
@@ -1850,48 +1743,6 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1850
1743
  return reply.send({ success: true });
1851
1744
  });
1852
1745
 
1853
- fastify.post<{ Body: { base64: string; mimeType: string; fileName?: string } }>('/avatar', {
1854
- preHandler: createAuthHook(fastify),
1855
- }, async (req, reply) => {
1856
- if (!req.user?.id) throw AuthError.unauthorized();
1857
- if (!isR2Configured()) {
1858
- return reply.code(500).send({ error: 'r2_not_configured', message: 'Configure Cloudflare R2 before uploading avatars.' });
1859
- }
1860
-
1861
- const { base64, mimeType, fileName } = req.body;
1862
- if (!base64 || !mimeType) {
1863
- return reply.code(400).send({ error: 'invalid_payload', message: 'Avatar payload is incomplete.' });
1864
- }
1865
- if (!mimeType.startsWith('image/')) {
1866
- return reply.code(400).send({ error: 'invalid_mime_type', message: 'Only image uploads are supported.' });
1867
- }
1868
-
1869
- const normalizedBase64 = base64.includes(',') ? (base64.split(',')[1] ?? '') : base64;
1870
- const buffer = Buffer.from(normalizedBase64, 'base64');
1871
- if (buffer.length === 0) {
1872
- return reply.code(400).send({ error: 'invalid_payload', message: 'Avatar payload is empty.' });
1873
- }
1874
- if (buffer.length > 5 * 1024 * 1024) {
1875
- return reply.code(400).send({ error: 'file_too_large', message: 'Avatar uploads are capped at 5MB.' });
1876
- }
1877
-
1878
- const safeName = sanitizeFileName(fileName || 'avatar');
1879
- const objectKey = \`avatars/\${req.user.id}/\${Date.now()}-\${safeName || 'avatar'}\`;
1880
- await uploadAvatarToR2({
1881
- objectKey,
1882
- body: buffer,
1883
- contentType: mimeType,
1884
- });
1885
-
1886
- await fastify.repos.users.updateAvatar(req.user.id, {
1887
- avatarObjectKey: objectKey,
1888
- avatarMimeType: mimeType,
1889
- });
1890
-
1891
- const avatarUrl = await createSignedAvatarUrl(objectKey);
1892
- return reply.send({ ok: true, avatarUrl });
1893
- });
1894
-
1895
1746
  fastify.delete('/account', {
1896
1747
  preHandler: createAuthHook(fastify),
1897
1748
  }, async (req: FastifyRequest, reply: FastifyReply) => {
@@ -1908,8 +1759,8 @@ export default authRoutes;
1908
1759
  import clerkAuthPlugin from '../plugins/clerkAuth.js';
1909
1760
  import { SubscriptionService } from '../services/SubscriptionService.js';
1910
1761
  import { AuthError } from '../errors/index.js';
1911
- import { verifyRevenueCatWebhook, verifyRevenueCatWebhookAuthorization, grantPromotionalEntitlement } from '../lib/revenuecat.js';
1912
- import { createAuthHook, runBackgroundTask } from '../lib/routeHelpers.js';
1762
+ import { verifyRevenueCatWebhook, verifyRevenueCatWebhookAuthorization } from '../lib/revenuecat.js';
1763
+ import { createAuthHook } from '../lib/routeHelpers.js';
1913
1764
  import { CACHE_SECONDS } from '../lib/constants.js';
1914
1765
 
1915
1766
  const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
@@ -1949,50 +1800,6 @@ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance)
1949
1800
  reply.header('Cache-Control', 'private, no-store');
1950
1801
  return reply.send(data);
1951
1802
  });
1952
-
1953
- app.post<{ Body: { code: string } }>('/redeem', {
1954
- preHandler: createAuthHook(app),
1955
- }, async (req, reply) => {
1956
- const userId = req.user?.id;
1957
- if (!userId) throw AuthError.unauthorized();
1958
- if (app.env.ANHEDRAL_DEMO === 'true') {
1959
- return { ok: true, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() };
1960
- }
1961
- const { code } = req.body;
1962
- const validation = await app.repos.promoCodes.validateCode(code, userId);
1963
- if (!validation.valid || !validation.promoCode) {
1964
- const msgs: Record<string, string> = { invalid_code: 'This code is not valid.', code_expired: 'This code has expired.', code_fully_used: 'This code has already been used.', already_redeemed: 'You have already redeemed this code.' };
1965
- return reply.code(400).send({ error: validation.error, message: msgs[validation.error!] || 'Invalid code.' });
1966
- }
1967
- const rcKey = app.env.RC_SECRET_API_KEY;
1968
- if (!rcKey) return reply.code(500).send({ error: 'configuration_error', message: 'Subscription service not configured.' });
1969
-
1970
- const { promoCode } = validation;
1971
- const entitlementId = app.env.RC_ENTITLEMENT_ID || 'pro';
1972
- try {
1973
- const currentSub = await app.repos.subscriptions.findByUserId(userId);
1974
- const result = await grantPromotionalEntitlement(userId, entitlementId, promoCode.months, rcKey);
1975
- await app.repos.promoCodes.recordRedemption(promoCode.id, userId, result.expiresAt);
1976
- const now = new Date();
1977
- await app.repos.subscriptions.upsert(userId, {
1978
- tier: 'pro', status: 'active', method: 'redeemed',
1979
- currentPeriodStart: now, currentPeriodEnd: result.expiresAt,
1980
- trialStart: null, trialEnd: null,
1981
- metadata: { redeemCode: promoCode.code, redeemCodeRedeemedAt: now.toISOString() },
1982
- });
1983
- runBackgroundTask(req, app.repos.subscriptionEvents.recordEvent({
1984
- userId, subscriptionId: currentSub?.id, eventType: 'promo_redeemed',
1985
- previousState: { tier: currentSub?.tier, status: currentSub?.status, method: currentSub?.method },
1986
- newState: { tier: 'pro', status: 'active', method: 'redeemed' },
1987
- periodStart: now, periodEnd: result.expiresAt,
1988
- metadata: { promoCode: promoCode.code },
1989
- }), 'promo_redeemed_event');
1990
- return { ok: true, expiresAt: result.expiresAt.toISOString() };
1991
- } catch (err) {
1992
- app.log.error({ msg: '[redeem:failed]', userId, error: (err as Error).message });
1993
- return reply.code(500).send({ error: 'redemption_failed', message: 'Failed to redeem code. Please try again.' });
1994
- }
1995
- });
1996
1803
  });
1997
1804
 
1998
1805
  await fastify.register(async (app) => {
@@ -2033,12 +1840,100 @@ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance)
2033
1840
  };
2034
1841
 
2035
1842
  export default subscriptionRoutes;
1843
+ `);
1844
+ writeFile(path.join(dir, 'src/routes/storage.ts'), `import crypto from 'node:crypto';
1845
+ import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
1846
+ import { CreateUploadRequestSchema } from '@anhedral/contracts';
1847
+ import { AuthError, ServerError, ValidationError } from '../errors/index.js';
1848
+ import { createAuthHook } from '../lib/routeHelpers.js';
1849
+ import { createSignedDownloadUrl, createSignedUploadUrl, deleteObjectFromR2, isR2Configured } from '../lib/r2.js';
1850
+
1851
+ const SIGNED_URL_EXPIRES_IN = 60 * 10;
1852
+
1853
+ function sanitizeFileName(fileName: string | undefined) {
1854
+ const fallback = 'upload';
1855
+ const cleaned = (fileName ?? fallback)
1856
+ .normalize('NFKD')
1857
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
1858
+ .replace(/^-+|-+$/g, '')
1859
+ .slice(0, 120);
1860
+ return cleaned || fallback;
1861
+ }
1862
+
1863
+ function objectKeyForUser(userId: string, fileName: string | undefined) {
1864
+ const safeUserId = userId.replace(/[^a-zA-Z0-9_-]+/g, '_');
1865
+ return \`\${safeUserId}--\${crypto.randomUUID()}--\${sanitizeFileName(fileName)}\`;
1866
+ }
1867
+
1868
+ function assertUserOwnsObject(userId: string, objectKey: string) {
1869
+ const safeUserId = userId.replace(/[^a-zA-Z0-9_-]+/g, '_');
1870
+ if (!objectKey.startsWith(\`\${safeUserId}--\`)) {
1871
+ throw AuthError.forbidden();
1872
+ }
1873
+ }
1874
+
1875
+ const storageRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1876
+ fastify.post('/uploads', {
1877
+ preHandler: createAuthHook(fastify),
1878
+ }, async (req, reply) => {
1879
+ if (!req.user?.id) throw AuthError.unauthorized();
1880
+ if (!isR2Configured()) throw ServerError.missingConfiguration('R2');
1881
+
1882
+ const parsed = CreateUploadRequestSchema.safeParse(req.body);
1883
+ if (!parsed.success) throw ValidationError.invalidFormat('body', 'CreateUploadRequest');
1884
+ const input = parsed.data;
1885
+ const objectKey = objectKeyForUser(req.user.id, input.fileName);
1886
+ const signed = await createSignedUploadUrl({
1887
+ objectKey,
1888
+ contentType: input.contentType,
1889
+ }, SIGNED_URL_EXPIRES_IN);
1890
+
1891
+ await fastify.repos.users.createUploadRecord(req.user.id, {
1892
+ objectKey,
1893
+ bucket: signed.bucket,
1894
+ contentType: input.contentType,
1895
+ });
1896
+
1897
+ return reply.send({
1898
+ objectKey,
1899
+ uploadUrl: signed.uploadUrl,
1900
+ expiresIn: signed.expiresIn,
1901
+ headers: { 'Content-Type': input.contentType },
1902
+ });
1903
+ });
1904
+
1905
+ fastify.get<{ Params: { key: string } }>('/files/:key', {
1906
+ preHandler: createAuthHook(fastify),
1907
+ }, async (req, reply) => {
1908
+ if (!req.user?.id) throw AuthError.unauthorized();
1909
+ if (!isR2Configured()) throw ServerError.missingConfiguration('R2');
1910
+
1911
+ const objectKey = decodeURIComponent(req.params.key);
1912
+ assertUserOwnsObject(req.user.id, objectKey);
1913
+ return reply.send(await createSignedDownloadUrl(objectKey, SIGNED_URL_EXPIRES_IN));
1914
+ });
1915
+
1916
+ fastify.delete<{ Params: { key: string } }>('/files/:key', {
1917
+ preHandler: createAuthHook(fastify),
1918
+ }, async (req, reply) => {
1919
+ if (!req.user?.id) throw AuthError.unauthorized();
1920
+ if (!isR2Configured()) throw ServerError.missingConfiguration('R2');
1921
+
1922
+ const objectKey = decodeURIComponent(req.params.key);
1923
+ assertUserOwnsObject(req.user.id, objectKey);
1924
+ await deleteObjectFromR2(objectKey);
1925
+ return reply.code(204).send();
1926
+ });
1927
+ };
1928
+
1929
+ export default storageRoutes;
2036
1930
  `);
2037
1931
  writeFile(path.join(dir, 'src/routes/index.ts'), `import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
2038
1932
  import clerkAuthPlugin from '../plugins/clerkAuth.js';
2039
1933
  import health from './health.js';
2040
1934
  import auth from './auth.js';
2041
1935
  import subscriptions from './subscriptions.js';
1936
+ import storage from './storage.js';
2042
1937
  import cors from '@fastify/cors';
2043
1938
 
2044
1939
  const routes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
@@ -2066,6 +1961,12 @@ const routes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
2066
1961
  await app.register(cors, { origin: restrictedCorsOrigin, maxAge: 86_400, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Platform', 'X-RevenueCat-Signature'] });
2067
1962
  await app.register(subscriptions);
2068
1963
  }, { prefix: '/subscriptions' });
1964
+
1965
+ await fastify.register(async (app) => {
1966
+ await app.register(cors, { origin: restrictedCorsOrigin, maxAge: 86_400, methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Platform'] });
1967
+ await app.register(clerkAuthPlugin);
1968
+ await app.register(storage);
1969
+ }, { prefix: '/storage' });
2069
1970
  };
2070
1971
 
2071
1972
  export default routes;