@ramonclaudio/create-vexpo 0.1.0

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.
Files changed (174) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +183 -0
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
  5. package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
  6. package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
  7. package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
  8. package/dist/templates/default/.eas/workflows/release.yml +44 -0
  9. package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
  10. package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
  11. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
  12. package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
  13. package/dist/templates/default/.github/workflows/check.yml +28 -0
  14. package/dist/templates/default/.maestro/launch.yaml +18 -0
  15. package/dist/templates/default/AGENTS.md +79 -0
  16. package/dist/templates/default/DESIGN.md +331 -0
  17. package/dist/templates/default/LICENSE +21 -0
  18. package/dist/templates/default/README.md +153 -0
  19. package/dist/templates/default/SETUP.md +618 -0
  20. package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
  21. package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
  22. package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
  23. package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
  24. package/dist/templates/default/_easignore +22 -0
  25. package/dist/templates/default/_editorconfig +9 -0
  26. package/dist/templates/default/_env.example +34 -0
  27. package/dist/templates/default/_fingerprintignore +24 -0
  28. package/dist/templates/default/_gitattributes +7 -0
  29. package/dist/templates/default/_gitignore +69 -0
  30. package/dist/templates/default/_oxfmtrc.json +3 -0
  31. package/dist/templates/default/_oxlintrc.json +34 -0
  32. package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
  33. package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
  34. package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
  35. package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
  36. package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
  37. package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
  38. package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
  39. package/dist/templates/default/app/(app)/_layout.tsx +73 -0
  40. package/dist/templates/default/app/(app)/debug.tsx +389 -0
  41. package/dist/templates/default/app/(app)/help.tsx +254 -0
  42. package/dist/templates/default/app/(app)/linked.tsx +116 -0
  43. package/dist/templates/default/app/(app)/privacy.tsx +159 -0
  44. package/dist/templates/default/app/(app)/profile.tsx +915 -0
  45. package/dist/templates/default/app/(app)/sessions.tsx +191 -0
  46. package/dist/templates/default/app/(app)/welcome.tsx +140 -0
  47. package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
  48. package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
  49. package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
  50. package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
  51. package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
  52. package/dist/templates/default/app/+native-intent.tsx +14 -0
  53. package/dist/templates/default/app/+not-found.tsx +51 -0
  54. package/dist/templates/default/app/_layout.tsx +102 -0
  55. package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
  56. package/dist/templates/default/app-store/screenshots/README.md +13 -0
  57. package/dist/templates/default/app.config.ts +201 -0
  58. package/dist/templates/default/app.json +11 -0
  59. package/dist/templates/default/assets/brand-icon-dark.png +0 -0
  60. package/dist/templates/default/assets/brand-icon-light.png +0 -0
  61. package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
  62. package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
  63. package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
  64. package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
  65. package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
  66. package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  67. package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
  68. package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
  69. package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
  70. package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
  71. package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
  72. package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
  73. package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
  74. package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
  75. package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
  76. package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
  77. package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
  78. package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
  79. package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
  80. package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
  81. package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
  82. package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
  83. package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
  84. package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
  85. package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
  86. package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
  87. package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
  88. package/dist/templates/default/assets/icon.png +0 -0
  89. package/dist/templates/default/assets/sounds/notification.wav +0 -0
  90. package/dist/templates/default/assets/splash-image-dark.png +0 -0
  91. package/dist/templates/default/assets/splash-image-light.png +0 -0
  92. package/dist/templates/default/bun.lock +1860 -0
  93. package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
  94. package/dist/templates/default/components/auth/password-field.tsx +121 -0
  95. package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
  96. package/dist/templates/default/components/ui/convex-error.tsx +32 -0
  97. package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
  98. package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
  99. package/dist/templates/default/components/ui/material.tsx +94 -0
  100. package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
  101. package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
  102. package/dist/templates/default/components/ui/skeleton.tsx +107 -0
  103. package/dist/templates/default/components/ui/status-text.tsx +49 -0
  104. package/dist/templates/default/components/ui/update-banner.tsx +82 -0
  105. package/dist/templates/default/constants/layout.ts +102 -0
  106. package/dist/templates/default/constants/theme.ts +401 -0
  107. package/dist/templates/default/constants/ui.ts +77 -0
  108. package/dist/templates/default/convex/_generated/api.d.ts +77 -0
  109. package/dist/templates/default/convex/_generated/api.js +23 -0
  110. package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
  111. package/dist/templates/default/convex/_generated/server.d.ts +143 -0
  112. package/dist/templates/default/convex/_generated/server.js +93 -0
  113. package/dist/templates/default/convex/admin.ts +102 -0
  114. package/dist/templates/default/convex/auth.config.ts +6 -0
  115. package/dist/templates/default/convex/auth.ts +335 -0
  116. package/dist/templates/default/convex/constants.ts +46 -0
  117. package/dist/templates/default/convex/convex.config.ts +11 -0
  118. package/dist/templates/default/convex/crons.ts +42 -0
  119. package/dist/templates/default/convex/email.ts +109 -0
  120. package/dist/templates/default/convex/env.ts +31 -0
  121. package/dist/templates/default/convex/errors.ts +33 -0
  122. package/dist/templates/default/convex/functions.ts +54 -0
  123. package/dist/templates/default/convex/http.ts +176 -0
  124. package/dist/templates/default/convex/log.ts +81 -0
  125. package/dist/templates/default/convex/pushTokens.ts +114 -0
  126. package/dist/templates/default/convex/rateLimit.ts +92 -0
  127. package/dist/templates/default/convex/schema.ts +28 -0
  128. package/dist/templates/default/convex/tsconfig.json +18 -0
  129. package/dist/templates/default/convex/users.ts +279 -0
  130. package/dist/templates/default/convex/validators.ts +74 -0
  131. package/dist/templates/default/convex/webhook.ts +193 -0
  132. package/dist/templates/default/convex.json +6 -0
  133. package/dist/templates/default/eas.json +56 -0
  134. package/dist/templates/default/fingerprint.config.js +9 -0
  135. package/dist/templates/default/hooks/use-debounce.ts +20 -0
  136. package/dist/templates/default/hooks/use-deep-link.ts +43 -0
  137. package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
  138. package/dist/templates/default/hooks/use-network.ts +11 -0
  139. package/dist/templates/default/hooks/use-notifications.ts +107 -0
  140. package/dist/templates/default/hooks/use-onboarding.ts +15 -0
  141. package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
  142. package/dist/templates/default/hooks/use-theme.ts +53 -0
  143. package/dist/templates/default/hooks/use-updates.ts +86 -0
  144. package/dist/templates/default/lib/a11y.ts +5 -0
  145. package/dist/templates/default/lib/app.ts +14 -0
  146. package/dist/templates/default/lib/assets.ts +17 -0
  147. package/dist/templates/default/lib/auth-client.ts +21 -0
  148. package/dist/templates/default/lib/convex-auth.tsx +79 -0
  149. package/dist/templates/default/lib/deep-link.ts +71 -0
  150. package/dist/templates/default/lib/dev-menu.ts +119 -0
  151. package/dist/templates/default/lib/device.ts +40 -0
  152. package/dist/templates/default/lib/dynamic-font.ts +49 -0
  153. package/dist/templates/default/lib/env.ts +10 -0
  154. package/dist/templates/default/lib/haptics.ts +24 -0
  155. package/dist/templates/default/lib/notifications.ts +276 -0
  156. package/dist/templates/default/lib/preferences.ts +45 -0
  157. package/dist/templates/default/lib/schemas.ts +137 -0
  158. package/dist/templates/default/lib/storage.ts +47 -0
  159. package/dist/templates/default/lib/updates.ts +107 -0
  160. package/dist/templates/default/metro.config.js +14 -0
  161. package/dist/templates/default/package.json +129 -0
  162. package/dist/templates/default/patches/PR-368.patch +91 -0
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. package/dist/templates/default/plugins/README.md +9 -0
  165. package/dist/templates/default/plugins/with-auto-signing.js +45 -0
  166. package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
  167. package/dist/templates/default/scripts/README.md +36 -0
  168. package/dist/templates/default/scripts/_run.mjs +77 -0
  169. package/dist/templates/default/scripts/clean.ts +543 -0
  170. package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
  171. package/dist/templates/default/store.config.json +58 -0
  172. package/dist/templates/default/tsconfig.json +13 -0
  173. package/dist/templates/default/vitest.config.ts +21 -0
  174. package/package.json +69 -0
