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.
- package/README.md +120 -155
- package/dist/dependencies.d.ts +2 -0
- package/dist/dependencies.js +12 -0
- package/dist/scaffold.js +158 -116
- package/dist/templates/backend.js +135 -234
- package/dist/templates/frontend.js +117 -411
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
829
|
+
export async function createSignedDownloadUrl(objectKey: string, expiresIn = 60 * 10) {
|
|
862
830
|
const client = getR2Client();
|
|
863
|
-
|
|
831
|
+
const downloadUrl = await getSignedUrl(client, new GetObjectCommand({
|
|
864
832
|
Bucket: getBucket(),
|
|
865
833
|
Key: objectKey,
|
|
866
|
-
}), { expiresIn
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
1912
|
-
import { createAuthHook
|
|
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;
|