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 +25 -0
- package/dist/dependencies.d.ts +2 -0
- package/dist/dependencies.js +17 -5
- package/dist/scaffold.js +166 -58
- package/dist/templates/backend.js +136 -17
- package/dist/templates/extension.js +1 -1
- package/dist/templates/frontend.js +2 -2
- package/package.json +1 -1
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:
|
package/dist/dependencies.d.ts
CHANGED
|
@@ -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;
|
package/dist/dependencies.js
CHANGED
|
@@ -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
|
-
'@
|
|
46
|
-
'@
|
|
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
|
-
'@
|
|
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
|
-
'@
|
|
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
|
-
'@
|
|
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.
|
|
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
|
|
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: '@
|
|
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
|
-
['
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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 {
|
|
839
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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: `@
|
|
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 @
|
|
1072
|
-
'db:migrate': 'pnpm --filter @
|
|
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 @
|
|
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 @
|
|
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 '@
|
|
277
|
-
export type { Database } from '@
|
|
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 '@
|
|
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 '@
|
|
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
|
|
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
|
|
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
|
|
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
|
|
829
|
+
export async function createSignedDownloadUrl(objectKey: string, expiresIn = 60 * 10) {
|
|
829
830
|
const client = getR2Client();
|
|
830
|
-
|
|
831
|
+
const downloadUrl = await getSignedUrl(client, new GetObjectCommand({
|
|
831
832
|
Bucket: getBucket(),
|
|
832
833
|
Key: objectKey,
|
|
833
|
-
}), { 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
|
+
}));
|
|
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
|
|
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 '@
|
|
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
|
-
'@
|
|
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 '@
|
|
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>) {
|