anhedral 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 avatar URL retrieval work from the deployed Backend domain.
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,
@@ -583,7 +589,7 @@ export const SUBSCRIPTION_EVENT_TYPES = [
583
589
  'trial_started', 'trial_converted', 'trial_expired',
584
590
  'initial_purchase', 'renewal', 'product_change',
585
591
  'cancellation_scheduled', 'cancellation_unscheduled', 'subscription_expired', 'subscription_canceled',
586
- 'promo_redeemed', 'billing_issue', 'billing_recovered',
592
+ 'billing_issue', 'billing_recovered',
587
593
  ] as const;
588
594
  export type SubscriptionEventType = (typeof SUBSCRIPTION_EVENT_TYPES)[number];
589
595
 
@@ -591,14 +597,11 @@ export type SubscriptionMetadata = {
591
597
  revenueCatProductId?: string;
592
598
  lastWebhookUpdate?: string;
593
599
  cancelReason?: string;
594
- redeemCode?: string;
595
- redeemCodeRedeemedAt?: string;
596
600
  };
597
601
 
598
602
  export type SubscriptionEventMetadata = {
599
603
  revenueCatEventType?: string;
600
604
  revenueCatProductId?: string;
601
- promoCode?: string;
602
605
  billingPeriod?: string;
603
606
  price?: { amount: number; currency: string };
604
607
  store?: string;
@@ -606,9 +609,6 @@ export type SubscriptionEventMetadata = {
606
609
  reason?: string;
607
610
  };
608
611
 
609
- export const PROMO_CODE_DURATIONS = [1, 6, 12] as const;
610
- export type PromoCodeDuration = (typeof PROMO_CODE_DURATIONS)[number];
611
-
612
612
  export const users = pgTable('users', {
613
613
  id: text('id').primaryKey(),
614
614
  clerkUserId: text('clerk_user_id').unique(),
@@ -618,9 +618,6 @@ export const users = pgTable('users', {
618
618
  profileImageUrl: text('profile_image_url'),
619
619
  subscriptionTier: text('subscription_tier').notNull().default('free'),
620
620
  subscriptionStatus: text('subscription_status').notNull().default('active'),
621
- avatarObjectKey: text('avatar_object_key'),
622
- avatarMimeType: text('avatar_mime_type'),
623
- creditsBalance: integer('credits_balance').notNull().default(250),
624
621
  createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
625
622
  updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
626
623
  }, (t) => [
@@ -693,27 +690,6 @@ export const trialClaims = pgTable('trial_claims', {
693
690
  claimedAt: timestamp('claimed_at').$defaultFn(() => new Date()).notNull(),
694
691
  }, (t) => [index('trial_claims_email_idx').on(t.email)]);
695
692
 
696
- export const promoCodes = pgTable('promo_codes', {
697
- id: text('id').primaryKey(),
698
- code: text('code').notNull().unique(),
699
- months: integer('months').$type<PromoCodeDuration>().notNull(),
700
- maxRedemptions: integer('max_redemptions').notNull().default(1),
701
- redeemedCount: integer('redeemed_count').notNull().default(0),
702
- expiresAt: timestamp('expires_at'),
703
- createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
704
- }, (t) => [index('promo_codes_code_idx').on(t.code)]);
705
-
706
- export const promoRedemptions = pgTable('promo_redemptions', {
707
- id: text('id').primaryKey(),
708
- promoCodeId: text('promo_code_id').notNull().references(() => promoCodes.id, { onDelete: 'cascade' }),
709
- userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
710
- redeemedAt: timestamp('redeemed_at').$defaultFn(() => new Date()).notNull(),
711
- entitlementExpiresAt: timestamp('entitlement_expires_at').notNull(),
712
- }, (t) => [
713
- index('promo_redemptions_user_idx').on(t.userId),
714
- index('promo_redemptions_code_idx').on(t.promoCodeId),
715
- ]);
716
-
717
693
  export type Users = InferSelectModel<typeof users>;
718
694
  export type NewUsers = InferInsertModel<typeof users>;
719
695
  export type Uploads = InferSelectModel<typeof uploads>;
@@ -724,10 +700,6 @@ export type SubscriptionEvents = InferSelectModel<typeof subscriptionEvents>;
724
700
  export type NewSubscriptionEvents = InferInsertModel<typeof subscriptionEvents>;
725
701
  export type TrialClaims = InferSelectModel<typeof trialClaims>;
726
702
  export type NewTrialClaims = InferInsertModel<typeof trialClaims>;
727
- export type PromoCodes = InferSelectModel<typeof promoCodes>;
728
- export type NewPromoCodes = InferInsertModel<typeof promoCodes>;
729
- export type PromoRedemptions = InferSelectModel<typeof promoRedemptions>;
730
- export type NewPromoRedemptions = InferInsertModel<typeof promoRedemptions>;
731
703
  `);
732
704
  writeFile(path.join(dbRoot, 'src/queries/users.ts'), `import { eq } from 'drizzle-orm';
733
705
  import { db } from '../index';
@@ -766,20 +738,6 @@ export async function syncUserProfile(input: SyncUserInput) {
766
738
  return findUserByClerkId(input.clerkUserId);
767
739
  }
768
740
 
769
- export async function updateAvatarForUser(
770
- clerkUserId: string,
771
- input: { objectKey: string; contentType: string | null },
772
- ) {
773
- await db
774
- .update(users)
775
- .set({
776
- avatarObjectKey: input.objectKey,
777
- avatarMimeType: input.contentType,
778
- updatedAt: new Date(),
779
- })
780
- .where(eq(users.clerkUserId, clerkUserId));
781
- }
782
-
783
741
  export async function createUploadRecord(
784
742
  clerkUserId: string,
785
743
  input: { objectKey: string; bucket: string; contentType: string | null },
@@ -828,50 +786,104 @@ console.log('Migrations completed in', Date.now() - start, 'ms');
828
786
  `);
829
787
  writeFile(path.join(dbRoot, 'migrations/.gitkeep'), '');
830
788
  const simplePackages = [
831
- ['types', { exports: { '.': './src/index.ts' } }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
832
- export type ClientPlatform = 'frontend' | 'extension';
833
- export type SubscriptionEntitlements = {
834
- pro: boolean;
835
- inTrial: boolean;
836
- trialEndsAt?: string;
837
- expiresAt?: string;
838
- periodStart?: string;
839
- periodEnd?: string;
840
- method?: 'trialing' | 'redeemed' | 'paid' | null;
841
- managementUrl?: string;
842
- cancelAtPeriodEnd?: boolean;
843
- };
844
- export type AuthMeResponse = {
845
- user: {
846
- id: string;
847
- email: string;
848
- firstName?: string | null;
849
- lastName?: string | null;
850
- displayName: string;
851
- imageUrl?: string | null;
852
- avatarUrl?: string | null;
853
- creditsBalance: number;
854
- subscriptionTier: string;
855
- subscriptionStatus: string;
856
- };
857
- };
858
- export type PricingResponse = {
859
- tiers: Array<{
860
- tier: 'free' | 'pro';
861
- displayName: string;
862
- description: string;
863
- priceMonthly: number | null;
864
- priceYearly: number | null;
865
- currency: string;
866
- limits: {
867
- dailyLimit: number | null;
868
- };
869
- paymentInfo?: {
870
- revenueCatEntitlementId: string;
871
- revenueCatOfferingId: string;
872
- };
873
- }>;
874
- };
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';
875
887
  `],
876
888
  ['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
877
889
  export const DEFAULT_LOCAL_API_URL = 'http://localhost:8787';
@@ -887,8 +899,24 @@ export function joinApiUrl(baseUrl: string, path: string) {
887
899
  ['api-client', {
888
900
  exports: { '.': './src/index.ts' },
889
901
  dependencies: API_CLIENT_DEPENDENCIES.dependencies,
890
- }, `import { joinApiUrl } from '@anhedral/config';
891
- 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';
892
920
 
893
921
  export class APIRequestError extends Error {
894
922
  constructor(
@@ -910,10 +938,12 @@ export type ApiClientOptions = {
910
938
  export class ApiClient {
911
939
  constructor(private readonly options: ApiClientOptions) {}
912
940
 
913
- async request<T>(path: string, init: RequestInit = {}): Promise<T> {
941
+ async request<T>(path: string, init: RequestInit = {}, schema?: ZodType<T>): Promise<T> {
914
942
  const token = await this.options.getToken?.();
915
943
  const headers = new Headers(init.headers);
916
- 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
+ }
917
947
  headers.set('X-Platform', this.options.platform);
918
948
  if (token) headers.set('Authorization', \`Bearer \${token}\`);
919
949
 
@@ -933,15 +963,16 @@ export class ApiClient {
933
963
  }
934
964
 
935
965
  if (response.status === 204) return {} as T;
936
- return response.json() as Promise<T>;
966
+ const data = await response.json();
967
+ return schema ? schema.parse(data) : data as T;
937
968
  }
938
969
 
939
970
  getMe() {
940
- return this.request<AuthMeResponse>('/auth/me');
971
+ return this.request<AuthMeResponse>('/auth/me', {}, AuthMeResponseSchema);
941
972
  }
942
973
 
943
974
  getPricing() {
944
- return this.request<PricingResponse>('/subscriptions/pricing');
975
+ return this.request<PricingResponse>('/subscriptions/pricing', {}, PricingResponseSchema);
945
976
  }
946
977
 
947
978
  getSubscriptionPricing() {
@@ -951,31 +982,40 @@ export class ApiClient {
951
982
  getSubscriptionEntitlements(options?: { refresh?: boolean }) {
952
983
  return this.request<SubscriptionEntitlements>(
953
984
  options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
985
+ {},
986
+ SubscriptionEntitlementsSchema,
954
987
  );
955
988
  }
956
989
 
957
- redeemCode(code: string) {
958
- return this.request<{ ok: boolean; expiresAt: string }>('/subscriptions/redeem', {
990
+ signOut() {
991
+ return this.request<SignOutResponse>('/auth/signout', {
959
992
  method: 'POST',
960
- body: JSON.stringify({ code }),
961
- });
993
+ }, SignOutResponseSchema);
962
994
  }
963
995
 
964
- signOut() {
965
- return this.request<{ success: boolean }>('/auth/signout', {
966
- method: 'POST',
996
+ deleteAccount() {
997
+ return this.request<void>('/auth/account', {
998
+ method: 'DELETE',
967
999
  });
968
1000
  }
969
1001
 
970
- uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
971
- return this.request<{ ok: boolean; avatarUrl: string }>('/auth/avatar', {
1002
+ createUpload(input: CreateUploadRequest) {
1003
+ return this.request<CreateUploadResponse>('/storage/uploads', {
972
1004
  method: 'POST',
973
1005
  body: JSON.stringify(input),
974
- });
1006
+ }, CreateUploadResponseSchema);
975
1007
  }
976
1008
 
977
- deleteAccount() {
978
- return this.request<void>('/auth/account', {
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)}\`, {
979
1019
  method: 'DELETE',
980
1020
  });
981
1021
  }
@@ -1136,6 +1176,8 @@ function writeFullstackRootFiles(root, options) {
1136
1176
  'verify:extension': 'pnpm --filter ./Extension typecheck && pnpm --filter ./Extension zip',
1137
1177
  'db:generate': 'pnpm --filter @anhedral/db db:generate',
1138
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',
1139
1181
  };
1140
1182
  scripts['dev:frontend'] = 'pnpm --filter ./Frontend dev';
1141
1183
  scripts['dev:extension'] = 'pnpm --filter ./Extension dev';