anhedral 0.1.7 → 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 CHANGED
@@ -39,6 +39,7 @@ pnpm install
39
39
  ├─ packages/
40
40
  │ ├─ api-client/ shared typed API client
41
41
  │ ├─ config/ shared config helpers
42
+ │ ├─ contracts/ shared Zod request/response contracts
42
43
  │ ├─ db/ Drizzle schema, Neon client, migrations
43
44
  │ └─ types/ shared TypeScript types
44
45
  ├─ PRODUCTION.md deployment checklist
@@ -69,6 +70,30 @@ Anhedral supports one carefully maintained stack:
69
70
 
70
71
  The goal is stability. The generated project avoids framework sprawl, duplicate schemas, and separate frontend/backend/extension repositories.
71
72
 
73
+ ## Why This Stack
74
+
75
+ Anhedral is built around one idea: a startup should be able to launch every major client from one repository without committing to infrastructure that becomes expensive, fragmented, or hard to change later.
76
+
77
+ The stack is intentionally narrow:
78
+
79
+ | Choice | Why Anhedral uses it |
80
+ | --- | --- |
81
+ | Expo | One React Native codebase targets web browsers, iOS, and Android. Compared with separate web and native apps, Expo keeps product iteration faster and avoids rebuilding shared screens, auth flows, and client logic three times. |
82
+ | React Native Reusables | It brings shadcn-style primitives to React Native and Expo without locking the app into a heavy design system. Compared with bespoke component scaffolds, it gives teams accessible primitives they can replace or extend as the product becomes specific. |
83
+ | Fastify | Fastify is small, fast, TypeScript-friendly, and works well as a central API layer on Vercel. Compared with pushing logic into many frontend routes, serverless functions, or client SDK calls, a Fastify backend centralizes auth, billing, storage, database access, webhooks, and internal APIs in one place. |
84
+ | TypeScript | The whole stack shares types, contracts, and API clients. Compared with untyped template code, this catches frontend/backend drift before deploy and makes the generated app safer to evolve. |
85
+ | Zod contracts | Request and response schemas live in shared packages. Compared with duplicating interfaces across clients, Zod lets the backend validate inputs while the frontend parses responses from the same source of truth. |
86
+ | Neon + Drizzle | Neon gives serverless Postgres that can start free and scale with usage; Drizzle keeps schema and queries close to TypeScript. Compared with proprietary document databases or opaque ORMs, this keeps the data layer portable, SQL-native, and easy to inspect. |
87
+ | Cloudflare R2/CDN | R2 provides S3-compatible object storage without S3-style egress fees. Compared with storing files in the database or coupling uploads to one app client, signed storage routes give every client the same upload/download primitive. |
88
+ | Clerk | Clerk handles modern auth across web, native, and extension clients. Compared with hand-rolled auth, it removes a high-risk surface area while still letting the backend own authorization decisions. |
89
+ | RevenueCat + Stripe | RevenueCat coordinates native subscriptions and Stripe web billing behind one entitlement model. Compared with separate payment implementations per platform, the backend can ask one question: what is this user allowed to access? |
90
+ | WXT | WXT gives the Chrome extension a modern TypeScript/Vite workflow and supports the Side Panel API. Compared with hand-maintained manifest scaffolds, it reduces extension build and packaging mistakes. |
91
+ | Vercel | Vercel deploys the Expo web app and Fastify API from the same Git repository with separate project roots. Compared with manually wiring CI/CD early, teams get preview deployments and production deploys from normal commits. |
92
+
93
+ Every part of the stack can be started on free tiers and scales automatically as traffic grows. That matters because the first production architecture should not force a team to choose between moving quickly and being able to handle success.
94
+
95
+ The bigger advantage is architectural: Anhedral creates all target clients plus one backend that can serve internal and external requests. Product logic, auth decisions, subscription entitlements, storage access, database writes, and webhooks have a central home. That helps startups avoid the common failure mode where every client invents its own rules, and the team later cannot respond quickly when the product needs to shift.
96
+
72
97
  ## Local Development
