anhedral 0.1.7 → 0.1.9

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,16 +40,26 @@ 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
- '@anhedral/config': 'workspace:*',
46
- '@anhedral/types': 'workspace:*',
52
+ '@shared/config': 'workspace:*',
53
+ '@shared/contracts': 'workspace:*',
54
+ '@shared/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: {
52
- '@anhedral/db': 'workspace:*',
61
+ '@shared/contracts': 'workspace:*',
62
+ '@shared/db': 'workspace:*',
53
63
  // renovate: datasource=npm depName=fastify
54
64
  fastify: '5.6.2',
55
65
  // renovate: datasource=npm depName=fastify-plugin
@@ -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,
@@ -105,7 +116,7 @@ export const BACKEND_DEPENDENCIES = {
105
116
  };
106
117
  export const EXTENSION_DEPENDENCIES = {
107
118
  dependencies: {
108
- '@anhedral/api-client': 'workspace:*',
119
+ '@shared/api-client': 'workspace:*',
109
120
  // renovate: datasource=npm depName=@clerk/chrome-extension
110
121
  '@clerk/chrome-extension': '3.1.25',
111
122
  // renovate: datasource=npm depName=react
@@ -141,7 +152,7 @@ export const EXTENSION_DEPENDENCIES = {
141
152
  },
142
153
  };
143
154
  export const FRONTEND_ADDON_DEPENDENCIES = {
144
- '@anhedral/api-client': 'workspace:*',
155
+ '@shared/api-client': 'workspace:*',
145
156
  // renovate: datasource=npm depName=@clerk/expo
146
157
  '@clerk/expo': '3.2.11',
147
158
  // renovate: datasource=npm depName=react-native-purchases
@@ -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
 
@@ -492,7 +496,7 @@ function writeStackFile(root, stack) {
492
496
  function writeSharedPackages(root) {
493
497
  const dbRoot = path.join(root, 'packages/db');
494
498
  writeFile(path.join(dbRoot, 'package.json'), JSON.stringify({
495
- name: '@anhedral/db',
499
+ name: '@shared/db',
496
500
  version: '0.1.0',
497
501
  private: true,
498
502
  type: 'module',
@@ -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: { '@shared/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 '@shared/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 '@shared/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 '@shared/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,13 +998,34 @@ 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
  ];
919
1025
  for (const [name, packageFields, source] of simplePackages) {
920
1026
  const packageRoot = path.join(root, `packages/${name}`);
921
1027
  writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
922
- name: `@anhedral/${name}`,
1028
+ name: `@shared/${name}`,
923
1029
  version: '0.1.0',
924
1030
  private: true,
925
1031
  type: 'module',
@@ -1068,8 +1174,10 @@ function writeFullstackRootFiles(root, options) {
1068
1174
  'verify:frontend': 'pnpm --filter ./Frontend exec expo install --check && pnpm --filter ./Frontend build:web',
1069
1175
  'verify:backend': 'pnpm --filter ./Backend test && pnpm --filter ./Backend build',
1070
1176
  'verify:extension': 'pnpm --filter ./Extension typecheck && pnpm --filter ./Extension zip',
1071
- 'db:generate': 'pnpm --filter @anhedral/db db:generate',
1072
- 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
1177
+ 'db:generate': 'pnpm --filter @shared/db db:generate',
1178
+ 'db:migrate': 'pnpm --filter @shared/db db:migrate',
1179
+ 'db:studio': 'pnpm --filter @shared/db db:studio',
1180
+ 'db:check': 'pnpm --filter @shared/db db:check',
1073
1181
  };
1074
1182
  scripts['dev:frontend'] = 'pnpm --filter ./Frontend dev';
1075
1183
  scripts['dev:extension'] = 'pnpm --filter ./Extension dev';
@@ -1114,7 +1222,7 @@ async function scaffoldFullstack(root, options) {
1114
1222
  writeFullstackRootFiles(root, options);
1115
1223
  anhedralPrint.done('Root config written');
1116
1224
  anhedralPrint.section('Shared packages');
1117
- anhedralPrint.step('Writing @anhedral/db, types, config, api-client');
1225
+ anhedralPrint.step('Writing @shared/db, types, config, api-client');
1118
1226
  writeSharedPackages(root);
1119
1227
  anhedralPrint.done('Shared packages written');
1120
1228
  const frontendUrl = 'http://localhost:8081';
@@ -52,8 +52,9 @@ function writePackageJson(dir, projectName) {
52
52
  build: 'pnpm typecheck',
53
53
  typecheck: 'tsc --noEmit',
54
54
  'db:generate': 'drizzle-kit generate',
55
- 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
55
+ 'db:migrate': 'pnpm --filter @shared/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',
@@ -273,12 +274,12 @@ export {};
273
274
  // Database
274
275
  // ═══════════════════════════════════════════════════════════════════════════
275
276
  function writeDbFiles(dir) {
276
- writeFile(path.join(dir, 'src/db/index.ts'), `export { db } from '@anhedral/db';
277
- export type { Database } from '@anhedral/db';
277
+ writeFile(path.join(dir, 'src/db/index.ts'), `export { db } from '@shared/db';
278
+ export type { Database } from '@shared/db';
278
279
  `);
279
- writeFile(path.join(dir, 'src/db/schema.ts'), `export * from '@anhedral/db/schema';
280
+ writeFile(path.join(dir, 'src/db/schema.ts'), `export * from '@shared/db/schema';
280
281
  `);
281
- writeFile(path.join(dir, 'src/db/migrate.ts'), `import '@anhedral/db/migrate';
282
+ writeFile(path.join(dir, 'src/db/migrate.ts'), `import '@shared/db/migrate';
282
283
  `);
283
284
  }
284
285
  // ═══════════════════════════════════════════════════════════════════════════
@@ -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 '@shared/contracts';
1847
+ import { AuthError, ServerError, ValidationError } from '../errors/index.js';
1848
+ import { createAuthHook } from '../lib/routeHelpers.js';
1849
+ import { createSignedDownloadUrl, createSignedUploadUrl, deleteObjectFromR2, isR2Configured } from '../lib/r2.js';
1850
+
1851
+ const SIGNED_URL_EXPIRES_IN = 60 * 10;
1852
+
1853
+ function sanitizeFileName(fileName: string | undefined) {
1854
+ const fallback = 'upload';
1855
+ const cleaned = (fileName ?? fallback)
1856
+ .normalize('NFKD')
1857
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
1858
+ .replace(/^-+|-+$/g, '')
1859
+ .slice(0, 120);
1860
+ return cleaned || fallback;
1861
+ }
1862
+
1863
+ function objectKeyForUser(userId: string, fileName: string | undefined) {
1864
+ const safeUserId = userId.replace(/[^a-zA-Z0-9_-]+/g, '_');
1865
+ return \`\${safeUserId}--\${crypto.randomUUID()}--\${sanitizeFileName(fileName)}\`;
1866
+ }
1867
+
1868
+ function assertUserOwnsObject(userId: string, objectKey: string) {
1869
+ const safeUserId = userId.replace(/[^a-zA-Z0-9_-]+/g, '_');
1870
+ if (!objectKey.startsWith(\`\${safeUserId}--\`)) {
1871
+ throw AuthError.forbidden();
1872
+ }
1873
+ }
1874
+
1875
+ const storageRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1876
+ fastify.post('/uploads', {
1877
+ preHandler: createAuthHook(fastify),
1878
+ }, async (req, reply) => {
1879
+ if (!req.user?.id) throw AuthError.unauthorized();
1880
+ if (!isR2Configured()) throw ServerError.missingConfiguration('R2');
1881
+
1882
+ const parsed = CreateUploadRequestSchema.safeParse(req.body);
1883
+ if (!parsed.success) throw ValidationError.invalidFormat('body', 'CreateUploadRequest');
1884
+ const input = parsed.data;
1885
+ const objectKey = objectKeyForUser(req.user.id, input.fileName);
1886
+ const signed = await createSignedUploadUrl({
1887
+ objectKey,
1888
+ contentType: input.contentType,
1889
+ }, SIGNED_URL_EXPIRES_IN);
1890
+
1891
+ await fastify.repos.users.createUploadRecord(req.user.id, {
1892
+ objectKey,
1893
+ bucket: signed.bucket,
1894
+ contentType: input.contentType,
1895
+ });
1896
+
1897
+ return reply.send({
1898
+ objectKey,
1899
+ uploadUrl: signed.uploadUrl,
1900
+ expiresIn: signed.expiresIn,
1901
+ headers: { 'Content-Type': input.contentType },
1902
+ });
1903
+ });
1904
+
1905
+ fastify.get<{ Params: { key: string } }>('/files/:key', {
1906
+ preHandler: createAuthHook(fastify),
1907
+ }, async (req, reply) => {
1908
+ if (!req.user?.id) throw AuthError.unauthorized();
1909
+ if (!isR2Configured()) throw ServerError.missingConfiguration('R2');
1910
+
1911
+ const objectKey = decodeURIComponent(req.params.key);
1912
+ assertUserOwnsObject(req.user.id, objectKey);
1913
+ return reply.send(await createSignedDownloadUrl(objectKey, SIGNED_URL_EXPIRES_IN));
1914
+ });
1915
+
1916
+ fastify.delete<{ Params: { key: string } }>('/files/:key', {
1917
+ preHandler: createAuthHook(fastify),
1918
+ }, async (req, reply) => {
1919
+ if (!req.user?.id) throw AuthError.unauthorized();
1920
+ if (!isR2Configured()) throw ServerError.missingConfiguration('R2');
1921
+
1922
+ const objectKey = decodeURIComponent(req.params.key);
1923
+ assertUserOwnsObject(req.user.id, objectKey);
1924
+ await deleteObjectFromR2(objectKey);
1925
+ return reply.code(204).send();
1926
+ });
1927
+ };
1928
+
1929
+ export default storageRoutes;
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;
@@ -452,7 +452,7 @@ export { WEBSITE_URL };
452
452
  `);
453
453
  }
454
454
  function writeApiClient(dir) {
455
- writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient } from '@anhedral/api-client';
455
+ writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient } from '@shared/api-client';
456
456
 
457
457
  export class APIClient {
458
458
  constructor(private getToken: () => Promise<string | null>) {}
@@ -61,7 +61,7 @@ function patchFrontendPackageJson(dir) {
61
61
  const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
62
62
  packageJson.dependencies = {
63
63
  ...(packageJson.dependencies ?? {}),
64
- '@anhedral/api-client': FRONTEND_ADDON_DEPENDENCIES['@anhedral/api-client'],
64
+ '@shared/api-client': FRONTEND_ADDON_DEPENDENCIES['@shared/api-client'],
65
65
  '@clerk/expo': FRONTEND_ADDON_DEPENDENCIES['@clerk/expo'],
66
66
  'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
67
67
  'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
@@ -113,7 +113,7 @@ function patchExpoAppConfig(dir) {
113
113
  writeFile(filePath, JSON.stringify(appJson, null, 2) + '\n');
114
114
  }
115
115
  function writeApiClient(dir) {
116
- writeFile(path.join(dir, 'api/client.ts'), `import { ApiClient, APIRequestError } from '@anhedral/api-client';
116
+ writeFile(path.join(dir, 'api/client.ts'), `import { ApiClient, APIRequestError } from '@shared/api-client';
117
117
 
118
118
  export class APIClient extends ApiClient {
119
119
  constructor(baseUrl: string, getToken: () => Promise<string | null>) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anhedral",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": {