lytx 0.3.2 → 0.3.6

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/.env.example CHANGED
@@ -24,6 +24,7 @@ RESEND_API_KEY=
24
24
 
25
25
  # AI features (optional)
26
26
  AI_API_KEY=
27
+ AI_ACCOUNT_ID=
27
28
  AI_PROVIDER=openai
28
29
  AI_BASE_URL=
29
30
  AI_MODEL=
package/README.md CHANGED
@@ -72,7 +72,8 @@ export default app satisfies ExportedHandler<Env>;
72
72
  - `trackingRoutePrefix` (prefix all tracking routes, e.g. `/collect`)
73
73
  - `tagRoutes.scriptPath` + `tagRoutes.eventPath` (custom v2 route paths)
74
74
  - `auth.emailPasswordEnabled`, `auth.requireEmailVerification`, `auth.socialProviders.google`, `auth.socialProviders.github`
75
- - `ai.provider`, `ai.baseURL`, `ai.model`, `ai.apiKey` (runtime AI vendor/model overrides)
75
+ - `auth.signupMode` (`"open" | "bootstrap_then_invite" | "invite_only"`)
76
+ - `ai.provider`, `ai.model`, `ai.baseURL`, `ai.apiKey`, `ai.accountId` (runtime AI vendor/model overrides; blank values are ignored; provider/model include preset autocomplete values)
76
77
  - `features.reportBuilderEnabled` + `features.askAiEnabled`
77
78
  - `names.*` (typed resource binding names for D1/KV/Queue/DO)
78
79
  - `domains.app` + `domains.tracking` (typed host/domain values)
@@ -369,6 +370,7 @@ RESEND_API_KEY=...
369
370
 
370
371
  # AI features (optional)
371
372
  AI_API_KEY=...
373
+ AI_ACCOUNT_ID=...
372
374
  AI_PROVIDER=openai
373
375
  AI_BASE_URL=...
374
376
  AI_MODEL=...