73
98
 
74
99
  From the generated project root:
@@ -13,6 +13,7 @@ export declare const TOOLCHAIN_DEPENDENCIES: {
13
13
  export declare const ROOT_DEPENDENCIES: DependencyGroup;
14
14
  export declare const SHARED_DB_DEPENDENCIES: DependencyGroup;
15
15
  export declare const SHARED_PACKAGE_DEPENDENCIES: DependencyGroup;
16
+ export declare const CONTRACTS_DEPENDENCIES: DependencyGroup;
16
17
  export declare const API_CLIENT_DEPENDENCIES: DependencyGroup;
17
18
  export declare const BACKEND_DEPENDENCIES: DependencyGroup;
18
19
  export declare const EXTENSION_DEPENDENCIES: DependencyGroup;
@@ -27,6 +28,7 @@ export declare function dependencyManifest(): {
27
28
  readonly wxt: "0.20.25";
28
29
  };
29
30
  root: DependencyGroup;
31
+ contracts: DependencyGroup;
30
32
  sharedDb: DependencyGroup;
31
33
  sharedPackages: DependencyGroup;
32
34
  backend: DependencyGroup;
@@ -40,15 +40,25 @@ export const SHARED_PACKAGE_DEPENDENCIES = {
40
40
  typescript: '5.9.3',
41
41
  },
42
42
  };
43
+ export const CONTRACTS_DEPENDENCIES = {
44
+ dependencies: {
45
+ // renovate: datasource=npm depName=zod
46
+ zod: '4.2.1',
47
+ },
48
+ ...SHARED_PACKAGE_DEPENDENCIES,
49
+ };
43
50
  export const API_CLIENT_DEPENDENCIES = {
44
51
  dependencies: {
45
52
  '@anhedral/config': 'workspace:*',
53
+ '@anhedral/contracts': 'workspace:*',
46
54
  '@anhedral/types': 'workspace:*',
55
+ zod: CONTRACTS_DEPENDENCIES.dependencies.zod,
47
56
  },
48
57
  ...SHARED_PACKAGE_DEPENDENCIES,
49
58
  };