@@ -0,0 +1,46 @@
1
+ // Shared constants and pure helpers.
2
+ // Safe to import from Convex functions AND React routes.
3
+ // Do not add imports from `convex/server`, `./_generated/*`, or React here.
4
+
5
+ // ============================================================================
6
+ // Username validation (Better Auth `username` plugin)
7
+ // ============================================================================
8
+
9
+ export const USERNAME_MIN_LENGTH = 3;
10
+ export const USERNAME_MAX_LENGTH = 30;
11
+
12
+ // Alphanumerics, underscores, dots. Must match the server-side
13
+ // `usernameValidator` in convex/auth.ts to avoid client/server drift.
14
+ export const USERNAME_FORMAT_REGEX = /^[a-zA-Z0-9_.]+$/;
15
+
16
+ export const RESERVED_USERNAMES = [
17
+ "admin",
18
+ "administrator",
19
+ "root",
20
+ "system",
21
+ "moderator",
22
+ "mod",
23
+ "support",
24
+ "help",
25
+ "info",
26
+ "contact",
27
+ "api",
28
+ "www",
29
+ "mail",
30
+ "email",
31
+ "test",
32
+ "null",
33
+ "undefined",
34
+ ] as const;
35
+
36
+ export function isReservedUsername(username: string): boolean {
37
+ return (RESERVED_USERNAMES as ReadonlyArray<string>).includes(username.toLowerCase());
38
+ }
39
+
40
+ export function isValidUsernameFormat(username: string): boolean {
41
+ return (
42
+ username.length >= USERNAME_MIN_LENGTH &&
43
+ username.length <= USERNAME_MAX_LENGTH &&
44
+ USERNAME_FORMAT_REGEX.test(username)
45
+ );
46
+ }
@@ -0,0 +1,11 @@
1
+ import { defineApp } from "convex/server";
2
+ import betterAuth from "@convex-dev/better-auth/convex.config";
3
+ import resend from "@convex-dev/resend/convex.config";
4
+ import rateLimiter from "@convex-dev/rate-limiter/convex.config";
5
+
6
+ const app = defineApp();
7
+ app.use(betterAuth);
8
+ app.use(resend);
9
+ app.use(rateLimiter);
10
+
11
+ export default app;
@@ -0,0 +1,42 @@
1
+ import { cronJobs } from "convex/server";
2
+
3
+ import { components, internal } from "./_generated/api";
4
+ import { internalMutation } from "./_generated/server";
5
+
6
+ const crons = cronJobs();
7
+
8
+ // Drop push tokens that haven't refreshed in 30 days (stale device or app
9
+ // uninstalled). Bounded batches via `internal.pushTokens.cleanupStale`.
10
+ crons.daily(
11
+ "cleanup stale push tokens",
12
+ { hourUTC: 3, minuteUTC: 0 },
13
+ internal.pushTokens.cleanupStale,
14
+ );
15
+
16
+ // The Resend component retains finalized (delivered, cancelled, bounced)
17
+ // emails and it's our job to clear them. Run hourly to keep the emails table
18
+ // bounded. See @convex-dev/resend README → "Data retention".
19
+ crons.interval(
20
+ "Remove old emails from the resend component",
21
+ { hours: 1 },
22
+ internal.crons.cleanupResend,
23
+ );
24
+
25
+ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
26
+
27
+ export const cleanupResend = internalMutation({
28
+ args: {},
29
+ handler: async (ctx) => {
30
+ // Delivered/cancelled/bounced: 7 day retention.
31
+ await ctx.scheduler.runAfter(0, components.resend.lib.cleanupOldEmails, {
32
+ olderThan: ONE_WEEK_MS,
33
+ });
34
+ // Abandoned emails usually indicate a bug, so keep them around longer
35
+ // (4 weeks) for debugging before purging.
36
+ await ctx.scheduler.runAfter(0, components.resend.lib.cleanupAbandonedEmails, {
37
+ olderThan: 4 * ONE_WEEK_MS,
38
+ });
39
+ },
40
+ });
41
+
42
+ export default crons;
@@ -0,0 +1,109 @@
1
+ import type { GenericCtx } from "@convex-dev/better-auth";
2
+ import { requireRunMutationCtx } from "@convex-dev/better-auth/utils";
3
+ import { Resend, vOnEmailEventArgs } from "@convex-dev/resend";
4
+ import { v } from "convex/values";
5
+
6
+ import { components, internal } from "./_generated/api";
7
+ import type { DataModel } from "./_generated/dataModel";
8
+ import { internalMutation } from "./_generated/server";
9
+ import { env } from "./env";
10
+
11
+ // testMode defaults to true so dev can't accidentally email real users.
12
+ // Set RESEND_TEST_MODE=false in production to send to real addresses.
13
+ // Note: @convex-dev/resend's testMode only permits @resend.dev sandbox
14
+ // addresses and throws otherwise. To keep dev sign-up working with real-shaped
15
+ // emails, sendAuthOTP below short-circuits when testMode is on and logs the
16
+ // OTP to the Convex deployment console instead of calling sendEmail.
17
+ // Explicit `: Resend` annotation is required because `onEmailEvent` references
18
+ // a function in this same module, which would otherwise cause TS inference to
19
+ // loop on itself.
20
+ const testMode = process.env.RESEND_TEST_MODE !== "false";
21
+
22
+ export const resend: Resend = new Resend(components.resend, {
23
+ testMode,
24
+ onEmailEvent: internal.email.handleEmailEvent,
25
+ });
26
+
27
+ /**
28
+ * Receives delivery events from the Resend webhook (mounted in convex/http.ts).
29
+ * The event payload is also automatically persisted to the component's
30
+ * `deliveryEvents` table for inspection in the Convex dashboard.
31
+ *
32
+ * Logs the 4 actionable failure events (bounced, complained, suppressed,
33
+ * failed). Extend this handler to flag the user's email as unreachable if
34
+ * you want to stop sending auth OTPs to addresses that will never arrive.
35
+ */
36
+ const ACTIONABLE_FAILURE_EVENTS = new Set([
37
+ "email.bounced",
38
+ "email.complained",
39
+ "email.suppressed",
40
+ "email.failed",
41
+ ]);
42
+
43
+ export const handleEmailEvent = internalMutation({
44
+ args: vOnEmailEventArgs,
45
+ returns: v.null(),
46
+ handler: async (_ctx, args) => {
47
+ if (ACTIONABLE_FAILURE_EVENTS.has(args.event.type)) {
48
+ console.warn(`[resend] ${args.event.type} for email ${args.id}`, args.event.data);
49
+ }
50
+ return null;
51
+ },
52
+ });
53
+
54
+ type OTPType = "sign-in" | "email-verification" | "forget-password" | "change-email";
55
+
56
+ const OTP_COPY: Record<OTPType, { subject: string; heading: string; body: string }> = {
57
+ "sign-in": {
58
+ subject: "Your sign-in code",
59
+ heading: "Sign in",
60
+ body: "Use this code to sign in.",
61
+ },
62
+ "email-verification": {
63
+ subject: "Verify your email",
64
+ heading: "Verify your email",
65
+ body: "Enter this code to confirm your email address.",
66
+ },
67
+ "forget-password": {
68
+ subject: "Reset your password",
69
+ heading: "Reset your password",
70
+ body: "Use this code to reset your password. Ignore this email if you didn't request it.",
71
+ },
72
+ "change-email": {
73
+ subject: "Confirm your new email",
74
+ heading: "Confirm your new email",
75
+ body: "Enter this code to confirm the email change.",
76
+ },
77
+ };
78
+
79
+ /**
80
+ * Send an auth OTP email via Resend. Used by Better Auth's emailOTP plugin
81
+ * inside the `sendVerificationOTP` callback in convex/auth.ts.
82
+ *
83
+ * In test mode (dev default), logs the OTP to the Convex deployment console
84
+ * instead of calling Resend. Read it from `bunx convex dev` output or the
85
+ * deployment logs in the Convex dashboard. Production sets RESEND_TEST_MODE
86
+ * to "false" and sends real emails.
87
+ */
88
+ export async function sendAuthOTP(
89
+ ctx: GenericCtx<DataModel>,
90
+ { email, otp, type }: { email: string; otp: string; type: OTPType },
91
+ ) {
92
+ if (testMode) {
93
+ console.log(`[otp] ${type} for ${email}: ${otp}`);
94
+ return;
95
+ }
96
+
97
+ const { subject, heading, body } = OTP_COPY[type];
98
+ await resend.sendEmail(requireRunMutationCtx(ctx), {
99
+ from: `${env.appName} <${env.email.from}>`,
100
+ to: email,
101
+ subject: `${env.appName}: ${subject} (${otp})`,
102
+ html: renderHtml(heading, body, otp),
103
+ text: `${heading}\n\n${body}\n\nCode: ${otp}\n\nThis code expires in 5 minutes.`,
104
+ });
105
+ }
106
+
107
+ function renderHtml(heading: string, body: string, otp: string): string {
108
+ return `<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:32px;color:#111"><h1 style="font-size:20px;margin:0 0 16px">${heading}</h1><p style="margin:0 0 24px">${body}</p><div style="font-size:28px;letter-spacing:6px;font-weight:600;padding:16px;background:#f5f5f5;border-radius:8px;text-align:center">${otp}</div><p style="margin:24px 0 0;color:#666;font-size:13px">This code expires in 5 minutes.</p></body></html>`;
109
+ }
@@ -0,0 +1,31 @@
1
+ const optional = (key: string, fallback: string): string => process.env[key] ?? fallback;
2
+
3
+ const required = (key: string): string => {
4
+ const value = process.env[key];
5
+ if (!value) throw new Error(`Missing required env var: ${key}`);
6
+ return value;
7
+ };
8
+
9
+ const bool = (key: string, fallback: boolean): boolean => {
10
+ const v = process.env[key];
11
+ if (v === undefined) return fallback;
12
+ return v === "true" || v === "1";
13
+ };
14
+
15
+ export const env = {
16
+ get convexSiteUrl() {
17
+ return required("CONVEX_SITE_URL");
18
+ },
19
+ siteUrl: optional("SITE_URL", "vexpo://"),
20
+ appName: optional("APP_NAME", "Vexpo"),
21
+ email: {
22
+ get from() {
23
+ return required("EMAIL_FROM");
24
+ },
25
+ },
26
+ // Email verification policy. Default `false` (minimal-tier setup, no Resend
27
+ // configured). sign-up creates verified-immediately accounts so the user
28
+ // can sign in without ever seeing an OTP. `bunx vexpo full` flips
29
+ // this to `true` on the Convex env when it provisions Resend.
30
+ requireEmailVerification: bool("REQUIRE_EMAIL_VERIFICATION", false),
31
+ } as const;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Structured Errors
3
+ *
4
+ * Error factories emit ConvexError with a stable code so clients can
5
+ * branch on `error.data.code` without parsing messages.
6
+ */
7
+
8
+ import { ConvexError } from "convex/values";
9
+
10
+ export const ErrorCode = {
11
+ UNAUTHENTICATED: "AUTH_1001",
12
+ VALIDATION_ERROR: "VAL_3001",
13
+ } as const;
14
+
15
+ type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
16
+
17
+ type AppErrorData = {
18
+ code: ErrorCodeValue;
19
+ message: string;
20
+ field?: string;
21
+ };
22
+
23
+ function createError(code: ErrorCodeValue, message: string, options?: { field?: string }) {
24
+ return new ConvexError({ code, message, ...options } as AppErrorData);
25
+ }
26
+
27
+ export function authenticationRequired(message = "Authentication required") {
28
+ return createError(ErrorCode.UNAUTHENTICATED, message);
29
+ }
30
+
31
+ export function validationError(message: string, field?: string) {
32
+ return createError(ErrorCode.VALIDATION_ERROR, message, { field });
33
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Custom Function Wrappers
3
+ *
4
+ * Authenticated query/mutation wrappers that inject the current user into
5
+ * the context. Uses the centralized helpers from auth.ts to avoid duplication.
6
+ */
7
+
8
+ import { customCtx, customMutation, customQuery } from "convex-helpers/server/customFunctions";
9
+
10
+ import { mutation, query } from "./_generated/server";
11
+ import { requireAuthenticatedUser, safeGetAuthenticatedUser } from "./auth";
12
+ import type { AuthUser } from "./auth";
13
+
14
+ // Re-export AuthUser type for convenience
15
+ export type { AuthUser };
16
+
17
+ // ============================================================================
18
+ // Query Wrappers
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Authenticated query - throws ConvexError if user is not logged in.
23
+ */
24
+ export const authQuery = customQuery(
25
+ query,
26
+ customCtx(async (ctx) => ({
27
+ user: await requireAuthenticatedUser(ctx),
28
+ })),
29
+ );
30
+
31
+ /**
32
+ * Optional auth query - user may be undefined.
33
+ * Use for endpoints that work for both authenticated and anonymous users.
34
+ */
35
+ export const optionalAuthQuery = customQuery(
36
+ query,
37
+ customCtx(async (ctx) => ({
38
+ user: await safeGetAuthenticatedUser(ctx),
39
+ })),
40
+ );
41
+
42
+ // ============================================================================
43
+ // Mutation Wrappers
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Authenticated mutation - throws ConvexError if user is not logged in.
48
+ */
49
+ export const authMutation = customMutation(
50
+ mutation,
51
+ customCtx(async (ctx) => ({
52
+ user: await requireAuthenticatedUser(ctx),
53
+ })),
54
+ );
@@ -0,0 +1,176 @@
1
+ import { httpRouter } from "convex/server";
2
+
3
+ import { httpAction } from "./_generated/server";
4
+ import { authComponent, createAuth } from "./auth";
5
+ import { resend } from "./email";
6
+ import { log, newRequestId } from "./log";
7
+ import { withWebhook } from "./webhook";
8
+
9
+ const http = httpRouter();
10
+
11
+ // Register Better Auth routes lazily so Better Auth is not initialized at
12
+ // module load. Reduces http.ts memory footprint during `convex deploy`.
13
+ authComponent.registerRoutesLazy(http, createAuth);
14
+
15
+ // Resend delivery events webhook. `@convex-dev/resend` ships its own Svix
16
+ // signature verification + idempotency, so we just forward the raw request.
17
+ // Configure the Resend dashboard webhook at
18
+ // https://<your-deployment>.convex.site/resend-webhook and set
19
+ // RESEND_WEBHOOK_SECRET on the Convex deployment.
20
+ // `@convex-dev/resend`'s `handleResendEventWebhook` throws if
21
+ // `RESEND_WEBHOOK_SECRET` is unset, and Convex's default error handling
22
+ // serializes the stack trace into the 503 body (including absolute-ish
23
+ // module paths). Wrap to short-circuit with a clean 503 before the library
24
+ // runs, and wrap the library call itself so its other internal errors don't
25
+ // leak source paths either.
26
+ http.route({
27
+ path: "/resend-webhook",
28
+ method: "POST",
29
+ handler: httpAction(async (ctx, req) => {
30
+ if (!process.env.RESEND_WEBHOOK_SECRET) {
31
+ return new Response(JSON.stringify({ error: "RESEND_WEBHOOK_SECRET not configured" }), {
32
+ status: 503,
33
+ headers: { "Content-Type": "application/json" },
34
+ });
35
+ }
36
+ try {
37
+ return await resend.handleResendEventWebhook(ctx, req);
38
+ } catch (err) {
39
+ log.warn({
40
+ event: "resend.handler_error",
41
+ message: err instanceof Error ? err.message : String(err),
42
+ });
43
+ return new Response(JSON.stringify({ error: "webhook handler error" }), {
44
+ status: 500,
45
+ headers: { "Content-Type": "application/json" },
46
+ });
47
+ }
48
+ }),
49
+ });
50
+
51
+ // EAS Build / Submit webhook receiver.
52
+ //
53
+ // Wire it up once with:
54
+ // bunx eas webhook:create --event BUILD --url https://<your-deployment>.convex.site/eas-webhook --secret <strong-secret>
55
+ // bunx eas webhook:create --event SUBMIT --url https://<your-deployment>.convex.site/eas-webhook --secret <strong-secret>
56
+ // bunx convex env set EAS_WEBHOOK_SECRET <strong-secret>
57
+ //
58
+ // Per https://docs.expo.dev/eas/webhooks/, EAS signs every POST with
59
+ // HMAC-SHA1 in `expo-signature: sha1=<hex>`. The factory below handles
60
+ // the signature + body cap + structured access log; the handler here just
61
+ // dispatches on payload shape.
62
+ type EasWebhookPayload = {
63
+ id?: string;
64
+ status?: string;
65
+ platform?: string;
66
+ buildDetailsPageUrl?: string;
67
+ appId?: string;
68
+ metadata?: { appName?: string };
69
+ };
70
+
71
+ http.route({
72
+ path: "/eas-webhook",
73
+ method: "POST",
74
+ handler: httpAction(
75
+ withWebhook<EasWebhookPayload>(
76
+ {
77
+ source: "eas-webhook",
78
+ signatureHeader: "expo-signature",
79
+ signaturePrefix: "sha1=",
80
+ secretEnv: "EAS_WEBHOOK_SECRET",
81
+ algorithm: "sha1",
82
+ },
83
+ (_ctx, payload, { requestId }) => {
84
+ log.info({
85
+ event: "eas.received",
86
+ requestId,
87
+ easId: payload.id,
88
+ platform: payload.platform,
89
+ status: payload.status,
90
+ appName: payload.metadata?.appName,
91
+ detailsUrl: payload.buildDetailsPageUrl,
92
+ });
93
+ // Extend: dispatch on `payload.status === "errored"` to a Slack
94
+ // notifier, persist the build/submit row to a Convex table, etc.
95
+ return new Response(JSON.stringify({ ok: true, requestId }), {
96
+ status: 200,
97
+ headers: { "Content-Type": "application/json", "X-Request-Id": requestId },
98
+ });
99
+ },
100
+ ),
101
+ ),
102
+ });
103
+
104
+ // Apple universal link association file.
105
+ //
106
+ // Served on every cold-launch of every installed copy of the app. high
107
+ // fanout, fully static body, cheapest possible payload wins. We:
108
+ // 1. Construct the body deterministically from env so the ETag is stable.
109
+ // 2. Cache for 1h with `must-revalidate` so a bundle-id change still
110
+ // converges within that window. Apple itself caches AASA aggressively
111
+ // on-device; the public CDN cache is the only tier that matters for
112
+ // first-install latency.
113
+ // 3. Honor conditional GETs (`If-None-Match`) with 304s so warm callers
114
+ // pay only the round-trip.
115
+ // `appID` is `<APPLE_TEAM_ID>.<BUNDLE_ID>`. Both env vars are pushed to
116
+ // the Convex deployment by `setup:convex` (or `setup:apple`).
117
+ http.route({
118
+ path: "/.well-known/apple-app-site-association",
119
+ method: "GET",
120
+ handler: httpAction(async (_ctx, req) => {
121
+ const requestId = newRequestId();
122
+ const teamId = process.env.APPLE_TEAM_ID;
123
+ const bundleId = process.env.APP_BUNDLE_ID;
124
+ if (!teamId || !bundleId) {
125
+ log.error({
126
+ event: "aasa.misconfigured",
127
+ requestId,
128
+ hasTeamId: !!teamId,
129
+ hasBundleId: !!bundleId,
130
+ });
131
+ return jsonError(503, "APPLE_TEAM_ID and APP_BUNDLE_ID must be set", requestId);
132
+ }
133
+ const body = JSON.stringify({
134
+ applinks: {
135
+ details: [{ appID: `${teamId}.${bundleId}`, paths: ["*"] }],
136
+ },
137
+ });
138
+ const etag = `"${await sha256Hex(body)}"`;
139
+ const ifNoneMatch = req.headers.get("if-none-match");
140
+ if (ifNoneMatch === etag) {
141
+ log.info({ event: "aasa.not_modified", requestId });
142
+ return new Response(null, {
143
+ status: 304,
144
+ headers: {
145
+ ETag: etag,
146
+ "Cache-Control": "public, max-age=3600, must-revalidate",
147
+ "X-Request-Id": requestId,
148
+ },
149
+ });
150
+ }
151
+ log.info({ event: "aasa.served", requestId, bytes: body.length });
152
+ return new Response(body, {
153
+ status: 200,
154
+ headers: {
155
+ "Content-Type": "application/json",
156
+ "Cache-Control": "public, max-age=3600, must-revalidate",
157
+ ETag: etag,
158
+ "X-Request-Id": requestId,
159
+ },
160
+ });
161
+ }),
162
+ });
163
+
164
+ async function sha256Hex(s: string): Promise<string> {
165
+ const sig = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
166
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
167
+ }
168
+
169
+ function jsonError(status: number, message: string, requestId: string): Response {
170
+ return new Response(JSON.stringify({ error: message, requestId }), {
171
+ status,
172
+ headers: { "Content-Type": "application/json", "X-Request-Id": requestId },
173
+ });
174
+ }
175
+
176
+ export default http;
@@ -0,0 +1,81 @@
1
+ // Structured one-line JSON logger for Convex HTTP handlers.
2
+ //
3
+ // Convex's web dashboard renders `console.log` output line-by-line. Plain
4
+ // strings work for narrative logs but lose context once volume grows. The
5
+ // helpers below emit single-line JSON with a stable shape so dashboards
6
+ // and log aggregators can filter on fields directly:
7
+ //
8
+ // { "ts": "...", "level": "info", "event": "webhook.ok", "requestId": "...",
9
+ // "durationMs": 12, "platform": "ios", "status": "finished" }
10
+ //
11
+ // Keep the field set small and predictable:
12
+ // - ts ISO timestamp (UTC)
13
+ // - level info | warn | error
14
+ // - event dot-namespaced verb ("webhook.ok", "aasa.served")
15
+ // - requestId set per HTTP request via `newRequestId()`
16
+ // - durationMs numeric (preferred over Date arithmetic in queries)
17
+ // - err { message, name, stack? } when level === "error"
18
+ //
19
+ // Anything else is merged in as-is. Field order is alphabetical except `ts`
20
+ // and `level` come first for readability when scrolling raw log output.
21
+
22
+ export type LogLevel = "info" | "warn" | "error";
23
+
24
+ export type LogFields = Record<string, unknown> & {
25
+ event: string;
26
+ requestId?: string;
27
+ };
28
+
29
+ function emit(level: LogLevel, fields: LogFields): void {
30
+ const { event, ...rest } = fields;
31
+ const payload = {
32
+ ts: new Date().toISOString(),
33
+ level,
34
+ event,
35
+ ...rest,
36
+ };
37
+ const line = JSON.stringify(payload, replacer);
38
+ if (level === "error") {
39
+ console.error(line);
40
+ } else if (level === "warn") {
41
+ console.warn(line);
42
+ } else {
43
+ console.log(line);
44
+ }
45
+ }
46
+
47
+ // JSON.stringify replacer: errors don't serialize by default. Pull
48
+ // `message`, `name`, and (only in development) `stack`.
49
+ function replacer(_key: string, value: unknown): unknown {
50
+ if (value instanceof Error) {
51
+ return {
52
+ message: value.message,
53
+ name: value.name,
54
+ ...(process.env.NODE_ENV !== "production" && value.stack ? { stack: value.stack } : {}),
55
+ };
56
+ }
57
+ return value;
58
+ }
59
+
60
+ export const log = {
61
+ info(fields: LogFields): void {
62
+ emit("info", fields);
63
+ },
64
+ warn(fields: LogFields): void {
65
+ emit("warn", fields);
66
+ },
67
+ error(fields: LogFields & { err?: unknown }): void {
68
+ emit("error", fields);
69
+ },
70
+ };
71
+
72
+ // Cryptographically-random short request ID. Web crypto is available in
73
+ // Convex's runtime. 9 bytes → 12-char base64url, plenty for correlation
74
+ // without bloating every log line.
75
+ export function newRequestId(): string {
76
+ const bytes = new Uint8Array(9);
77
+ crypto.getRandomValues(bytes);
78
+ let s = "";
79
+ for (const b of bytes) s += String.fromCharCode(b);
80
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
81
+ }