package/alchemy.run.ts CHANGED
@@ -139,6 +139,7 @@ export const worker = await Redwood(resourceNames.workerName, {
139
139
  RESEND_API_KEY: alchemy.secret(process.env.RESEND_API_KEY),
140
140
  ENCRYPTION_KEY: alchemy.secret(process.env.ENCRYPTION_KEY),
141
141
  AI_API_KEY: alchemy.secret(process.env.AI_API_KEY),
142
+ AI_ACCOUNT_ID: process.env.AI_ACCOUNT_ID ?? "",
142
143
  AI_PROVIDER: process.env.AI_PROVIDER ?? "openai",
143
144
  SEED_DATA_SECRET: alchemy.secret(process.env.SEED_DATA_SECRET),
144
145
  BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
package/cli/setup.ts CHANGED
@@ -350,6 +350,7 @@ EMAIL_FROM=noreply@yourdomain.com
350
350
 
351
351
  # AI features (optional)
352
352
  AI_API_KEY=
353
+ AI_ACCOUNT_ID=
353
354
  AI_PROVIDER=openai
354
355
  AI_BASE_URL=
355
356
  AI_MODEL=
package/index.d.ts CHANGED
@@ -49,9 +49,14 @@ export { SiteDurableObject } from "./db/durable/siteDurableObject";
49
49
  export type {
50
50
  CreateLytxAppConfig,
51
51
  LytxDbAdapter,
52
+ LytxSignupMode,
52
53
  LytxEventStore,
53
54
  LytxDbConfig,
54
55
  LytxAiConfig,
56
+ LytxAiProviderPreset,
57
+ LytxAiProvider,
58
+ LytxAiModelPreset,
59
+ LytxAiModel,
55
60
  } from "./src/config/createLytxAppConfig";
56
61
  export function createLytxApp(
57
62
  config?: CreateLytxAppConfig,
package/index.ts CHANGED
@@ -71,9 +71,14 @@ export { createLytxApp } from "./src/worker";
71
71
  export type {
72
72
  CreateLytxAppConfig,
73
73
  LytxDbAdapter,
74
+ LytxSignupMode,
74
75
  LytxEventStore,
75
76
  LytxDbConfig,
76
77
  LytxAiConfig,
78
+ LytxAiProviderPreset,
79
+ LytxAiProvider,
80
+ LytxAiModelPreset,
81
+ LytxAiModel,
77
82
  } from "./src/config/createLytxAppConfig";
78
83
  export { DEFAULT_LYTX_RESOURCE_NAMES, resolveLytxResourceNames } from "./src/config/resourceNames";
79
84
  export type { LytxResourceNames, LytxResourceNamingOptions, LytxResourceStagePosition } from "./src/config/resourceNames";
package/lib/auth.ts CHANGED
@@ -3,10 +3,12 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
3
  import { customSession } from "better-auth/plugins";
4
4
  import { env } from "cloudflare:workers";
5
5
  import { IS_DEV } from "rwsdk/constants";
6
+ import { and, eq } from "drizzle-orm";
6
7
 
7
8
  import { d1_client } from "@db/d1/client";
8
9
  import type { DBAdapter } from "@db/d1/schema";
9
10
  import * as schema from "@db/d1/schema";
11
+ import { invited_user, user as user_table } from "@db/d1/schema";
10
12
  import { createNewAccount, getSitesForUser } from "@db/d1/sites";
11
13
  import type { SitesContext } from "@db/d1/sites";
12
14
  import { sendVerificationEmail } from "@lib/sendMail";
@@ -16,10 +18,21 @@ type SocialProviderToggles = {
16
18
  github?: boolean;
17
19
  };
18
20
 
21
+ export const SIGNUP_MODES = ["open", "bootstrap_then_invite", "invite_only"] as const;
22
+ export type SignupMode = (typeof SIGNUP_MODES)[number];
23
+
24
+ export type SignupAccessState = {
25
+ mode: SignupMode;
26
+ hasUsers: boolean;
27
+ publicSignupOpen: boolean;
28
+ bootstrapSignupOpen: boolean;
29
+ };
30
+
19
31
  export type AuthRuntimeConfig = {
20
32
  emailPasswordEnabled?: boolean;
21
33
  requireEmailVerification?: boolean;
22
34
  socialProviders?: SocialProviderToggles;
35
+ signupMode?: SignupMode;
23
36
  };
24
37
 
25
38
  type TeamSummary = {
@@ -71,6 +84,7 @@ export type AuthProviderAvailability = {
71
84
  let auth_runtime_config: AuthRuntimeConfig = {
72
85
  emailPasswordEnabled: true,
73
86
  requireEmailVerification: true,
87
+ signupMode: "open",
74
88
  socialProviders: {},
75
89
  };
76
90
 
@@ -102,6 +116,101 @@ export function getAuthProviderAvailability(): AuthProviderAvailability {
102
116
  };
103
117
  }
104
118
 
119
+ function getSignupMode(): SignupMode {
120
+ return auth_runtime_config.signupMode ?? "open";
121
+ }
122
+
123
+ function isMissingTableError(error: unknown): boolean {
124
+ if (!error || typeof error !== "object") return false;
125
+ const message = (error as { message?: unknown }).message;
126
+ return typeof message === "string" && message.includes("no such table");
127
+ }
128
+
129
+ async function hasAnyUserAccount(): Promise<boolean> {
130
+ try {
131
+ const users = await d1_client.select({ id: user_table.id }).from(user_table).limit(1);
132
+ return typeof users[0]?.id === "string";
133
+ } catch (error) {
134
+ if (isMissingTableError(error)) {
135
+ if (IS_DEV) {
136
+ console.warn("Signup bootstrap check: user table missing, treating as zero users");
137
+ }
138
+ return false;
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ async function hasPendingInviteForEmail(email: string): Promise<boolean> {
145
+ const normalizedEmail = email.trim().toLowerCase();
146
+ if (!normalizedEmail) return false;
147
+
148
+ try {
149
+ const invites = await d1_client
150
+ .select({ id: invited_user.id })
151
+ .from(invited_user)
152
+ .where(and(eq(invited_user.email, normalizedEmail), eq(invited_user.accepted, false)))
153
+ .limit(1);
154
+
155
+ return typeof invites[0]?.id === "number";
156
+ } catch (error) {
157
+ if (isMissingTableError(error)) {
158
+ if (IS_DEV) {
159
+ console.warn("Signup invite check: invited_user table missing, treating as no invite");
160
+ }
161
+ return false;
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ export async function getSignupAccessState(): Promise<SignupAccessState> {
168
+ const mode = getSignupMode();
169
+
170
+ if (mode === "open") {
171
+ return {
172
+ mode,
173
+ hasUsers: true,
174
+ publicSignupOpen: true,
175
+ bootstrapSignupOpen: false,
176
+ };
177
+ }
178
+
179
+ if (mode === "invite_only") {
180
+ return {
181
+ mode,
182
+ hasUsers: true,
183
+ publicSignupOpen: false,
184
+ bootstrapSignupOpen: false,
185
+ };
186
+ }
187
+
188
+ const hasUsers = await hasAnyUserAccount();
189
+ return {
190
+ mode,
191
+ hasUsers,
192
+ publicSignupOpen: !hasUsers,
193
+ bootstrapSignupOpen: !hasUsers,
194
+ };
195
+ }
196
+
197
+ export async function isPublicSignupOpen(): Promise<boolean> {
198
+ const state = await getSignupAccessState();
199
+ return state.publicSignupOpen;
200
+ }
201
+
202
+ export async function canRegisterEmail(email: string): Promise<boolean> {
203
+ const signupMode = getSignupMode();
204
+ if (signupMode === "open") return true;
205
+
206
+ const invited = await hasPendingInviteForEmail(email);
207
+ if (invited) return true;
208
+
209
+ if (signupMode === "invite_only") return false;
210
+ const state = await getSignupAccessState();
211
+ return state.bootstrapSignupOpen;
212
+ }
213
+
105
214
  function getTrustedOrigins() {
106
215
  if (!IS_DEV) return [env.BETTER_AUTH_URL];
107
216
  return [
@@ -245,6 +354,13 @@ function createAuthInstance() {
245
354
  databaseHooks: {
246
355
  user: {
247
356
  create: {
357
+ before: async (userRecord) => {
358
+ const email = typeof userRecord.email === "string" ? userRecord.email : "";
359
+ const allowed = await canRegisterEmail(email);
360
+ if (!allowed) {
361
+ throw new Error("Public sign up is disabled. Ask an admin for an invitation.");
362
+ }
363
+ },
248
364
  after: async (user) => {
249
365
  await createNewAccount(user);
250
366
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lytx",
3
- "version": "0.3.2",
3
+ "version": "0.3.6",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -103,6 +103,8 @@
103
103
  "wrangler": "^4.59.3"
104
104
  },
105
105
  "dependencies": {
106
+ "@ai-sdk/anthropic": "^3.0.46",
107
+ "@ai-sdk/google": "^3.0.30",
106
108
  "@ai-sdk/openai-compatible": "^2.0.26",
107
109
  "@ai-sdk/react": "^3.0.70",
108
110
  "@fontsource/montserrat": "^5.2.8",
@@ -137,6 +139,7 @@
137
139
  "react-simple-maps": "^3.0.0",
138
140
  "rwsdk": "1.0.0-beta.49",
139
141
  "vite": "^7.3.1",
142
+ "workers-ai-provider": "^3.1.2",
140
143
  "zod": "^4.3.6"
141
144
  }
142
145
  }
package/src/api/ai_api.ts CHANGED
@@ -4,6 +4,9 @@ import type { RequestInfo } from "rwsdk/worker";
4
4
  import { convertToModelMessages, generateText, streamText, tool } from "ai";
5
5
  import { IS_DEV } from "rwsdk/constants";
6
6
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
7
+ import { createAnthropic } from "@ai-sdk/anthropic";
8
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
9
+ import { createWorkersAI } from "workers-ai-provider";
7
10
  import { z } from "zod";
8
11
 
9
12
  import type { AppContext } from "@/types/app-context";
@@ -23,9 +26,10 @@ import { parseDateParam, parseSiteIdParam } from "@/utilities/dashboardParams";
23
26
 
24
27
  type AiConfig = {
25
28
  provider: string;
26
- baseURL: string;
29
+ baseURL?: string;
27
30
  model: string;
28
31
  apiKey: string;
32
+ accountId?: string;
29
33
  };
30
34
 
31
35
  type AiRuntimeOverrides = {
@@ -33,6 +37,7 @@ type AiRuntimeOverrides = {
33
37
  baseURL?: string;
34
38
  model?: string;
35
39
  apiKey?: string;
40
+ accountId?: string;
36
41
  };
37
42
 
38
43
  type NivoChartType = "bar" | "line" | "pie";
@@ -42,6 +47,21 @@ const DEFAULT_AI_PROVIDER = "openai";
42
47
  const DEFAULT_AI_BASE_URL = "https://api.openai.com/v1";
43
48
  const MAX_METRIC_LIMIT = 100;
44
49
 
50
+ const OPENAI_COMPATIBLE_PROVIDERS = new Set([
51
+ "openai",
52
+ "openrouter",
53
+ "groq",
54
+ "deepseek",
55
+ "xai",
56
+ "ollama",
57
+ "custom",
58
+ ]);
59
+
60
+ const AI_PROVIDER_ALIASES: Record<string, string> = {
61
+ claude: "anthropic",
62
+ gemini: "google",
63
+ };
64
+
45
65
  const AI_PROVIDER_BASE_URLS: Record<string, string> = {
46
66
  openai: "https://api.openai.com/v1",
47
67
  openrouter: "https://openrouter.ai/api/v1",
@@ -49,6 +69,8 @@ const AI_PROVIDER_BASE_URLS: Record<string, string> = {
49
69
  deepseek: "https://api.deepseek.com/v1",
50
70
  xai: "https://api.x.ai/v1",
51
71
  ollama: "http://localhost:11434/v1",
72
+ anthropic: "https://api.anthropic.com/v1",
73
+ google: "https://generativelanguage.googleapis.com/v1beta",
52
74
  };
53
75
 
54
76
  const AI_PROVIDER_DEFAULT_MODELS: Record<string, string> = {
@@ -58,6 +80,9 @@ const AI_PROVIDER_DEFAULT_MODELS: Record<string, string> = {
58
80
  deepseek: "deepseek-chat",
59
81
  xai: "grok-2-latest",
60
82
  ollama: "llama3.2",
83
+ anthropic: "claude-3-5-sonnet-latest",
84
+ google: "gemini-2.5-flash",
85
+ cloudflare: "@cf/meta/llama-3.1-8b-instruct",
61
86
  };
62
87
 
63
88
  let aiRuntimeOverrides: AiRuntimeOverrides = {};
@@ -68,8 +93,16 @@ function normalizeOptionalString(value: unknown): string | null {
68
93
  return trimmed.length > 0 ? trimmed : null;
69
94
  }
70
95
 
71
- function resolveAiBaseUrl(provider: string, explicitBaseUrl: string | null): string {
96
+ function normalizeProviderName(value: string): string {
97
+ const normalized = value.trim().toLowerCase();
98
+ return AI_PROVIDER_ALIASES[normalized] ?? normalized;
99
+ }
100
+
101
+ function resolveAiBaseUrl(provider: string, explicitBaseUrl: string | null): string | undefined {
72
102
  if (explicitBaseUrl) return explicitBaseUrl;
103
+ if (!OPENAI_COMPATIBLE_PROVIDERS.has(provider) && provider !== "anthropic" && provider !== "google") {
104
+ return undefined;
105
+ }
73
106
  return AI_PROVIDER_BASE_URLS[provider] ?? DEFAULT_AI_BASE_URL;
74
107
  }
75
108
 
@@ -78,12 +111,66 @@ function resolveAiModel(provider: string, explicitModel: string | null): string
78
111
  return AI_PROVIDER_DEFAULT_MODELS[provider] ?? DEFAULT_AI_MODEL;
79
112
  }
80
113
 
114
+ function getWorkersAiBinding(envValues: Record<string, unknown>): Ai | null {
115
+ const binding = envValues.AI;
116
+ if (!binding || typeof binding !== "object") return null;
117
+ return binding as Ai;
118
+ }
119
+
120
+ function createAiLanguageModel(aiConfig: AiConfig) {
121
+ if (aiConfig.provider === "anthropic") {
122
+ const anthropic = createAnthropic({
123
+ apiKey: aiConfig.apiKey,
124
+ ...(aiConfig.baseURL ? { baseURL: aiConfig.baseURL } : {}),
125
+ });
126
+ return anthropic(aiConfig.model);
127
+ }
128
+
129
+ if (aiConfig.provider === "google") {
130
+ const google = createGoogleGenerativeAI({
131
+ apiKey: aiConfig.apiKey,
132
+ ...(aiConfig.baseURL ? { baseURL: aiConfig.baseURL } : {}),
133
+ });
134
+ return google(aiConfig.model);
135
+ }
136
+
137
+ if (aiConfig.provider === "cloudflare") {
138
+ const envValues = env as unknown as Record<string, unknown>;
139
+ const binding = getWorkersAiBinding(envValues);
140
+ if (binding) {
141
+ const workersAi = createWorkersAI({ binding });
142
+ return workersAi(aiConfig.model);
143
+ }
144
+
145
+ if (aiConfig.accountId && aiConfig.apiKey) {
146
+ const workersAi = createWorkersAI({
147
+ accountId: aiConfig.accountId,
148
+ apiKey: aiConfig.apiKey,
149
+ });
150
+ return workersAi(aiConfig.model);
151
+ }
152
+
153
+ throw new Error("Cloudflare provider requires env.AI binding or AI_ACCOUNT_ID + AI_API_KEY");
154
+ }
155
+
156
+ const modelProvider = createOpenAICompatible({
157
+ baseURL: aiConfig.baseURL ?? DEFAULT_AI_BASE_URL,
158
+ name: "team-model",
159
+ apiKey: aiConfig.apiKey,
160
+ includeUsage: true,
161
+ });
162
+
163
+ return modelProvider.chatModel(aiConfig.model);
164
+ }
165
+
81
166
  export function setAiRuntimeOverrides(overrides: AiRuntimeOverrides = {}): void {
167
+ const provider = normalizeOptionalString(overrides.provider);
82
168
  aiRuntimeOverrides = {
83
- provider: normalizeOptionalString(overrides.provider) ?? undefined,
169
+ provider: provider ? normalizeProviderName(provider) : undefined,
84
170
  baseURL: normalizeOptionalString(overrides.baseURL) ?? undefined,
85
171
  model: normalizeOptionalString(overrides.model) ?? undefined,
86
172
  apiKey: normalizeOptionalString(overrides.apiKey) ?? undefined,
173
+ accountId: normalizeOptionalString(overrides.accountId) ?? undefined,
87
174
  };
88
175
  }
89
176
 
@@ -107,7 +194,7 @@ function getAiConfigFromEnv(): AiConfig | null {
107
194
  normalizeOptionalString(aiRuntimeOverrides.provider)
108
195
  ?? normalizeOptionalString(envValues.AI_PROVIDER)
109
196
  ?? DEFAULT_AI_PROVIDER;
110
- const provider = providerRaw.toLowerCase();
197
+ const provider = normalizeProviderName(providerRaw);
111
198
  const baseURL = resolveAiBaseUrl(
112
199
  provider,
113
200
  normalizeOptionalString(aiRuntimeOverrides.baseURL) ?? normalizeOptionalString(envValues.AI_BASE_URL),
@@ -120,10 +207,22 @@ function getAiConfigFromEnv(): AiConfig | null {
120
207
  normalizeOptionalString(aiRuntimeOverrides.apiKey)
121
208
  ?? normalizeOptionalString(envValues.AI_API_KEY)
122
209
  ?? "";
210
+ const accountId =
211
+ normalizeOptionalString(aiRuntimeOverrides.accountId)
212
+ ?? normalizeOptionalString(envValues.AI_ACCOUNT_ID)
213
+ ?? undefined;
214
+
215
+ if (provider === "cloudflare") {
216
+ const hasBinding = Boolean(getWorkersAiBinding(envValues));
217
+ if (!hasBinding && (!accountId || !apiKey)) {
218
+ return null;
219
+ }
220
+ return { provider, baseURL, model, apiKey, accountId };
221
+ }
123
222
 
124
223
  if (!apiKey) return null;
125
224
 
126
- return { provider, baseURL, model, apiKey };
225
+ return { provider, baseURL, model, apiKey, accountId };
127
226
  }
128
227
 
129
228
  function clampLimit(value: unknown, fallback = 10): number {
@@ -657,19 +756,14 @@ export const aiTagSuggestRoute = route(
657
756
  );
658
757
  }
659
758
 
660
- const modelProvider = createOpenAICompatible({
661
- baseURL: aiConfig.baseURL,
662
- name: "team-model",
663
- apiKey: aiConfig.apiKey,
664
- includeUsage: true,
665
- });
759
+ const aiModel = createAiLanguageModel(aiConfig);
666
760
 
667
761
  const prompt = `Analyze this page HTML and suggest DOM events to track.\n\nContext:\n- Selected site domain: ${site.domain ?? "unknown"}\n- Target URL: ${url.toString()}\n- Lytx tag id (account): ${tagId}\n- Tag script detected on page: ${tagFound}\n- Tracking events seen recently: ${trackingOk === null ? "unknown" : trackingOk}\n\nHTML (truncated):\n${truncateUtf8(html, 60_000)}`;
668
762
  const aiStartedAt = Date.now();
669
763
 
670
764
  try {
671
765
  const result = await generateText({
672
- model: modelProvider.chatModel(aiConfig.model),
766
+ model: aiModel,
673
767
  system: getSiteTagSystemPrompt(),
674
768
  prompt,
675
769
  });
@@ -828,12 +922,7 @@ export const aiChatRoute = route(
828
922
  );
829
923
  }
830
924
 
831
- const modelProvider = createOpenAICompatible({
832
- baseURL: aiConfig.baseURL,
833
- name: "team-model",
834
- apiKey: aiConfig.apiKey,
835
- includeUsage: true,
836
- });
925
+ const aiModel = createAiLanguageModel(aiConfig);
837
926
 
838
927
  const system = `${getSchemaPrompt()}\n\n${getTeamContextPrompt(ctx, siteId, defaultToolSiteId)}`;
839
928
 
@@ -1053,7 +1142,7 @@ export const aiChatRoute = route(
1053
1142
  };
1054
1143
 
1055
1144
  const result = streamText({
1056
- model: modelProvider.chatModel(aiConfig.model),
1145
+ model: aiModel,
1057
1146
  system,
1058
1147
  messages: modelMessages,
1059
1148
  tools,
@@ -4,8 +4,37 @@ export const CREATE_LYTX_APP_CONFIG_DOC_URL =
4
4
  "https://github.com/lytx-io/lytx/blob/master/core/docs/oss-contract.md#supported-extension-and-customization-points";
5
5
 
6
6
  const dbAdapterValues = ["sqlite", "postgres", "singlestore", "analytics_engine"] as const;
7
+ const signupModeValues = ["open", "bootstrap_then_invite", "invite_only"] as const;
8
+
9
+ export const LYTX_AI_PROVIDER_PRESETS = [
10
+ "openai",
11
+ "openrouter",
12
+ "groq",
13
+ "deepseek",
14
+ "xai",
15
+ "ollama",
16
+ "anthropic",
17
+ "claude",
18
+ "google",
19
+ "gemini",
20
+ "cloudflare",
21
+ "custom",
22
+ ] as const;
23
+
24
+ export const LYTX_AI_MODEL_PRESETS = [
25
+ "gpt-5-mini",
26
+ "openai/gpt-4o-mini",
27
+ "llama-3.1-70b-versatile",
28
+ "deepseek-chat",
29
+ "grok-2-latest",
30
+ "llama3.2",
31
+ "claude-3-5-sonnet-latest",
32
+ "gemini-2.5-flash",
33
+ "@cf/meta/llama-3.1-8b-instruct",
34
+ ] as const;
7
35
 
8
36
  const dbAdapterSchema = z.enum(dbAdapterValues);
37
+ const signupModeSchema = z.enum(signupModeValues);
9
38
  const eventStoreSchema = z.enum([...dbAdapterValues, "durable_objects"] as const);
10
39
 
11
40
  const dbConfigSchema = z
@@ -16,6 +45,7 @@ const dbConfigSchema = z
16
45
  .strict();
17
46
 
18
47
  export type LytxDbAdapter = z.infer<typeof dbAdapterSchema>;
48
+ export type LytxSignupMode = z.infer<typeof signupModeSchema>;
19
49
  export type LytxEventStore = z.infer<typeof eventStoreSchema>;
20
50
  export type LytxDbConfig = z.input<typeof dbConfigSchema>;
21
51
 
@@ -56,17 +86,43 @@ const domainSchema = z
56
86
  }, "Domain must be a valid hostname or URL");
57
87
 
58
88
  const envKeySchema = z.string().trim().min(1, "Env var value cannot be empty");
89
+ const optionalTrimmedStringSchema = z.string().trim().optional();
90
+
91
+ const optionalUrlSchema = (message: string) =>
92
+ z
93
+ .string()
94
+ .trim()
95
+ .optional()
96
+ .refine((value) => {
97
+ if (!value) return true;
98
+ try {
99
+ new URL(value);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }, message);
59
105
 
60
106
  const aiRuntimeConfigSchema = z
61
107
  .object({
62
- provider: envKeySchema.optional(),
63
- baseURL: z.string().trim().url("ai.baseURL must be a valid URL").optional(),
64
- model: envKeySchema.optional(),
65
- apiKey: envKeySchema.optional(),
108
+ provider: optionalTrimmedStringSchema,
109
+ baseURL: optionalUrlSchema("ai.baseURL must be a valid URL"),
110
+ model: optionalTrimmedStringSchema,
111
+ apiKey: optionalTrimmedStringSchema,
112
+ accountId: optionalTrimmedStringSchema,
66
113
  })
67
114
  .strict();
68
115
 
69
- export type LytxAiConfig = z.input<typeof aiRuntimeConfigSchema>;
116
+ export type LytxAiProviderPreset = (typeof LYTX_AI_PROVIDER_PRESETS)[number];
117
+ export type LytxAiProvider = LytxAiProviderPreset | (string & {});
118
+ export type LytxAiModelPreset = (typeof LYTX_AI_MODEL_PRESETS)[number];
119
+ export type LytxAiModel = LytxAiModelPreset | (string & {});
120
+
121
+ type BaseLytxAiConfig = z.input<typeof aiRuntimeConfigSchema>;
122
+ export type LytxAiConfig = Omit<BaseLytxAiConfig, "provider" | "model"> & {
123
+ provider?: LytxAiProvider;
124
+ model?: LytxAiModel;
125
+ };
70
126
 
71
127
  const createLytxAppConfigSchema = z
72
128
  .object({
@@ -81,6 +137,7 @@ const createLytxAppConfigSchema = z
81
137
  .object({
82
138
  emailPasswordEnabled: z.boolean().optional(),
83
139
  requireEmailVerification: z.boolean().optional(),
140
+ signupMode: signupModeSchema.optional(),
84
141
  socialProviders: z
85
142
  .object({
86
143
  google: z.boolean().optional(),
@@ -136,6 +193,7 @@ const createLytxAppConfigSchema = z
136
193
  BETTER_AUTH_URL: z.string().trim().url("BETTER_AUTH_URL must be a valid URL").optional(),
137
194
  ENCRYPTION_KEY: envKeySchema.optional(),
138
195
  AI_API_KEY: envKeySchema.optional(),
196
+ AI_ACCOUNT_ID: envKeySchema.optional(),
139
197
  AI_BASE_URL: z.string().trim().url("AI_BASE_URL must be a valid URL").optional(),
140
198
  AI_PROVIDER: envKeySchema.optional(),
141
199
  AI_MODEL: envKeySchema.optional(),
@@ -242,7 +300,11 @@ const createLytxAppConfigSchema = z
242
300
  }
243
301
  });
244
302
 
245
- export type CreateLytxAppConfig = z.input<typeof createLytxAppConfigSchema>;
303
+ type BaseCreateLytxAppConfig = z.input<typeof createLytxAppConfigSchema>;
304
+ export type CreateLytxAppConfig = Omit<BaseCreateLytxAppConfig, "ai"> & {
305
+ ai?: LytxAiConfig;
306
+ };
307
+ type ParsedCreateLytxAppConfig = z.output<typeof createLytxAppConfigSchema>;
246
308
 
247
309
  function formatValidationErrors(error: z.ZodError): string {
248
310
  const lines = error.issues.map((issue) => {
@@ -257,10 +319,31 @@ function formatValidationErrors(error: z.ZodError): string {
257
319
  ].join("\n");
258
320
  }
259
321
 
260
- export function parseCreateLytxAppConfig(config: CreateLytxAppConfig): CreateLytxAppConfig {
322
+ function normalizeOptionalValue(value: string | undefined): string | undefined {
323
+ if (typeof value !== "string") return undefined;
324
+ const trimmed = value.trim();
325
+ return trimmed.length > 0 ? trimmed : undefined;
326
+ }
327
+
328
+ export function parseCreateLytxAppConfig(config: CreateLytxAppConfig): ParsedCreateLytxAppConfig {
261
329
  const parsed = createLytxAppConfigSchema.safeParse(config ?? {});
262
330
  if (!parsed.success) {
263
331
  throw new Error(formatValidationErrors(parsed.error));
264
332
  }
265
- return parsed.data;
333
+
334
+ const normalized: ParsedCreateLytxAppConfig = {
335
+ ...parsed.data,
336
+ ai: parsed.data.ai
337
+ ? {
338
+ ...parsed.data.ai,
339
+ provider: normalizeOptionalValue(parsed.data.ai.provider),
340
+ baseURL: normalizeOptionalValue(parsed.data.ai.baseURL),
341
+ model: normalizeOptionalValue(parsed.data.ai.model),
342
+ apiKey: normalizeOptionalValue(parsed.data.ai.apiKey),
343
+ accountId: normalizeOptionalValue(parsed.data.ai.accountId),
344
+ }
345
+ : undefined,
346
+ };
347
+
348
+ return normalized;
266
349
  }
@@ -11,6 +11,7 @@ type AuthProviders = {
11
11
  type LoginProps = {
12
12
  authProviders?: AuthProviders;
13
13
  emailPasswordEnabled?: boolean;
14
+ allowSignupLink?: boolean;
14
15
  };
15
16
 
16
17
  type AuthUiStatus =
@@ -58,7 +59,11 @@ function isEmailVerificationError(message: string) {
58
59
  );
59
60
  }
60
61
 
61
- export function Login({ authProviders = { google: true, github: true }, emailPasswordEnabled = true }: LoginProps) {
62
+ export function Login({
63
+ authProviders = { google: true, github: true },
64
+ emailPasswordEnabled = true,
65
+ allowSignupLink = true,
66
+ }: LoginProps) {
62
67
  const [status, setStatus] = useState<AuthUiStatus>({ type: "idle" });
63
68
  const [email, setEmail] = useState("");
64
69
  const [isResending, setIsResending] = useState(false);
@@ -274,7 +279,9 @@ export function Login({ authProviders = { google: true, github: true }, emailPas
274
279
  </div>
275
280
  )}
276
281
  <div className="h-5 my-4">
277
- <a href="/signup" className="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white transition-colors">Create an account</a>
282
+ {allowSignupLink ? (
283
+ <a href="/signup" className="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white transition-colors">Create an account</a>
284
+ ) : null}
278
285
  </div>
279
286
  </div>
280
287
  </div>
@@ -11,6 +11,8 @@ type AuthProviders = {
11
11
  type SignupProps = {
12
12
  authProviders?: AuthProviders;
13
13
  emailPasswordEnabled?: boolean;
14
+ publicSignupOpen?: boolean;
15
+ bootstrapSignupOpen?: boolean;
14
16
  };
15
17
 
16
18
  type SignupStatus =
@@ -19,7 +21,12 @@ type SignupStatus =
19
21
  | { type: "success"; message: string }
20
22
  | { type: "error"; message: string };
21
23
 
22
- export function Signup({ authProviders = { google: true, github: true }, emailPasswordEnabled = true }: SignupProps) {
24
+ export function Signup({
25
+ authProviders = { google: true, github: true },
26
+ emailPasswordEnabled = true,
27
+ publicSignupOpen = true,
28
+ bootstrapSignupOpen = false,
29
+ }: SignupProps) {
23
30
  const [status, setStatus] = useState<SignupStatus>({ type: "idle" });
24
31
  const [email, setEmail] = useState("");
25
32
  const [password, setPassword] = useState("");
@@ -62,6 +69,15 @@ export function Signup({ authProviders = { google: true, github: true }, emailPa
62
69
  <span>Lytx</span>
63
70
  </div>
64
71
  <div className="h-auto my-4">Register your account</div>
72
+ {!publicSignupOpen ? (
73
+ <div className="mb-4 px-4 w-full max-w-[300px] text-xs text-amber-700 dark:text-amber-300">
74
+ Public sign up is closed. Use an invited email address to register.
75
+ </div>
76
+ ) : bootstrapSignupOpen ? (
77
+ <div className="mb-4 px-4 w-full max-w-[300px] text-xs text-sky-700 dark:text-sky-300">
78
+ You are creating the first admin account for this instance. Public sign up will close after this account is created.
79
+ </div>
80
+ ) : null}
65
81
 
66
82
  {(authProviders.google || authProviders.github) ? (
67
83
  <div className="flex flex-col gap-3 mb-6 px-4 w-full max-w-[300px]">
package/src/worker.tsx CHANGED
@@ -19,7 +19,13 @@ import {
19
19
  } from "@api/tag_api";
20
20
  import { lytxTag, trackWebEvent } from "@api/tag_api_v2";
21
21
  import { authMiddleware, sessionMiddleware } from "@api/authMiddleware";
22
- import { getAuth, getAuthProviderAvailability, setAuthRuntimeConfig } from "@lib/auth";
22
+ import {
23
+ canRegisterEmail,
24
+ getSignupAccessState,
25
+ getAuth,
26
+ getAuthProviderAvailability,
27
+ setAuthRuntimeConfig,
28
+ } from "@lib/auth";
23
29
  import { Signup } from "@/pages/Signup";
24
30
  import { Login } from "@/pages/Login";
25
31
  import { VerifyEmail } from "@/pages/VerifyEmail";
@@ -160,12 +166,47 @@ const appRoute = <TPath extends string>(
160
166
  path: TPath,
161
167
  handlers: Parameters<typeof route<TPath, AppRequestInfo>>[1],
162
168
  ) => route<TPath, AppRequestInfo>(path, handlers);
169
+
170
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
171
+ return typeof value === "object" && value !== null;
172
+ };
173
+
174
+ const isEmailSignupRequest = (request: Request): boolean => {
175
+ if (request.method !== "POST") return false;
176
+ const url = new URL(request.url);
177
+ return url.pathname === "/api/auth/sign-up/email";
178
+ };
179
+
180
+ const readSignupEmail = async (request: Request): Promise<string> => {
181
+ try {
182
+ const body = await request.clone().json();
183
+ if (!isRecord(body) || typeof body.email !== "string") return "";
184
+ return body.email;
185
+ } catch {
186
+ return "";
187
+ }
188
+ };
189
+
190
+ const buildSignupClosedResponse = (): Response => {
191
+ return new Response(
192
+ JSON.stringify({
193
+ code: "SIGNUP_REQUIRES_INVITE",
194
+ message: "Public sign up is disabled. Ask an admin for an invitation.",
195
+ }),
196
+ {
197
+ status: 403,
198
+ headers: { "Content-Type": "application/json" },
199
+ },
200
+ );
201
+ };
202
+
163
203
  export function createLytxApp(config: CreateLytxAppConfig = {}) {
164
204
  const parsed_config = parseCreateLytxAppConfig(config);
165
205
  setAuthRuntimeConfig(parsed_config.auth);
166
206
  setEmailFromAddress(parsed_config.env?.EMAIL_FROM);
167
207
  setAiRuntimeOverrides({
168
208
  apiKey: parsed_config.ai?.apiKey ?? parsed_config.env?.AI_API_KEY,
209
+ accountId: parsed_config.ai?.accountId ?? parsed_config.env?.AI_ACCOUNT_ID,
169
210
  model: parsed_config.ai?.model ?? parsed_config.env?.AI_MODEL,
170
211
  baseURL: parsed_config.ai?.baseURL ?? parsed_config.env?.AI_BASE_URL,
171
212
  provider: parsed_config.ai?.provider ?? parsed_config.env?.AI_PROVIDER,
@@ -244,7 +285,16 @@ export function createLytxApp(config: CreateLytxAppConfig = {}) {
244
285
  seedApi,
245
286
  ...(authEnabled
246
287
  ? [
247
- route("/api/auth/*", (r) => authMiddleware(r)),
288
+ route("/api/auth/*", async (r) => {
289
+ if (isEmailSignupRequest(r.request)) {
290
+ const signupEmail = await readSignupEmail(r.request);
291
+ const allowed = await canRegisterEmail(signupEmail);
292
+ if (!allowed) {
293
+ return buildSignupClosedResponse();
294
+ }
295
+ }
296
+ return authMiddleware(r);
297
+ }),
248
298
  resendVerificationEmailRoute,
249
299
  userApiRoutes,
250
300
  ]
@@ -258,8 +308,16 @@ export function createLytxApp(config: CreateLytxAppConfig = {}) {
258
308
  ...(authEnabled
259
309
  ? [
260
310
  route("/signup", [
261
- onlyAllowGetPost, () => {
262
- return <Signup authProviders={authProviders} emailPasswordEnabled={emailPasswordEnabled} />;
311
+ onlyAllowGetPost, async () => {
312
+ const signupAccess = await getSignupAccessState();
313
+ return (
314
+ <Signup
315
+ authProviders={authProviders}
316
+ emailPasswordEnabled={emailPasswordEnabled}
317
+ publicSignupOpen={signupAccess.publicSignupOpen}
318
+ bootstrapSignupOpen={signupAccess.bootstrapSignupOpen}
319
+ />
320
+ );
263
321
  },
264
322
  ]),
265
323
  ]
@@ -268,8 +326,15 @@ export function createLytxApp(config: CreateLytxAppConfig = {}) {
268
326
  ? [
269
327
  route("/login", [
270
328
  onlyAllowGetPost,
271
- () => {
272
- return <Login authProviders={authProviders} emailPasswordEnabled={emailPasswordEnabled} />;
329
+ async () => {
330
+ const signupAccess = await getSignupAccessState();
331
+ return (
332
+ <Login
333
+ authProviders={authProviders}
334
+ emailPasswordEnabled={emailPasswordEnabled}
335
+ allowSignupLink={signupAccess.publicSignupOpen}
336
+ />
337
+ );
273
338
  },
274
339
  ]),
275
340
  route("/verify-email", [