50
59
  export const BACKEND_DEPENDENCIES = {
51
60
  dependencies: {
61
+ '@anhedral/contracts': 'workspace:*',
52
62
  '@anhedral/db': 'workspace:*',
53
63
  // renovate: datasource=npm depName=fastify
54
64
  fastify: '5.6.2',
@@ -81,6 +91,7 @@ export const BACKEND_DEPENDENCIES = {
81
91
  // renovate: datasource=npm depName=@aws-sdk/s3-request-presigner
82
92
  '@aws-sdk/s3-request-presigner': '3.1047.0',
83
93
  dotenv: SHARED_DB_DEPENDENCIES.dependencies.dotenv,
94
+ zod: CONTRACTS_DEPENDENCIES.dependencies.zod,
84
95
  },
85
96
  devDependencies: {
86
97
  typescript: SHARED_DB_DEPENDENCIES.devDependencies.typescript,
@@ -164,6 +175,7 @@ export function dependencyManifest() {
164
175
  packageManager: PACKAGE_MANAGER,
165
176
  toolchain: TOOLCHAIN_DEPENDENCIES,
166
177
  root: ROOT_DEPENDENCIES,
178
+ contracts: CONTRACTS_DEPENDENCIES,
167
179
  sharedDb: SHARED_DB_DEPENDENCIES,
168
180
  sharedPackages: SHARED_PACKAGE_DEPENDENCIES,
169
181
  backend: BACKEND_DEPENDENCIES,
package/dist/scaffold.js CHANGED
@@ -4,7 +4,7 @@ import { env } from 'node:process';
4
4
  import { anhedralPrint } from './print.js';
5
5
  import { appendGitignore, writeFile } from './util.js';
6
6
  import { getSkillCommands } from './commands.js';
7
- import { API_CLIENT_DEPENDENCIES, PACKAGE_MANAGER, ROOT_DEPENDENCIES, SHARED_DB_DEPENDENCIES, SHARED_PACKAGE_DEPENDENCIES, dependencyManifest, } from './dependencies.js';
7
+ import { API_CLIENT_DEPENDENCIES, CONTRACTS_DEPENDENCIES, PACKAGE_MANAGER, ROOT_DEPENDENCIES, SHARED_DB_DEPENDENCIES, SHARED_PACKAGE_DEPENDENCIES, dependencyManifest, } from './dependencies.js';
8
8
  import { resolveToolchain } from './toolchain.js';
9
9
  import { scaffoldBackend } from './templates/backend.js';
10
10
  import { scaffoldExtension } from './templates/extension.js';
@@ -259,6 +259,7 @@ The generated app is one pnpm monorepo:
259
259
  ├─ packages/
260
260
  │ ├─ api-client/ shared typed API client
261
261
  │ ├─ config/ shared config constants/helpers
262
+ │ ├─ contracts/ shared Zod request/response contracts
262
263
  │ ├─ db/ Drizzle schema, Neon client, migrations
263
264
  │ └─ types/ shared TypeScript types
264
265
  ├─ PRODUCTION.md development and deployment guide
@@ -292,7 +293,7 @@ Important files:
292
293
  - \`Backend/src/index.ts\`: Vercel Fastify entrypoint
293
294
  - \`Backend/src/app.ts\`: Fastify app construction
294
295
  - \`Backend/vercel.json\`: minimal Vercel config
295
- - \`Backend/src/routes\`: health/auth/subscription routes
296
+ - \`Backend/src/routes\`: health/auth/subscription/storage routes
296
297
 
297
298
  ## Extension
298
299
 
@@ -337,6 +338,8 @@ Database workflow:
337
338
  \`\`\`sh
338
339
  pnpm db:generate
339
340
  pnpm db:migrate
341
+ pnpm db:studio
342
+ pnpm db:check
340
343
  \`\`\`
341
344
 
342
345
  Local demo mode is enabled through generated \`.env\` files. Real provider behavior needs Clerk, RevenueCat/Stripe, Neon, and R2 credentials.
@@ -419,7 +422,8 @@ Steps:
419
422
  1. Create an R2 bucket.
420
423
  2. Create a least-privilege API token for that bucket.
421
424
  3. Set \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, and \`R2_BUCKET\` only in Backend envs.
422
- 4. Configure a public bucket URL or custom domain if uploaded assets need public CDN delivery.
425
+ 4. Use \`POST /storage/uploads\` to create signed upload URLs, then \`GET /storage/files/:key\` or \`DELETE /storage/files/:key\` for user-owned objects.
426
+ 5. Configure a public bucket URL or custom domain if uploaded assets need public CDN delivery.
423
427
 
424
428
  ## Deploy
425
429
 
@@ -462,7 +466,7 @@ Upload \`Extension/.output/*-chrome.zip\` to the Chrome Web Store Developer Dash
462
466
  - Confirm Clerk works on local web, Vercel web, iOS, Android, and the Chrome extension.
463
467
  - Confirm RevenueCat returns the \`pro\` entitlement after Stripe web purchase and native store purchase.
464
468
  - Confirm \`pnpm db:migrate\` has run against the production Neon database.
465
- - Confirm R2 upload and signed URL retrieval work from the deployed Backend domain if you expose storage routes.
469
+ - Confirm R2 upload, signed URL retrieval, and deletion work from the deployed Backend domain.
466
470
  - Confirm both Vercel projects can resolve \`packages/*\` workspace packages.
467
471
  - Confirm the Chrome extension ZIP is tested locally with \`chrome://extensions\` before Chrome Web Store upload.
468
472
 
@@ -507,6 +511,8 @@ function writeSharedPackages(root) {
507
511
  typecheck: 'tsc --noEmit',
508
512
  'db:generate': 'drizzle-kit generate',
509
513
  'db:migrate': 'tsx --env-file=../../.env src/migrate.ts',
514
+ 'db:studio': 'drizzle-kit studio',
515
+ 'db:check': 'drizzle-kit check',
510
516
  },
511
517
  dependencies: SHARED_DB_DEPENDENCIES.dependencies,
512
518
  devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
@@ -780,46 +786,104 @@ console.log('Migrations completed in', Date.now() - start, 'ms');
780
786
  `);
781
787
  writeFile(path.join(dbRoot, 'migrations/.gitkeep'), '');
782
788
  const simplePackages = [
783
- ['types', { exports: { '.': './src/index.ts' } }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
784
- export type ClientPlatform = 'frontend' | 'extension';
785
- export type SubscriptionEntitlements = {
786
- pro: boolean;
787
- inTrial: boolean;
788
- trialEndsAt?: string;
789
- expiresAt?: string;
790
- periodStart?: string;
791
- periodEnd?: string;
792
- method?: 'trialing' | 'redeemed' | 'paid' | null;
793
- managementUrl?: string;
794
- cancelAtPeriodEnd?: boolean;
795
- };
796
- export type AuthMeResponse = {
797
- user: {
798
- id: string;
799
- email: string;
800
- firstName?: string | null;
801
- lastName?: string | null;
802
- displayName: string;
803
- imageUrl?: string | null;
804
- };
805
- };
806
- export type PricingResponse = {
807
- tiers: Array<{
808
- tier: 'free' | 'pro';
809
- displayName: string;
810
- description: string;
811
- priceMonthly: number | null;
812
- priceYearly: number | null;
813
- currency: string;
814
- limits: {
815
- dailyLimit: number | null;
816
- };
817
- paymentInfo?: {
818
- revenueCatEntitlementId: string;
819
- revenueCatOfferingId: string;
820
- };
821
- }>;
822
- };
789
+ ['contracts', {
790
+ exports: { '.': './src/index.ts' },
791
+ dependencies: CONTRACTS_DEPENDENCIES.dependencies,
792
+ }, `import { z } from 'zod';
793
+
794
+ export const ClientPlatformSchema = z.enum(['frontend', 'extension']);
795
+ export type ClientPlatform = z.infer<typeof ClientPlatformSchema>;
796
+
797
+ export const ApiErrorSchema = z.object({
798
+ error: z.string(),
799
+ message: z.string(),
800
+ });
801
+ export type ApiError = z.infer<typeof ApiErrorSchema>;
802
+
803
+ export const AuthMeResponseSchema = z.object({
804
+ user: z.object({
805
+ id: z.string(),
806
+ email: z.string().email(),
807
+ firstName: z.string().nullable().optional(),
808
+ lastName: z.string().nullable().optional(),
809
+ displayName: z.string(),
810
+ imageUrl: z.string().url().nullable().optional(),
811
+ }),
812
+ });
813
+ export type AuthMeResponse = z.infer<typeof AuthMeResponseSchema>;
814
+
815
+ export const PricingResponseSchema = z.object({
816
+ tiers: z.array(z.object({
817
+ tier: z.enum(['free', 'pro']),
818
+ displayName: z.string(),
819
+ description: z.string(),
820
+ priceMonthly: z.number().nullable(),
821
+ priceYearly: z.number().nullable(),
822
+ currency: z.string(),
823
+ limits: z.object({
824
+ dailyLimit: z.number().nullable(),
825
+ }),
826
+ paymentInfo: z.object({
827
+ revenueCatEntitlementId: z.string(),
828
+ revenueCatOfferingId: z.string(),
829
+ }).optional(),
830
+ })),
831
+ });
832
+ export type PricingResponse = z.infer<typeof PricingResponseSchema>;
833
+
834
+ export const SubscriptionEntitlementsSchema = z.object({
835
+ pro: z.boolean(),
836
+ inTrial: z.boolean(),
837
+ trialEndsAt: z.string().optional(),
838
+ expiresAt: z.string().optional(),
839
+ periodStart: z.string().optional(),
840
+ periodEnd: z.string().optional(),
841
+ method: z.enum(['trialing', 'redeemed', 'paid']).nullable().optional(),
842
+ managementUrl: z.string().url().optional(),
843
+ cancelAtPeriodEnd: z.boolean().optional(),
844
+ });
845
+ export type SubscriptionEntitlements = z.infer<typeof SubscriptionEntitlementsSchema>;
846
+
847
+ export const SignOutResponseSchema = z.object({
848
+ success: z.boolean(),
849
+ });
850
+ export type SignOutResponse = z.infer<typeof SignOutResponseSchema>;
851
+
852
+ export const CreateUploadRequestSchema = z.object({
853
+ fileName: z.string().min(1).max(200).optional(),
854
+ contentType: z.string().min(1).max(200),
855
+ });
856
+ export type CreateUploadRequest = z.infer<typeof CreateUploadRequestSchema>;
857
+
858
+ export const CreateUploadResponseSchema = z.object({
859
+ objectKey: z.string(),
860
+ uploadUrl: z.string().url(),
861
+ expiresIn: z.number(),
862
+ headers: z.record(z.string(), z.string()),
863
+ });
864
+ export type CreateUploadResponse = z.infer<typeof CreateUploadResponseSchema>;
865
+
866
+ export const StorageFileResponseSchema = z.object({
867
+ objectKey: z.string(),
868
+ downloadUrl: z.string().url(),
869
+ expiresIn: z.number(),
870
+ });
871
+ export type StorageFileResponse = z.infer<typeof StorageFileResponseSchema>;
872
+ `],
873
+ ['types', {
874
+ exports: { '.': './src/index.ts' },
875
+ dependencies: { '@anhedral/contracts': 'workspace:*' },
876
+ }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
877
+ export type {
878
+ AuthMeResponse,
879
+ ClientPlatform,
880
+ CreateUploadRequest,
881
+ CreateUploadResponse,
882
+ PricingResponse,
883
+ SignOutResponse,
884
+ StorageFileResponse,
885
+ SubscriptionEntitlements,
886
+ } from '@anhedral/contracts';
823
887
  `],
824
888
  ['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
825
889
  export const DEFAULT_LOCAL_API_URL = 'http://localhost:8787';
@@ -835,8 +899,24 @@ export function joinApiUrl(baseUrl: string, path: string) {
835
899
  ['api-client', {
836
900
  exports: { '.': './src/index.ts' },
837
901
  dependencies: API_CLIENT_DEPENDENCIES.dependencies,
838
- }, `import { joinApiUrl } from '@anhedral/config';
839
- import type { AuthMeResponse, ClientPlatform, PricingResponse, SubscriptionEntitlements } from '@anhedral/types';
902
+ }, `import type { ZodType } from 'zod';
903
+ import { joinApiUrl } from '@anhedral/config';
904
+ import {
905
+ AuthMeResponseSchema,
906
+ CreateUploadResponseSchema,
907
+ PricingResponseSchema,
908
+ SignOutResponseSchema,
909
+ StorageFileResponseSchema,
910
+ SubscriptionEntitlementsSchema,
911
+ type AuthMeResponse,
912
+ type ClientPlatform,
913
+ type CreateUploadRequest,
914
+ type CreateUploadResponse,
915
+ type PricingResponse,
916
+ type SignOutResponse,
917
+ type StorageFileResponse,
918
+ type SubscriptionEntitlements,
919
+ } from '@anhedral/contracts';
840
920
 
841
921
  export class APIRequestError extends Error {
842
922
  constructor(
@@ -858,10 +938,12 @@ export type ApiClientOptions = {
858
938
  export class ApiClient {
859
939
  constructor(private readonly options: ApiClientOptions) {}
860
940
 
861
- async request<T>(path: string, init: RequestInit = {}): Promise<T> {
941
+ async request<T>(path: string, init: RequestInit = {}, schema?: ZodType<T>): Promise<T> {
862
942
  const token = await this.options.getToken?.();
863
943
  const headers = new Headers(init.headers);
864
- headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json');
944
+ if (init.body != null) {
945
+ headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json');
946
+ }
865
947
  headers.set('X-Platform', this.options.platform);
866
948
  if (token) headers.set('Authorization', \`Bearer \${token}\`);
867
949
 
@@ -881,15 +963,16 @@ export class ApiClient {
881
963
  }
882
964
 
883
965
  if (response.status === 204) return {} as T;
884
- return response.json() as Promise<T>;
966
+ const data = await response.json();
967
+ return schema ? schema.parse(data) : data as T;
885
968
  }
886
969
 
887
970
  getMe() {
888
- return this.request<AuthMeResponse>('/auth/me');
971
+ return this.request<AuthMeResponse>('/auth/me', {}, AuthMeResponseSchema);
889
972
  }
890
973
 
891
974
  getPricing() {
892
- return this.request<PricingResponse>('/subscriptions/pricing');
975
+ return this.request<PricingResponse>('/subscriptions/pricing', {}, PricingResponseSchema);
893
976
  }
894
977
 
895
978
  getSubscriptionPricing() {
@@ -899,13 +982,15 @@ export class ApiClient {
899
982
  getSubscriptionEntitlements(options?: { refresh?: boolean }) {
900
983
  return this.request<SubscriptionEntitlements>(
901
984
  options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
985
+ {},
986
+ SubscriptionEntitlementsSchema,
902
987
  );
903
988
  }
904
989
 
905
990
  signOut() {
906
- return this.request<{ success: boolean }>('/auth/signout', {
991
+ return this.request<SignOutResponse>('/auth/signout', {
907
992
  method: 'POST',
908
- });
993
+ }, SignOutResponseSchema);
909
994
  }
910
995
 
911
996
  deleteAccount() {
@@ -913,6 +998,27 @@ export class ApiClient {
913
998
  method: 'DELETE',
914
999
  });
915
1000
  }
1001
+
1002
+ createUpload(input: CreateUploadRequest) {
1003
+ return this.request<CreateUploadResponse>('/storage/uploads', {
1004
+ method: 'POST',
1005
+ body: JSON.stringify(input),
1006
+ }, CreateUploadResponseSchema);
1007
+ }
1008
+
1009
+ getStorageFile(objectKey: string) {
1010
+ return this.request<StorageFileResponse>(
1011
+ \`/storage/files/\${encodeURIComponent(objectKey)}\`,
1012
+ {},
1013
+ StorageFileResponseSchema,
1014
+ );
1015
+ }
1016
+
1017
+ deleteStorageFile(objectKey: string) {
1018
+ return this.request<void>(\`/storage/files/\${encodeURIComponent(objectKey)}\`, {
1019
+ method: 'DELETE',
1020
+ });
1021
+ }
916
1022
  }
917
1023
  `],
918
1024
  ];
@@ -1070,6 +1176,8 @@ function writeFullstackRootFiles(root, options) {
1070
1176
  'verify:extension': 'pnpm --filter ./Extension typecheck && pnpm --filter ./Extension zip',
1071
1177
  'db:generate': 'pnpm --filter @anhedral/db db:generate',
1072
1178
  'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
1179
+ 'db:studio': 'pnpm --filter @anhedral/db db:studio',
1180
+ 'db:check': 'pnpm --filter @anhedral/db db:check',
1073
1181
  };
1074
1182
  scripts['dev:frontend'] = 'pnpm --filter ./Frontend dev';
1075
1183
  scripts['dev:extension'] = 'pnpm --filter ./Extension dev';
@@ -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',
@@ -763,12 +764,11 @@ export function verifyRevenueCatWebhookAuthorization(authHeader: string | undefi
763
764
  return normalize(authHeader) === normalize(secret);
764
765
  }
765
766
  `);
766
- 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';
767
768
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
768
769
 
769
- type UploadAvatarInput = {
770
+ type UploadObjectInput = {
770
771
  objectKey: string;
771
- body: Buffer;
772
772
  contentType: string;
773
773
  };
774
774
 
@@ -808,29 +808,40 @@ export function isR2Configured() {
808
808
  return hasR2Config();
809
809
  }
810
810
 
811
- export async function uploadAvatarToR2(input: UploadAvatarInput) {
811
+ export async function createSignedUploadUrl(input: UploadObjectInput, expiresIn = 60 * 10) {
812
812
  const client = getR2Client();
813
813
  const bucket = getBucket();
814
814
 
815
- await client.send(new PutObjectCommand({
815
+ const uploadUrl = await getSignedUrl(client, new PutObjectCommand({
816
816
  Bucket: bucket,
817
817
  Key: input.objectKey,
818
- Body: input.body,
819
818
  ContentType: input.contentType,
820
- }));
819
+ }), { expiresIn });
821
820
 
822
821
  return {
823
822
  bucket,
824
823
  objectKey: input.objectKey,
824
+ uploadUrl,
825
+ expiresIn,
825
826
  };
826
827
  }
827
828
 
828
- export async function createSignedAvatarUrl(objectKey: string) {
829
+ export async function createSignedDownloadUrl(objectKey: string, expiresIn = 60 * 10) {
829
830
  const client = getR2Client();
830
- return getSignedUrl(client, new GetObjectCommand({
831
+ const downloadUrl = await getSignedUrl(client, new GetObjectCommand({
831
832
  Bucket: getBucket(),
832
833
  Key: objectKey,
833
- }), { 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
+ }));
834
845
  }
835
846
  `);
836
847
  writeFile(path.join(dir, 'src/services/SubscriptionService.ts'), `import type { FastifyInstance, FastifyRequest } from 'fastify';
@@ -1349,12 +1360,13 @@ export class CorsConfig {
1349
1360
  // Repositories
1350
1361
  // ═══════════════════════════════════════════════════════════════════════════
1351
1362
  function writeRepositories(dir) {
1352
- 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';
1353
1365
  import type { Database } from '../db/index.js';
1354
1366
  import { users } from '../db/schema.js';
1355
1367
  import type { NewUsers, SubscriptionStatus, SubscriptionTier } from '../db/schema.js';
1356
1368
  import { LRUCache } from '../lib/lruCache.js';
1357
- import { subscriptions } from '../db/schema.js';
1369
+ import { subscriptions, uploads } from '../db/schema.js';
1358
1370
 
1359
1371
  export type UserAuthData = {
1360
1372
  id: string;
@@ -1436,6 +1448,19 @@ export class UserRepository {
1436
1448
  authPluginCache.invalidate(\`auth:\${userId}\`);
1437
1449
  }
1438
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
+
1439
1464
  async deleteById(userId: string): Promise<void> {
1440
1465
  await this.db.delete(users).where(eq(users.id, userId));
1441
1466
  authPluginCache.invalidate(\`auth:\${userId}\`);
@@ -1815,12 +1840,100 @@ const subscriptionRoutes: FastifyPluginAsync = async (fastify: FastifyInstance)
1815
1840
  };
1816
1841
 
1817
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;
1818
1930
  `);
1819
1931
  writeFile(path.join(dir, 'src/routes/index.ts'), `import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
1820
1932
  import clerkAuthPlugin from '../plugins/clerkAuth.js';
1821
1933
  import health from './health.js';
1822
1934
  import auth from './auth.js';
1823
1935
  import subscriptions from './subscriptions.js';
1936
+ import storage from './storage.js';
1824
1937
  import cors from '@fastify/cors';
1825
1938
 
1826
1939
  const routes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
@@ -1848,6 +1961,12 @@ const routes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1848
1961
  await app.register(cors, { origin: restrictedCorsOrigin, maxAge: 86_400, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Platform', 'X-RevenueCat-Signature'] });
1849
1962
  await app.register(subscriptions);
1850
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' });
1851
1970
  };
1852
1971
 
1853
1972
  export default routes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anhedral",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Opinionated init CLI for Expo, Fastify, WXT, shared packages, Neon, Drizzle, Clerk, RevenueCat, Stripe, R2, and Vercel.",
5
5
  "type": "module",
6
6
  "bin": {