lytx 0.3.2 → 0.3.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/.env.example +1 -0
- package/README.md +37 -1
- package/alchemy.run.ts +1 -0
- package/cli/setup.ts +1 -0
- package/index.d.ts +5 -0
- package/index.ts +5 -0
- package/lib/auth.ts +128 -6
- package/package.json +4 -1
- package/src/api/ai_api.ts +108 -19
- package/src/app/components/NewSiteSetup.tsx +1 -1
- package/src/config/createLytxAppConfig.ts +91 -8
- package/src/pages/Login.tsx +9 -2
- package/src/pages/Signup.tsx +17 -1
- package/src/worker.tsx +71 -6
package/.env.example
CHANGED
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
|
-
- `
|
|
75
|
+
- `auth.signupMode` (`"open" | "bootstrap_then_invite" | "invite_only"`; default is `"bootstrap_then_invite"`)
|
|
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)
|
|
@@ -347,6 +348,40 @@ LYTX_TRACKING_DOMAIN=collect.example.com
|
|
|
347
348
|
|
|
348
349
|
Use `createLytxApp({ tagRoutes: { pathPrefix: "/collect" } })` to prefix tracking script and ingestion endpoints.
|
|
349
350
|
|
|
351
|
+
### Auth setup (important)
|
|
352
|
+
|
|
353
|
+
`createLytxApp` defaults to bootstrap-safe auth behavior:
|
|
354
|
+
|
|
355
|
+
- `auth.signupMode` defaults to `"bootstrap_then_invite"`.
|
|
356
|
+
- First account signup is allowed and becomes the initial admin.
|
|
357
|
+
- After the first account exists, public signup is automatically closed.
|
|
358
|
+
- New users can then register only through team invites.
|
|
359
|
+
|
|
360
|
+
This default applies when:
|
|
361
|
+
|
|
362
|
+
- `auth` is omitted entirely, or
|
|
363
|
+
- `auth: {}` is passed.
|
|
364
|
+
|
|
365
|
+
Use these explicit modes when you need different behavior:
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
createLytxApp({
|
|
369
|
+
auth: {
|
|
370
|
+
// "bootstrap_then_invite" is the default
|
|
371
|
+
signupMode: "bootstrap_then_invite",
|
|
372
|
+
// signupMode: "invite_only", // never allow public signup
|
|
373
|
+
// signupMode: "open", // always allow public signup
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
If you need to bootstrap an admin user without public signup, use the CLI:
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
cd core
|
|
382
|
+
bun run cli/bootstrap-admin.ts --email admin@example.com --password "StrongPassword123"
|
|
383
|
+
```
|
|
384
|
+
|
|
350
385
|
### Environment variables
|
|
351
386
|
|
|
352
387
|
Add these to your `.env` (local) or worker secrets (production):
|
|
@@ -369,6 +404,7 @@ RESEND_API_KEY=...
|
|
|
369
404
|
|
|
370
405
|
# AI features (optional)
|
|
371
406
|
AI_API_KEY=...
|
|
407
|
+
AI_ACCOUNT_ID=...
|
|
372
408
|
AI_PROVIDER=openai
|
|
373
409
|
AI_BASE_URL=...
|
|
374
410
|
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
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 = {
|
|
@@ -68,25 +81,32 @@ export type AuthProviderAvailability = {
|
|
|
68
81
|
github: boolean;
|
|
69
82
|
};
|
|
70
83
|
|
|
71
|
-
|
|
84
|
+
const DEFAULT_AUTH_RUNTIME_CONFIG: AuthRuntimeConfig = {
|
|
72
85
|
emailPasswordEnabled: true,
|
|
73
86
|
requireEmailVerification: true,
|
|
87
|
+
signupMode: "bootstrap_then_invite",
|
|
74
88
|
socialProviders: {},
|
|
75
89
|
};
|
|
76
90
|
|
|
91
|
+
let auth_runtime_config: AuthRuntimeConfig = {
|
|
92
|
+
...DEFAULT_AUTH_RUNTIME_CONFIG,
|
|
93
|
+
};
|
|
94
|
+
|
|
77
95
|
let auth_instance: ReturnType<typeof betterAuth> | null = null;
|
|
78
96
|
|
|
79
97
|
const hasGoogleCredentials = () => Boolean(env.GOOGLE_CLIENT_ID?.trim() && env.GOOGLE_CLIENT_SECRET?.trim());
|
|
80
98
|
const hasGithubCredentials = () => Boolean(env.GITHUB_CLIENT_ID?.trim() && env.GITHUB_CLIENT_SECRET?.trim());
|
|
81
99
|
|
|
82
100
|
export function setAuthRuntimeConfig(config: AuthRuntimeConfig = {}) {
|
|
101
|
+
const socialProviders = {
|
|
102
|
+
...DEFAULT_AUTH_RUNTIME_CONFIG.socialProviders,
|
|
103
|
+
...config.socialProviders,
|
|
104
|
+
};
|
|
105
|
+
|
|
83
106
|
auth_runtime_config = {
|
|
84
|
-
...
|
|
107
|
+
...DEFAULT_AUTH_RUNTIME_CONFIG,
|
|
85
108
|
...config,
|
|
86
|
-
socialProviders
|
|
87
|
-
...auth_runtime_config.socialProviders,
|
|
88
|
-
...config.socialProviders,
|
|
89
|
-
},
|
|
109
|
+
socialProviders,
|
|
90
110
|
};
|
|
91
111
|
|
|
92
112
|
auth_instance = null;
|
|
@@ -102,6 +122,101 @@ export function getAuthProviderAvailability(): AuthProviderAvailability {
|
|
|
102
122
|
};
|
|
103
123
|
}
|
|
104
124
|
|
|
125
|
+
function getSignupMode(): SignupMode {
|
|
126
|
+
return auth_runtime_config.signupMode ?? "bootstrap_then_invite";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isMissingTableError(error: unknown): boolean {
|
|
130
|
+
if (!error || typeof error !== "object") return false;
|
|
131
|
+
const message = (error as { message?: unknown }).message;
|
|
132
|
+
return typeof message === "string" && message.includes("no such table");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function hasAnyUserAccount(): Promise<boolean> {
|
|
136
|
+
try {
|
|
137
|
+
const users = await d1_client.select({ id: user_table.id }).from(user_table).limit(1);
|
|
138
|
+
return typeof users[0]?.id === "string";
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (isMissingTableError(error)) {
|
|
141
|
+
if (IS_DEV) {
|
|
142
|
+
console.warn("Signup bootstrap check: user table missing, treating as zero users");
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function hasPendingInviteForEmail(email: string): Promise<boolean> {
|
|
151
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
152
|
+
if (!normalizedEmail) return false;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const invites = await d1_client
|
|
156
|
+
.select({ id: invited_user.id })
|
|
157
|
+
.from(invited_user)
|
|
158
|
+
.where(and(eq(invited_user.email, normalizedEmail), eq(invited_user.accepted, false)))
|
|
159
|
+
.limit(1);
|
|
160
|
+
|
|
161
|
+
return typeof invites[0]?.id === "number";
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (isMissingTableError(error)) {
|
|
164
|
+
if (IS_DEV) {
|
|
165
|
+
console.warn("Signup invite check: invited_user table missing, treating as no invite");
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function getSignupAccessState(): Promise<SignupAccessState> {
|
|
174
|
+
const mode = getSignupMode();
|
|
175
|
+
|
|
176
|
+
if (mode === "open") {
|
|
177
|
+
return {
|
|
178
|
+
mode,
|
|
179
|
+
hasUsers: true,
|
|
180
|
+
publicSignupOpen: true,
|
|
181
|
+
bootstrapSignupOpen: false,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (mode === "invite_only") {
|
|
186
|
+
return {
|
|
187
|
+
mode,
|
|
188
|
+
hasUsers: true,
|
|
189
|
+
publicSignupOpen: false,
|
|
190
|
+
bootstrapSignupOpen: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const hasUsers = await hasAnyUserAccount();
|
|
195
|
+
return {
|
|
196
|
+
mode,
|
|
197
|
+
hasUsers,
|
|
198
|
+
publicSignupOpen: !hasUsers,
|
|
199
|
+
bootstrapSignupOpen: !hasUsers,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function isPublicSignupOpen(): Promise<boolean> {
|
|
204
|
+
const state = await getSignupAccessState();
|
|
205
|
+
return state.publicSignupOpen;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function canRegisterEmail(email: string): Promise<boolean> {
|
|
209
|
+
const signupMode = getSignupMode();
|
|
210
|
+
if (signupMode === "open") return true;
|
|
211
|
+
|
|
212
|
+
const invited = await hasPendingInviteForEmail(email);
|
|
213
|
+
if (invited) return true;
|
|
214
|
+
|
|
215
|
+
if (signupMode === "invite_only") return false;
|
|
216
|
+
const state = await getSignupAccessState();
|
|
217
|
+
return state.bootstrapSignupOpen;
|
|
218
|
+
}
|
|
219
|
+
|
|
105
220
|
function getTrustedOrigins() {
|
|
106
221
|
if (!IS_DEV) return [env.BETTER_AUTH_URL];
|
|
107
222
|
return [
|
|
@@ -245,6 +360,13 @@ function createAuthInstance() {
|
|
|
245
360
|
databaseHooks: {
|
|
246
361
|
user: {
|
|
247
362
|
create: {
|
|
363
|
+
before: async (userRecord) => {
|
|
364
|
+
const email = typeof userRecord.email === "string" ? userRecord.email : "";
|
|
365
|
+
const allowed = await canRegisterEmail(email);
|
|
366
|
+
if (!allowed) {
|
|
367
|
+
throw new Error("Public sign up is disabled. Ask an admin for an invitation.");
|
|
368
|
+
}
|
|
369
|
+
},
|
|
248
370
|
after: async (user) => {
|
|
249
371
|
await createNewAccount(user);
|
|
250
372
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lytx",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
1145
|
+
model: aiModel,
|
|
1057
1146
|
system,
|
|
1058
1147
|
messages: modelMessages,
|
|
1059
1148
|
tools,
|
|
@@ -49,7 +49,7 @@ export const NewSiteSetup: React.FC<NewSiteSetupProps> = ({
|
|
|
49
49
|
|
|
50
50
|
const siteData = await response.json();
|
|
51
51
|
onSiteCreated?.(siteData);
|
|
52
|
-
window.location.href = "/dashboard
|
|
52
|
+
window.location.href = "/dashboard";
|
|
53
53
|
} catch (err) {
|
|
54
54
|
setError(err instanceof Error ? err.message : "An error occurred");
|
|
55
55
|
} finally {
|
|
@@ -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:
|
|
63
|
-
baseURL:
|
|
64
|
-
model:
|
|
65
|
-
apiKey:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/pages/Login.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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>
|
package/src/pages/Signup.tsx
CHANGED
|
@@ -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({
|
|
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 {
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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", [
|