fix(web): remove orphaned `error`/`result` references in settings page #4094
6 changed files+827−311
ModifiedCLAUDE.md+11−7View fileUnifiedSplit
@@ -629,7 +629,7 @@ After writing the code:
62962913. **Configure DNS** for alecrae.com — MX, SPF, DKIM, DMARC, CNAMEs (Craig)
63063014. **Set up Stripe** live keys + webhook URL → api.alecrae.com/billing/webhook (Craig)
63163115. **Add API keys** — Anthropic, OpenAI, Google OAuth, Microsoft OAuth (Craig)
63216. **Deploy to Crontec** — connect repo, set env vars, point domain (Craig + Claude)
63216. **Deploy to Crontech** — runbook + tenant env template ready (`docs/CRONTECH-ONBOARDING.md`, `infrastructure/crontech/.env.alecrae.tenant.template`). Craig runs the wizard at https://crontech.ai/admin/onboard, registers the tenant, points DNS. AlecRae stays a standalone product — Crontech is hosting only.
633633
634634---
635635
@@ -710,9 +710,9 @@ If the answer isn't compelling, don't build it. If it is, build it 10x better th
710710**Current phase:** Phase 1 — Ready for Beta Launch
711711**Current focus:** Admin preview (`/admin` on web app) added so Craig can SEE the admin surface from his iPad ahead of admin.alecrae.com cutover. Production deployment still awaiting Craig's infra setup.
712712**Build completion:** TIER 1-4 ALL DONE (36/36) + 7 bonus + 31 advanced features (S10/10 + A7/7 + B8/8 + C6/10)
713**Date last updated:** 2026-04-24
713**Date last updated:** 2026-04-25
714714**Current phase:** Phase 1 — Launch Imminent
715**Current focus:** Build is 100% clean (29/29 static pages). Landing page merge conflict fixed. Admin console rebuilt as full live dashboard. CI gate hardened. E2E suite expanded. All code done — blocked only on Craig's infra provisioning (Neon, Upstash, Stripe, API keys, DNS, Crontec deploy).
715**Current focus:** Production-blocking build error fixed (orphaned `error` refs in settings/page.tsx — `next build` was exiting 1, killing every Vercel deploy). Auth pages (login/register/forgot-password) rebranded to landing visual language (ivory + Italianno + Inter, no more indigo @alecrae/ui mismatch). Crontech onboarding deliverables prepped: `docs/CRONTECH-ONBOARDING.md` (runbook) + `infrastructure/crontech/.env.alecrae.tenant.template` (wizard input). All code done — blocked only on Craig's infra provisioning (Neon, Upstash, Stripe, API keys, DNS) + running the Crontech wizard.
716716**Build completion:** TIER 1-4 (36/36) + 7 bonus + 31 advanced (S10/10 + A7/7 + B8/8 + C6/10) + 20 expansion (Tier 5) + 9 platform (Tier 6) + 6 intelligence (Tier 7) + 6 deep AI (Tier 8) = 84 features total
717717
718718**Next review:** Before any major architectural change, before any production deployment, at the start of every session.
@@ -909,9 +909,13 @@ If you ship something not in this file, you broke the rules.
909909
910910## 🏗️ THE BIGGER PICTURE — ALECRAE AS FLAGSHIP
911911
912Craig is also building a **Render+Vercel+AI hybrid platform** (the "Back to the Future" infrastructure). AlecRae will eventually deploy on this platform — making AlecRae both:
9131. **A standalone product** that generates revenue
9142. **The flagship reference app** that proves the underlying platform works
912Craig is also building **Crontech** (https://crontech.ai) — a full-stack AI developer platform that replaces Vercel + Supabase + Render + GitHub Actions. AlecRae will deploy on Crontech, making AlecRae both:
9131. **A standalone product** that generates revenue (own brand, own users, own billing)
9142. **The flagship reference app** that proves Crontech works under real production load
915915
916This is why we move with discipline: every architectural choice in AlecRae informs the platform underneath. We don't build AlecRae in a way that requires the platform to ship first — AlecRae deploys to Cloudflare today, and migrates to the new platform when it's ready, with zero rewrites needed (because the new platform supports the same primitives).
916**AlecRae stays standalone.** Crontech is hosting infrastructure — not a product merger. Brand, users, pricing, billing, marketing, and source code all stay in AlecRae's name. The same way Gmail running on Google Cloud doesn't make Gmail "Google Cloud", AlecRae running on Crontech doesn't make AlecRae "Crontech".
917
918This is why we move with discipline: every architectural choice in AlecRae informs Crontech underneath. We don't build AlecRae in a way that requires Crontech to ship first — AlecRae's code is portable (Bun + Next.js + Hono + Drizzle Postgres + standard env vars) and runs anywhere. Onboarding to Crontech is a hosting decision, reversible at the DNS layer.
919
920See `docs/CRONTECH-ONBOARDING.md` for the operator runbook and `infrastructure/crontech/.env.alecrae.tenant.template` for the wizard input.
917921
Modifiedapps/web/app/(auth)/forgot-password/page.tsx+77−63View fileUnifiedSplit
@@ -1,71 +1,85 @@
1"use client";
2
3import { Box, Text, Button, Card, CardContent } from "@alecrae/ui";
4
51export default function ForgotPasswordPage(): React.ReactElement {
62 return (
7 <Box className="min-h-full flex items-center justify-center px-4 py-12 bg-surface-secondary">
8 <Box className="w-full max-w-md">
9 <Box className="text-center mb-8">
10 <Text variant="heading-lg" className="text-brand-600 font-bold mb-2">
3 <main
4 className="min-h-screen bg-[#f5f4ef] text-neutral-900"
5 style={{ fontFamily: "var(--font-inter), sans-serif" }}
6 >
7 <nav className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md bg-[#f5f4ef]/80 border-b border-neutral-300/40">
8 <div className="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
9 <a
10 href="/"
11 className="text-2xl"
12 style={{ fontFamily: "var(--font-italianno), cursive", fontWeight: 400 }}
13 >
1114 AlecRae
12 </Text>
13 <Text variant="display-sm">Account recovery</Text>
14 <Text variant="body-md" muted className="mt-2">
15 Let's get you back in
16 </Text>
17 </Box>
15 </a>
16 <a
17 href="/login"
18 className="text-xs tracking-[0.18em] uppercase text-neutral-600 hover:text-neutral-900 transition-colors"
19 >
20 Sign in
21 </a>
22 </div>
23 </nav>
24
25 <section className="pt-32 pb-16 px-6 flex flex-col items-center">
26 <h1
27 className="text-[5rem] sm:text-[6.5rem] leading-[0.85] text-neutral-900 select-none"
28 style={{
29 fontFamily: "var(--font-italianno), 'Snell Roundhand', cursive",
30 fontWeight: 400,
31 letterSpacing: "-0.01em",
32 }}
33 >
34 Recovery
35 </h1>
36 <div className="mt-3 mb-8 w-32 h-px bg-neutral-400/50" aria-hidden="true" />
37 <p className="text-sm text-neutral-600 font-light">
38 Let's get you back in
39 </p>
1840
19 <Card>
20 <CardContent>
21 <Box className="space-y-6">
22 <Box>
23 <Text variant="heading-sm" className="mb-2">
24 Sign in with a passkey
25 </Text>
26 <Text variant="body-sm" muted className="mb-4">
27 Passkeys are our primary authentication method. If you've set one up on any of your devices, you can use it to sign in without a password.
28 </Text>
29 <Box as="a" href="/login" className="inline-block w-full">
30 <Button variant="primary" size="lg" className="w-full">
31 Return to sign in
32 </Button>
33 </Box>
34 </Box>
41 <div className="mt-10 w-full max-w-md bg-white/60 backdrop-blur-sm border border-neutral-300/60 rounded-2xl p-8 shadow-sm space-y-7">
42 <div>
43 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500 mb-3">
44 Sign in with a passkey
45 </p>
46 <p className="text-sm text-neutral-600 font-light leading-relaxed mb-5">
47 Passkeys are our primary authentication method. If you've set one up on
48 any of your devices, you can use it to sign in without a password.
49 </p>
50 <a
51 href="/login"
52 className="block w-full text-center text-xs tracking-[0.18em] uppercase bg-neutral-900 text-[#f5f4ef] px-5 py-3.5 rounded-full hover:bg-neutral-800 transition-colors"
53 >
54 Return to sign in
55 </a>
56 </div>
3557
36 <Box className="border-t border-border pt-6">
37 <Text variant="heading-sm" className="mb-2">
38 Need more help?
39 </Text>
40 <Text variant="body-sm" muted>
41 Email{" "}
42 <Box
43 as="a"
44 href="mailto:support@alecrae.com"
45 className="inline text-brand-600 hover:text-brand-700 font-medium"
46 >
47 <Text as="span" variant="body-sm" className="text-brand-600">
48 support@alecrae.com
49 </Text>
50 </Box>{" "}
51 from the address tied to your account and our team will verify your identity and help you regain access.
52 </Text>
53 </Box>
54 </Box>
55 </CardContent>
56 </Card>
58 <div className="border-t border-neutral-300/60 pt-6">
59 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500 mb-3">
60 Need more help?
61 </p>
62 <p className="text-sm text-neutral-600 font-light leading-relaxed">
63 Email{" "}
64 <a
65 href="mailto:support@alecrae.com"
66 className="text-neutral-900 underline hover:no-underline"
67 >
68 support@alecrae.com
69 </a>{" "}
70 from the address tied to your account and our team will verify your identity
71 and help you regain access.
72 </p>
73 </div>
74 </div>
5775
58 <Box className="text-center mt-6">
59 <Text variant="body-sm" muted>
60 Don't have an account?{" "}
61 </Text>
62 <Box as="a" href="/register" className="inline">
63 <Text as="span" variant="body-sm" className="text-brand-600 hover:text-brand-700 font-medium">
64 Create one
65 </Text>
66 </Box>
67 </Box>
68 </Box>
69 </Box>
76 <p className="mt-8 text-sm text-neutral-600">
77 Don't have an account?{" "}
78 <a href="/register" className="text-neutral-900 hover:underline font-medium">
79 Create one
80 </a>
81 </p>
82 </section>
83 </main>
7084 );
7185}
Modifiedapps/web/app/(auth)/login/page.tsx+127−101View fileUnifiedSplit
@@ -1,7 +1,6 @@
11"use client";
22
33import { useState, useEffect } from "react";
4import { Box, Text, Button, Input, Card, CardContent } from "@alecrae/ui";
54import { authApi } from "../../../lib/api";
65import {
76 isWebAuthnSupported,
@@ -11,40 +10,58 @@ import {
1110
1211export default function LoginPage(): React.ReactElement {
1312 return (
14 <Box className="min-h-full flex items-center justify-center px-4 py-12 bg-surface-secondary">
15 <Box className="w-full max-w-md">
16 <Box className="text-center mb-8">
17 <Text variant="heading-lg" className="text-brand-600 font-bold mb-2">
13 <main
14 className="min-h-screen bg-[#f5f4ef] text-neutral-900"
15 style={{ fontFamily: "var(--font-inter), sans-serif" }}
16 >
17 <nav className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md bg-[#f5f4ef]/80 border-b border-neutral-300/40">
18 <div className="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
19 <a
20 href="/"
21 className="text-2xl"
22 style={{ fontFamily: "var(--font-italianno), cursive", fontWeight: 400 }}
23 >
1824 AlecRae
19 </Text>
20 <Text variant="display-sm">Welcome back</Text>
21 <Text variant="body-md" muted className="mt-2">
22 Sign in to your account
23 </Text>
24 </Box>
25
26 <Card>
27 <CardContent>
28 <Box className="space-y-6">
29 <PasskeyLogin />
30 <Divider />
31 <EmailLogin />
32 </Box>
33 </CardContent>
34 </Card>
35
36 <Box className="text-center mt-6">
37 <Text variant="body-sm" muted>
38 Don't have an account?{" "}
39 </Text>
40 <Box as="a" href="/register" className="inline">
41 <Text as="span" variant="body-sm" className="text-brand-600 hover:text-brand-700 font-medium">
42 Create one
43 </Text>
44 </Box>
45 </Box>
46 </Box>
47 </Box>
25 </a>
26 <a
27 href="/register"
28 className="text-xs tracking-[0.18em] uppercase text-neutral-600 hover:text-neutral-900 transition-colors"
29 >
30 Create account
31 </a>
32 </div>
33 </nav>
34
35 <section className="pt-32 pb-16 px-6 flex flex-col items-center">
36 <h1
37 className="text-[5rem] sm:text-[6.5rem] leading-[0.85] text-neutral-900 select-none"
38 style={{
39 fontFamily: "var(--font-italianno), 'Snell Roundhand', cursive",
40 fontWeight: 400,
41 letterSpacing: "-0.01em",
42 }}
43 >
44 Welcome back
45 </h1>
46 <div className="mt-3 mb-8 w-32 h-px bg-neutral-400/50" aria-hidden="true" />
47 <p className="text-sm text-neutral-600 font-light">
48 Sign in to your account
49 </p>
50
51 <div className="mt-10 w-full max-w-md bg-white/60 backdrop-blur-sm border border-neutral-300/60 rounded-2xl p-8 shadow-sm">
52 <PasskeyLogin />
53 <Divider />
54 <EmailLogin />
55 </div>
56
57 <p className="mt-8 text-sm text-neutral-600">
58 Don't have an account?{" "}
59 <a href="/register" className="text-neutral-900 hover:underline font-medium">
60 Create one
61 </a>
62 </p>
63 </section>
64 </main>
4865 );
4966}
5067
@@ -93,58 +110,50 @@ function PasskeyLogin(): React.ReactElement {
93110
94111 if (!supported) {
95112 return (
96 <Box className="space-y-3">
97 <Text variant="label">Passkey</Text>
98 <Text variant="caption" className="text-center" muted>
113 <div className="space-y-3">
114 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500">Passkey</p>
115 <p className="text-sm text-neutral-600 text-center font-light">
99116 Passkey authentication is not available on this device. Please use email and password.
100 </Text>
101 </Box>
117 </p>
118 </div>
102119 );
103120 }
104121
105122 return (
106 <Box className="space-y-3">
107 <Text variant="label">Recommended</Text>
123 <div className="space-y-4">
124 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500">Recommended</p>
108125 {error && (
109 <Box className="p-3 rounded-lg bg-red-50 border border-red-200">
110 <Text variant="body-sm" className="text-red-800">
111 {error}
112 </Text>
113 </Box>
126 <div className="p-3 rounded-lg bg-red-50 border border-red-200">
127 <p className="text-sm text-red-800">{error}</p>
128 </div>
114129 )}
115 <Button
116 variant="primary"
117 size="lg"
118 className="w-full"
130 <button
131 type="button"
119132 onClick={handlePasskeyLogin}
120 loading={loading}
121133 disabled={loading}
134 className="w-full text-xs tracking-[0.18em] uppercase bg-neutral-900 text-[#f5f4ef] px-5 py-3.5 rounded-full hover:bg-neutral-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
122135 >
123 {loading ? "Authenticating..." : "Sign in with Passkey"}
124 </Button>
125 <Text variant="caption" className="text-center">
136 {loading ? "Authenticating…" : "Sign in with Passkey"}
137 </button>
138 <p className="text-xs text-neutral-500 text-center font-light">
126139 Use your fingerprint, face, or security key for instant secure access.
127 </Text>
128 </Box>
140 </p>
141 </div>
129142 );
130143}
131144
132PasskeyLogin.displayName = "PasskeyLogin";
133
134145function Divider(): React.ReactElement {
135146 return (
136 <Box className="flex items-center gap-4">
137 <Box className="flex-1 h-px bg-border" />
138 <Text variant="caption" muted>
147 <div className="my-7 flex items-center gap-4">
148 <div className="flex-1 h-px bg-neutral-300/70" />
149 <span className="text-[10px] tracking-[0.2em] uppercase text-neutral-500">
139150 or continue with email
140 </Text>
141 <Box className="flex-1 h-px bg-border" />
142 </Box>
151 </span>
152 <div className="flex-1 h-px bg-neutral-300/70" />
153 </div>
143154 );
144155}
145156
146Divider.displayName = "Divider";
147
148157function EmailLogin(): React.ReactElement {
149158 const [email, setEmail] = useState("");
150159 const [password, setPassword] = useState("");
@@ -169,54 +178,71 @@ function EmailLogin(): React.ReactElement {
169178 };
170179
171180 return (
172 <Box as="form" className="space-y-4" onSubmit={handleSubmit}>
181 <form className="space-y-4" onSubmit={handleSubmit}>
173182 {error && (
174 <Box className="p-3 rounded-lg bg-red-50 border border-red-200">
175 <Text variant="body-sm" className="text-red-800">
176 {error}
177 </Text>
178 </Box>
183 <div className="p-3 rounded-lg bg-red-50 border border-red-200">
184 <p className="text-sm text-red-800">{error}</p>
185 </div>
179186 )}
180 <Input
181 label="Email address"
182 variant="email"
183 placeholder="you@example.com"
187 <FieldLabel htmlFor="email">Email address</FieldLabel>
188 <input
189 id="email"
190 type="email"
184191 autoComplete="email"
192 placeholder="you@example.com"
185193 value={email}
186194 onChange={(e) => setEmail(e.target.value)}
195 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
187196 />
188 <Input
189 label="Password"
190 variant="password"
191 placeholder="Enter your password"
197 <FieldLabel htmlFor="password">Password</FieldLabel>
198 <input
199 id="password"
200 type="password"
192201 autoComplete="current-password"
202 placeholder="Enter your password"
193203 value={password}
194204 onChange={(e) => setPassword(e.target.value)}
205 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
195206 />
196 <Box className="flex items-center justify-between">
197 <Box className="flex items-center gap-2">
198 <Box as="input" type="checkbox" id="remember" className="rounded border-border text-brand-600 focus:ring-brand-500" />
199 <Text as="label" variant="body-sm" htmlFor="remember">
200 Remember me
201 </Text>
202 </Box>
203 <Box as="a" href="/forgot-password">
204 <Text as="span" variant="body-sm" className="text-brand-600 hover:text-brand-700">
205 Forgot password?
206 </Text>
207 </Box>
208 </Box>
209 <Button
210 variant="secondary"
211 size="lg"
212 className="w-full"
207 <div className="flex items-center justify-between pt-1">
208 <label className="flex items-center gap-2 text-sm text-neutral-600 font-light">
209 <input
210 type="checkbox"
211 className="rounded border-neutral-400 text-neutral-900 focus:ring-neutral-900"
212 />
213 Remember me
214 </label>
215 <a
216 href="/forgot-password"
217 className="text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
218 >
219 Forgot password?
220 </a>
221 </div>
222 <button
213223 type="submit"
214224 disabled={loading || !email || !password}
225 className="w-full text-xs tracking-[0.18em] uppercase border border-neutral-900 text-neutral-900 px-5 py-3.5 rounded-full hover:bg-neutral-900 hover:text-[#f5f4ef] transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-900"
215226 >
216 {loading ? "Signing in..." : "Sign in with Email"}
217 </Button>
218 </Box>
227 {loading ? "Signing in…" : "Sign in with Email"}
228 </button>
229 </form>
219230 );
220231}
221232
222EmailLogin.displayName = "EmailLogin";
233function FieldLabel({
234 htmlFor,
235 children,
236}: {
237 htmlFor: string;
238 children: React.ReactNode;
239}): React.ReactElement {
240 return (
241 <label
242 htmlFor={htmlFor}
243 className="block text-xs tracking-[0.18em] uppercase text-neutral-500 mb-1"
244 >
245 {children}
246 </label>
247 );
248}
Modifiedapps/web/app/(auth)/register/page.tsx+178−140View fileUnifiedSplit
@@ -1,7 +1,6 @@
11"use client";
22
33import { useState, useEffect } from "react";
4import { Box, Text, Button, Input, Card, CardContent } from "@alecrae/ui";
54import { authApi } from "../../../lib/api";
65import {
76 isWebAuthnSupported,
@@ -11,54 +10,70 @@ import {
1110
1211export default function RegisterPage(): React.ReactElement {
1312 return (
14 <Box className="min-h-full flex items-center justify-center px-4 py-12 bg-surface-secondary">
15 <Box className="w-full max-w-md">
16 <Box className="text-center mb-8">
17 <Text variant="heading-lg" className="text-brand-600 font-bold mb-2">
13 <main
14 className="min-h-screen bg-[#f5f4ef] text-neutral-900"
15 style={{ fontFamily: "var(--font-inter), sans-serif" }}
16 >
17 <nav className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md bg-[#f5f4ef]/80 border-b border-neutral-300/40">
18 <div className="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
19 <a
20 href="/"
21 className="text-2xl"
22 style={{ fontFamily: "var(--font-italianno), cursive", fontWeight: 400 }}
23 >
1824 AlecRae
19 </Text>
20 <Text variant="display-sm">Create your account</Text>
21 <Text variant="body-md" muted className="mt-2">
22 Get started with AI-native email in minutes
23 </Text>
24 </Box>
25 </a>
26 <a
27 href="/login"
28 className="text-xs tracking-[0.18em] uppercase text-neutral-600 hover:text-neutral-900 transition-colors"
29 >
30 Sign in
31 </a>
32 </div>
33 </nav>
2534
26 <Card>
27 <CardContent>
28 <Box className="space-y-6">
29 <PasskeyRegistration />
30 <RegistrationDivider />
31 <EmailRegistration />
32 </Box>
33 </CardContent>
34 </Card>
35 <section className="pt-32 pb-16 px-6 flex flex-col items-center">
36 <h1
37 className="text-[5rem] sm:text-[6.5rem] leading-[0.85] text-neutral-900 select-none"
38 style={{
39 fontFamily: "var(--font-italianno), 'Snell Roundhand', cursive",
40 fontWeight: 400,
41 letterSpacing: "-0.01em",
42 }}
43 >
44 Create account
45 </h1>
46 <div className="mt-3 mb-8 w-32 h-px bg-neutral-400/50" aria-hidden="true" />
47 <p className="text-sm text-neutral-600 font-light">
48 Get started with AI-native email in minutes
49 </p>
50
51 <div className="mt-10 w-full max-w-md bg-white/60 backdrop-blur-sm border border-neutral-300/60 rounded-2xl p-8 shadow-sm">
52 <PasskeyRegistration />
53 <RegistrationDivider />
54 <EmailRegistration />
55 </div>
3556
36 <Box className="text-center mt-6">
37 <Text variant="body-sm" muted>
38 Already have an account?{" "}
39 </Text>
40 <Box as="a" href="/login" className="inline">
41 <Text as="span" variant="body-sm" className="text-brand-600 hover:text-brand-700 font-medium">
42 Sign in
43 </Text>
44 </Box>
45 </Box>
57 <p className="mt-8 text-sm text-neutral-600">
58 Already have an account?{" "}
59 <a href="/login" className="text-neutral-900 hover:underline font-medium">
60 Sign in
61 </a>
62 </p>
4663
47 <Box className="text-center mt-4">
48 <Text variant="caption" muted>
49 By creating an account, you agree to our{" "}
50 <Box as="a" href="/terms" className="text-brand-600 hover:underline inline">
51 <Text as="span" variant="caption">Terms of Service</Text>
52 </Box>
53 {" "}and{" "}
54 <Box as="a" href="/privacy" className="text-brand-600 hover:underline inline">
55 <Text as="span" variant="caption">Privacy Policy</Text>
56 </Box>
57 .
58 </Text>
59 </Box>
60 </Box>
61 </Box>
64 <p className="mt-3 text-xs text-neutral-500 max-w-md text-center font-light">
65 By creating an account, you agree to our{" "}
66 <a href="/terms" className="text-neutral-700 hover:text-neutral-900 underline">
67 Terms of Service
68 </a>{" "}
69 and{" "}
70 <a href="/privacy" className="text-neutral-700 hover:text-neutral-900 underline">
71 Privacy Policy
72 </a>
73 .
74 </p>
75 </section>
76 </main>
6277 );
6378}
6479
@@ -121,102 +136,100 @@ function PasskeyRegistration(): React.ReactElement {
121136
122137 if (!supported) {
123138 return (
124 <Box className="space-y-3">
125 <Text variant="label">Passkey</Text>
126 <Text variant="caption" className="text-center" muted>
139 <div className="space-y-3">
140 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500">Passkey</p>
141 <p className="text-sm text-neutral-600 text-center font-light">
127142 Passkey registration is not available on this device. Please use email and password below.
128 </Text>
129 </Box>
143 </p>
144 </div>
130145 );
131146 }
132147
133148 if (step === "initial") {
134149 return (
135 <Box className="space-y-3">
136 <Text variant="label">Fastest way to get started</Text>
137 <Button
138 variant="primary"
139 size="lg"
140 className="w-full"
150 <div className="space-y-4">
151 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500">
152 Fastest way to get started
153 </p>
154 <button
155 type="button"
141156 onClick={() => setStep("details")}
157 className="w-full text-xs tracking-[0.18em] uppercase bg-neutral-900 text-[#f5f4ef] px-5 py-3.5 rounded-full hover:bg-neutral-800 transition-colors"
142158 >
143159 Register with Passkey
144 </Button>
145 <Text variant="caption" className="text-center">
146 Create a passkey using your device biometrics. No password needed -- ever.
147 </Text>
148 </Box>
160 </button>
161 <p className="text-xs text-neutral-500 text-center font-light">
162 Create a passkey using your device biometrics. No password needed — ever.
163 </p>
164 </div>
149165 );
150166 }
151167
152168 return (
153 <Box className="space-y-3">
154 <Text variant="label">Create your passkey</Text>
169 <div className="space-y-4">
170 <p className="text-xs tracking-[0.18em] uppercase text-neutral-500">
171 Create your passkey
172 </p>
155173 {error && (
156 <Box className="p-3 rounded-lg bg-red-50 border border-red-200">
157 <Text variant="body-sm" className="text-red-800">
158 {error}
159 </Text>
160 </Box>
174 <div className="p-3 rounded-lg bg-red-50 border border-red-200">
175 <p className="text-sm text-red-800">{error}</p>
176 </div>
161177 )}
162 <Input
163 label="Your name"
164 variant="text"
165 placeholder="Jane Doe"
178 <FieldLabel htmlFor="passkey-name">Your name</FieldLabel>
179 <input
180 id="passkey-name"
181 type="text"
166182 autoComplete="name"
183 placeholder="Jane Doe"
167184 value={name}
168185 onChange={(e) => setName(e.target.value)}
186 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
169187 />
170 <Input
171 label="Email address"
172 variant="email"
173 placeholder="you@example.com"
188 <FieldLabel htmlFor="passkey-email">Email address</FieldLabel>
189 <input
190 id="passkey-email"
191 type="email"
174192 autoComplete="email"
193 placeholder="you@example.com"
175194 value={email}
176195 onChange={(e) => setEmail(e.target.value)}
196 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
177197 />
178 <Button
179 variant="primary"
180 size="lg"
181 className="w-full"
198 <button
199 type="button"
182200 onClick={handlePasskeyRegister}
183 loading={loading}
184201 disabled={loading || !name.trim() || !email.trim()}
202 className="w-full text-xs tracking-[0.18em] uppercase bg-neutral-900 text-[#f5f4ef] px-5 py-3.5 rounded-full hover:bg-neutral-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
185203 >
186 {loading ? "Creating passkey..." : "Create Passkey"}
187 </Button>
188 <Button
189 variant="ghost"
190 size="sm"
191 className="w-full"
204 {loading ? "Creating passkey…" : "Create Passkey"}
205 </button>
206 <button
207 type="button"
192208 onClick={() => {
193209 setStep("initial");
194210 setError(null);
195211 }}
196212 disabled={loading}
213 className="w-full text-xs tracking-[0.18em] uppercase text-neutral-500 hover:text-neutral-900 transition-colors py-2 disabled:opacity-50"
197214 >
198215 Back
199 </Button>
200 </Box>
216 </button>
217 </div>
201218 );
202219}
203220
204PasskeyRegistration.displayName = "PasskeyRegistration";
205
206221function RegistrationDivider(): React.ReactElement {
207222 return (
208 <Box className="flex items-center gap-4">
209 <Box className="flex-1 h-px bg-border" />
210 <Text variant="caption" muted>
223 <div className="my-7 flex items-center gap-4">
224 <div className="flex-1 h-px bg-neutral-300/70" />
225 <span className="text-[10px] tracking-[0.2em] uppercase text-neutral-500">
211226 or register with email
212 </Text>
213 <Box className="flex-1 h-px bg-border" />
214 </Box>
227 </span>
228 <div className="flex-1 h-px bg-neutral-300/70" />
229 </div>
215230 );
216231}
217232
218RegistrationDivider.displayName = "RegistrationDivider";
219
220233function EmailRegistration(): React.ReactElement {
221234 const [firstName, setFirstName] = useState("");
222235 const [lastName, setLastName] = useState("");
@@ -249,60 +262,85 @@ function EmailRegistration(): React.ReactElement {
249262 };
250263
251264 return (
252 <Box as="form" className="space-y-4" onSubmit={handleSubmit}>
265 <form className="space-y-4" onSubmit={handleSubmit}>
253266 {error && (
254 <Box className="p-3 rounded-lg bg-red-50 border border-red-200">
255 <Text variant="body-sm" className="text-red-800">
256 {error}
257 </Text>
258 </Box>
267 <div className="p-3 rounded-lg bg-red-50 border border-red-200">
268 <p className="text-sm text-red-800">{error}</p>
269 </div>
259270 )}
260 <Box className="grid grid-cols-2 gap-4">
261 <Input
262 label="First name"
263 variant="text"
264 placeholder="Jane"
265 autoComplete="given-name"
266 value={firstName}
267 onChange={(e) => setFirstName(e.target.value)}
268 />
269 <Input
270 label="Last name"
271 variant="text"
272 placeholder="Doe"
273 autoComplete="family-name"
274 value={lastName}
275 onChange={(e) => setLastName(e.target.value)}
276 />
277 </Box>
278 <Input
279 label="Email address"
280 variant="email"
281 placeholder="you@example.com"
271 <div className="grid grid-cols-2 gap-3">
272 <div>
273 <FieldLabel htmlFor="first-name">First name</FieldLabel>
274 <input
275 id="first-name"
276 type="text"
277 autoComplete="given-name"
278 placeholder="Jane"
279 value={firstName}
280 onChange={(e) => setFirstName(e.target.value)}
281 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
282 />
283 </div>
284 <div>
285 <FieldLabel htmlFor="last-name">Last name</FieldLabel>
286 <input
287 id="last-name"
288 type="text"
289 autoComplete="family-name"
290 placeholder="Doe"
291 value={lastName}
292 onChange={(e) => setLastName(e.target.value)}
293 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
294 />
295 </div>
296 </div>
297 <FieldLabel htmlFor="reg-email">Email address</FieldLabel>
298 <input
299 id="reg-email"
300 type="email"
282301 autoComplete="email"
302 placeholder="you@example.com"
283303 value={email}
284304 onChange={(e) => setEmail(e.target.value)}
305 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
285306 />
286 <Input
287 label="Password"
288 variant="password"
289 placeholder="Create a strong password"
307 <FieldLabel htmlFor="reg-password">Password</FieldLabel>
308 <input
309 id="reg-password"
310 type="password"
290311 autoComplete="new-password"
291 hint="Must be at least 8 characters."
312 placeholder="Create a strong password"
292313 value={password}
293314 onChange={(e) => setPassword(e.target.value)}
315 className="w-full bg-transparent border border-neutral-300/70 rounded-lg px-4 py-3 text-sm placeholder:text-neutral-400 focus:outline-none focus:border-neutral-900 transition-colors"
294316 />
295 <Button
296 variant="secondary"
297 size="lg"
298 className="w-full"
317 <p className="text-xs text-neutral-500 font-light -mt-2">
318 Must be at least 8 characters.
319 </p>
320 <button
299321 type="submit"
300322 disabled={loading || !firstName || !email || !password}
323 className="w-full text-xs tracking-[0.18em] uppercase border border-neutral-900 text-neutral-900 px-5 py-3.5 rounded-full hover:bg-neutral-900 hover:text-[#f5f4ef] transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-900"
301324 >
302 {loading ? "Creating account..." : "Create Account"}
303 </Button>
304 </Box>
325 {loading ? "Creating account…" : "Create account"}
326 </button>
327 </form>
305328 );
306329}
307330
308EmailRegistration.displayName = "EmailRegistration";
331function FieldLabel({
332 htmlFor,
333 children,
334}: {
335 htmlFor: string;
336 children: React.ReactNode;
337}): React.ReactElement {
338 return (
339 <label
340 htmlFor={htmlFor}
341 className="block text-xs tracking-[0.18em] uppercase text-neutral-500 mb-1"
342 >
343 {children}
344 </label>
345 );
346}
Addeddocs/CRONTECH-ONBOARDING.md+216−0View fileUnifiedSplit
@@ -0,0 +1,216 @@
1# AlecRae → Crontech Onboarding Runbook
2
3> Operator runbook for deploying AlecRae onto the Crontech platform.
4> Crontech is the *runtime*. AlecRae remains its own product, its own brand,
5> its own customers, its own revenue. Hosting choice ≠ product identity.
6
7---
8
9## Prerequisites
10
11Before starting:
12
13- [ ] Crontech admin account (access to `https://crontech.ai/admin/onboard`)
14- [ ] Neon Postgres project provisioned, connection strings on hand
15- [ ] Upstash Redis instance provisioned
16- [ ] Stripe account in live mode, plan price IDs created
17- [ ] Anthropic API key (production tier)
18- [ ] OpenAI API key (Whisper)
19- [ ] Google Cloud OAuth client (Gmail sync)
20- [ ] Microsoft Azure app (Outlook / Graph)
21- [ ] Cloudflare R2 bucket + access keys
22- [ ] DNS access to `alecrae.com` (Cloudflare)
23- [ ] DKIM keypair generated for `mail.alecrae.com`
24
25If any of the above are missing, stop and provision them first. The wizard is
26purely an env-mapping tool — it cannot create accounts upstream.
27
28---
29
30## Step 1 — Prepare the input env file
31
32A pre-populated template lives at:
33
34```
35infrastructure/crontech/.env.alecrae.tenant.template
36```
37
381. Copy the template to a working file outside the repo (do **not** commit
39 real secrets):
40 ```
41 cp infrastructure/crontech/.env.alecrae.tenant.template ~/alecrae.env.tmp
42 ```
432. Replace every `<angle_bracket>` placeholder with the real value.
443. Generate the random secrets:
45 ```
46 openssl rand -base64 64 # JWT_SECRET, SESSION_SECRET, COLLAB_JWT_SECRET, DEPLOY_AGENT_SECRET
47 openssl rand -hex 32 # WEBHOOK_SECRET, INBOUND_WEBHOOK_SECRET
48 ```
494. Verify the file with a quick scan — no `<…>` placeholders left.
50
51---
52
53## Step 2 — Run the wizard
54
551. Sign in to `https://crontech.ai/admin/onboard` with an admin account.
562. **Paste config** — paste the full contents of `~/alecrae.env.tmp` plus the
57 project name (`alecrae`).
583. **Analyse** — wait for the client-side mapping to complete. The wizard
59 never sends values off your machine.
604. **Review** — expect the following:
61 - **Auto-mapped (accept):** `AUTH_SECRET → SESSION_SECRET`,
62 `NEXT_PUBLIC_STRIPE_KEY → STRIPE_PUBLISHABLE_KEY`,
63 `OPENAI_KEY → OPENAI_API_KEY`, `ANTHROPIC_KEY → ANTHROPIC_API_KEY`.
64 - **Auto-mapped (REJECT for AlecRae):** `DATABASE_URL → TURSO_DATABASE_URL`.
65 AlecRae uses Postgres on Neon (Drizzle Postgres schema); Turso is
66 SQLite-flavored and incompatible with our schema. Keep `DATABASE_URL`
67 as-is. Crontech runtime supports Postgres via Neon directly.
68 - **Legacy-vendor email mappings — N/A:** the wizard offers mappings from
69 legacy transactional-email vendors into `ALECRAE_API_KEY`. Those don't
70 apply to us — AlecRae **is** the email service in Crontech's stack.
71 Skip; do not let the wizard flag those keys as missing.
72 - **Crontech required vars to confirm:** `JWT_SECRET`, `SESSION_SECRET`,
73 `ANTHROPIC_API_KEY`, `DEPLOY_AGENT_SECRET` — all present in our template.
74 - **Crontech required vars NOT applicable:** `TURSO_DATABASE_URL`,
75 `TURSO_AUTH_TOKEN` — leave blank or fill with placeholders; AlecRae
76 does not use Turso. If the wizard refuses to proceed without them,
77 create an empty Turso instance and paste its credentials — AlecRae's
78 code will never connect to it.
795. **Export** — download:
80 - `.env.crontech` — pre-filled production env, paste into Crontech's
81 env management surface (or `/opt/crontech/.env` per Crontech's
82 deploy-agent convention).
83 - `crontech-migration-checklist.md` — keep alongside this runbook.
84
85---
86
87## Step 3 — Register the AlecRae tenant
88
89The wizard does **not** write to the DB. Tenant registration is a separate
90admin step (per Crontech's spec).
91
921. From `https://crontech.ai/admin`, create a new tenant project named
93 `alecrae`.
942. Connect the source repo: `https://github.com/ccantynz-alt/AlecRae.com`
95 (or `https://gluecron.com/ccantynz-alt/AlecRae.com` if `GLUECRON_GIT_BASE_URL`
96 is set on the Crontech instance).
973. Configure deploy targets — AlecRae has multiple processes:
98 | Process | Path | Build | Start | Port |
99 |---|---|---|---|---|
100 | web (marketing) | `apps/web` | `bun install && bun run build` | `bun run start` | 3000 |
101 | api | `apps/api` | `bun install && bun run build` | `bun run start` | 3001 |
102 | admin | `apps/admin` | `bun install && bun run build` | `bun run start` | 3002 |
103 | collab | `services/collab` | `bun install` | `bun run start` | 4001 |
104 | mta | `services/mta` | `bun install` | `bun run start` | 25 (raw TCP) |
105
106 ⚠️ **MTA caveat:** the MTA needs raw TCP port 25 + persistent disk for
107 the queue + DKIM keys mounted. If Crontech's deploy-agent is HTTP-only,
108 keep `services/mta` on Fly.io and only deploy web/api/admin/collab to
109 Crontech. The MTA's only contract with the rest of the system is via
110 Postgres + Redis, so split deployment is safe.
111
1124. Paste the wizard's `.env.crontech` output into Crontech's env management.
1135. Register webhooks:
114 - Stripe webhook URL: `https://api.alecrae.com/billing/webhook` (set in
115 Stripe dashboard, paste the resulting `whsec_…` into `STRIPE_WEBHOOK_SECRET`).
116 - Google OAuth redirect: `https://api.alecrae.com/auth/google/callback`
117 (Google Cloud console, OAuth client → Authorized redirect URIs).
118 - Microsoft OAuth redirect: `https://api.alecrae.com/auth/microsoft/callback`
119 (Azure portal, App registrations → Redirect URIs).
120
121---
122
123## Step 4 — DNS cutover
124
125Point AlecRae domains at Crontech. From Cloudflare DNS for `alecrae.com`:
126
127| Subdomain | Type | Target | Proxy |
128|---|---|---|---|
129| `alecrae.com` (apex) | A/AAAA | Crontech edge IPs | proxied |
130| `mail.alecrae.com` | CNAME | Crontech tenant URL | proxied |
131| `api.alecrae.com` | CNAME | Crontech tenant URL | proxied |
132| `admin.alecrae.com` | CNAME | Crontech tenant URL | proxied |
133| `collab.alecrae.com` | CNAME | Crontech tenant URL | proxied |
134| `mx1.alecrae.com` | A | MTA host (Fly.io if split) | **NOT proxied** |
135| `mx2.alecrae.com` | A | MTA host secondary | **NOT proxied** |
136| `smtp.alecrae.com` | A | MTA host | **NOT proxied** |
137
138Email auth records (already specced in `infrastructure/cloudflare/`):
139
140- SPF: `v=spf1 mx ~all`
141- DKIM: `default._domainkey` TXT with public key from `DKIM_PRIVATE_KEY`
142- DMARC: `_dmarc` TXT, `v=DMARC1; p=quarantine; rua=mailto:dmarc@alecrae.com`
143
144---
145
146## Step 5 — Run database migrations
147
148Crontech doesn't run migrations on deploy. From a workstation with
149`DATABASE_URL` pointing at production Neon:
150
151```
152bun run db:migrate
153```
154
155Verify with `bun run db:studio` against the production URL.
156
157---
158
159## Step 6 — Smoke tests
160
161After Crontech reports healthy:
162
163- [ ] `https://alecrae.com` — landing page renders (ivory + Italianno)
164- [ ] `https://alecrae.com/login` — auth page renders, passkey button visible
165- [ ] `https://api.alecrae.com/health` — returns `{ ok: true }`
166- [ ] `https://api.alecrae.com/v1/send` — Crontech-compatible send endpoint
167 reachable (auth-protected, expect 401 without bearer)
168- [ ] `https://admin.alecrae.com` — admin login renders
169- [ ] Stripe webhook test event — confirm event lands in `billing_events` table
170- [ ] Send a test email through MTA — DKIM passes at mail-tester.com
171- [ ] Receive an inbound test email — appears in test inbox
172- [ ] Google OAuth flow — Gmail account links cleanly
173- [ ] Microsoft OAuth flow — Outlook account links cleanly
174
175If any step fails, **do not** flip DNS for `alecrae.com` apex yet. Keep the
176Vercel preview URL as the public face until smoke is fully green.
177
178---
179
180## Rollback
181
182If anything is broken post-cutover:
183
1841. Re-point DNS back to Vercel (TTL is 5 min on Cloudflare proxied records).
1852. Pause the Crontech tenant deployment.
1863. Open a postmortem in `docs/postmortems/`.
187
188The Neon DB and Redis instance are independent of where compute runs —
189rolling back compute does not lose data.
190
191---
192
193## What stays standalone
194
195Crontech is hosting infrastructure — not a product merger. The following
196remain 100% AlecRae:
197
198- Brand: `alecrae.com`, AlecRae wordmark, Italianno script, ivory palette
199- Product: 84 features across 8 tiers, all owned by AlecRae
200- Pricing: Free / $9 / $19 / $12pp / Enterprise — set by AlecRae
201- Billing: Stripe account in AlecRae's name, revenue flows to AlecRae
202- Users: AlecRae's user table, AlecRae's auth, AlecRae's data
203- Marketing: `alecrae.com` is the public face, not `crontech.ai`
204- Source code: this repo, `ccantynz-alt/AlecRae.com`
205
206If a customer asks "what is this email service?", the answer is "AlecRae."
207The fact that it runs on Crontech is an implementation detail — the same
208way Gmail running on Google Cloud doesn't make Gmail "Google Cloud."
209
210---
211
212## References
213
214- Crontech onboarding spec: pasted in session 019uxxTpXVK4rw9XzatpX2hs
215- Production env audit: `docs/infra/.env.production.template`
216- AlecRae architecture: `CLAUDE.md` (the Bible)
Addedinfrastructure/crontech/.env.alecrae.tenant.template+218−0View fileUnifiedSplit
@@ -0,0 +1,218 @@
1# =============================================================================
2# AlecRae on Crontech — Tenant .env Template
3# =============================================================================
4# Paste this file into the Crontech onboarding wizard at:
5# https://crontech.ai/admin/onboard
6#
7# The wizard runs ENTIRELY client-side. Values never leave the browser.
8# It will:
9# 1. Auto-map known keys (DATABASE_URL → TURSO_DATABASE_URL, etc.)
10# 2. Flag missing required Crontech vars
11# 3. Output: .env.crontech + crontech-migration-checklist.md
12#
13# IMPORTANT — AlecRae-specific overrides AFTER the wizard:
14# - AlecRae uses Neon Postgres (Drizzle ORM, Postgres-flavored schema).
15# The wizard will suggest mapping DATABASE_URL → TURSO_DATABASE_URL.
16# KEEP DATABASE_URL pointing at Neon. Crontech runtime supports both.
17# Discard or ignore the TURSO_* mapping for the AlecRae tenant.
18# - The MTA (services/mta) needs port 25 + persistent disk + DKIM key on
19# bare-metal — confirm Crontech's deploy-agent supports long-lived
20# processes with raw TCP, or keep MTA on its existing Fly.io microVM.
21#
22# Replace every <angle_bracket> placeholder before pasting. Generate secrets
23# with: openssl rand -base64 64
24# =============================================================================
25
26
27# -----------------------------------------------------------------------------
28# Core Runtime
29# -----------------------------------------------------------------------------
30NODE_ENV=production
31LOG_LEVEL=info
32PORT=3000
33
34
35# -----------------------------------------------------------------------------
36# Database — Neon Postgres (NOT Turso — see header note)
37# -----------------------------------------------------------------------------
38DATABASE_URL=<neon_pooled_connection_string>
39DIRECT_URL=<neon_direct_connection_string>
40
41
42# -----------------------------------------------------------------------------
43# Cache / Queue — Upstash Redis
44# -----------------------------------------------------------------------------
45REDIS_URL=<upstash_redis_url>
46UPSTASH_REDIS_REST_URL=<upstash_rest_url>
47UPSTASH_REDIS_REST_TOKEN=<upstash_rest_token>
48
49
50# -----------------------------------------------------------------------------
51# Auth — JWT + Sessions
52# Wizard will auto-map: AUTH_SECRET → SESSION_SECRET (we set both for safety).
53# Generate each with: openssl rand -base64 64
54# -----------------------------------------------------------------------------
55JWT_SECRET=<random_64_byte_base64>
56SESSION_SECRET=<random_64_byte_base64>
57JWT_ISSUER=alecrae
58JWT_AUDIENCE=alecrae-collab
59
60# RSA keypair for asymmetric JWT signing (multi-line PEM)
61JWT_PUBLIC_KEY=<pem_public_key>
62JWT_PRIVATE_KEY=<pem_private_key>
63
64
65# -----------------------------------------------------------------------------
66# Auth — WebAuthn / Passkeys
67# -----------------------------------------------------------------------------
68WEBAUTHN_RP_ID=alecrae.com
69WEBAUTHN_RP_NAME=AlecRae
70WEBAUTHN_ORIGIN=https://mail.alecrae.com
71
72
73# -----------------------------------------------------------------------------
74# Crontech-required (wizard will flag if missing)
75# -----------------------------------------------------------------------------
76DEPLOY_AGENT_SECRET=<random_64_byte_base64>
77
78
79# -----------------------------------------------------------------------------
80# App URLs
81# -----------------------------------------------------------------------------
82NEXT_PUBLIC_APP_URL=https://mail.alecrae.com
83NEXT_PUBLIC_API_URL=https://api.alecrae.com
84NEXT_PUBLIC_WS_URL=wss://api.alecrae.com
85NEXT_PUBLIC_MARKETING_URL=https://alecrae.com
86ALECRAE_APP_URL=https://mail.alecrae.com
87
88
89# -----------------------------------------------------------------------------
90# AI — Anthropic Claude (REQUIRED — core product)
91# -----------------------------------------------------------------------------
92ANTHROPIC_API_KEY=<sk-ant-api03-xxxxx>
93
94
95# -----------------------------------------------------------------------------
96# AI — OpenAI (Whisper transcription)
97# -----------------------------------------------------------------------------
98OPENAI_API_KEY=<sk-proj-xxxxx>
99
100
101# -----------------------------------------------------------------------------
102# AI — Voyage (embeddings, semantic search)
103# OPTIONAL — disables semantic search if absent
104# -----------------------------------------------------------------------------
105VOYAGE_API_KEY=<pa-xxxxx>
106
107
108# -----------------------------------------------------------------------------
109# Payments — Stripe
110# Wizard will auto-map: NEXT_PUBLIC_STRIPE_KEY / STRIPE_PUBLIC_KEY →
111# STRIPE_PUBLISHABLE_KEY
112# -----------------------------------------------------------------------------
113STRIPE_SECRET_KEY=<sk_live_xxxxx>
114STRIPE_WEBHOOK_SECRET=<whsec_xxxxx>
115NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<pk_live_xxxxx>
116STRIPE_PRICE_STARTER=<price_id_personal_9>
117STRIPE_PRICE_PROFESSIONAL=<price_id_pro_19>
118STRIPE_PRICE_ENTERPRISE=<price_id_enterprise>
119
120
121# -----------------------------------------------------------------------------
122# OAuth — Google (Gmail sync + SSO)
123# -----------------------------------------------------------------------------
124GOOGLE_CLIENT_ID=<xxxxx.apps.googleusercontent.com>
125GOOGLE_CLIENT_SECRET=<GOCSPX-xxxxx>
126GOOGLE_REDIRECT_URI=https://api.alecrae.com/auth/google/callback
127
128
129# -----------------------------------------------------------------------------
130# OAuth — Microsoft (Outlook / Graph API)
131# -----------------------------------------------------------------------------
132MICROSOFT_CLIENT_ID=<azure_app_client_id>
133MICROSOFT_CLIENT_SECRET=<azure_app_client_secret>
134MICROSOFT_TENANT_ID=common
135MICROSOFT_REDIRECT_URI=https://api.alecrae.com/auth/microsoft/callback
136
137
138# -----------------------------------------------------------------------------
139# MTA — Outbound SMTP + DKIM signing
140# NOTE: needs raw TCP port 25 + persistent disk. If Crontech deploy-agent
141# does not support that, leave MTA on Fly.io and only deploy api/web/admin
142# to Crontech.
143# -----------------------------------------------------------------------------
144MTA_HOSTNAME=mx1.alecrae.com
145MTA_QUEUE_NAME=alecrae:outbound
146MTA_WORKER_CONCURRENCY=10
147DKIM_SELECTOR=default
148DKIM_PRIVATE_KEY=<pem_private_key>
149
150POSTMASTER_EMAIL=postmaster@alecrae.com
151ABUSE_EMAIL=abuse@alecrae.com
152DMARC_EMAIL=dmarc@alecrae.com
153
154# Optional outbound relay (instead of direct MX delivery)
155# RELAY_PROVIDER=ses
156# SES_SMTP_HOST=email-smtp.us-east-1.amazonaws.com
157# SES_SMTP_PORT=587
158# SES_SMTP_USERNAME=<ses_username>
159# SES_SMTP_PASSWORD=<ses_password>
160# SES_REGION=us-east-1
161
162
163# -----------------------------------------------------------------------------
164# Search — Meilisearch
165# -----------------------------------------------------------------------------
166MEILI_URL=https://search.alecrae.com
167MEILI_MASTER_KEY=<meili_master_key>
168MEILISEARCH_URL=https://search.alecrae.com
169MEILISEARCH_API_KEY=<meili_master_key>
170
171
172# -----------------------------------------------------------------------------
173# Storage — Cloudflare R2
174# -----------------------------------------------------------------------------
175R2_ACCESS_KEY_ID=<r2_access_key_id>
176R2_SECRET_ACCESS_KEY=<r2_secret_access_key>
177R2_BUCKET=alecrae-attachments
178R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
179
180
181# -----------------------------------------------------------------------------
182# Collab (Yjs real-time drafting — S2 feature)
183# -----------------------------------------------------------------------------
184COLLAB_WS_URL=wss://collab.alecrae.com
185COLLAB_HTTP_URL=https://collab.alecrae.com
186COLLAB_JWT_SECRET=<random_64_byte_base64_or_reuse_JWT_SECRET>
187
188
189# -----------------------------------------------------------------------------
190# Webhooks
191# -----------------------------------------------------------------------------
192WEBHOOK_SECRET=<random_32_byte_hex>
193INBOUND_WEBHOOK_SECRET=<random_32_byte_hex>
194TRACKING_BASE_URL=https://track.alecrae.com
195
196
197# -----------------------------------------------------------------------------
198# Enterprise — SAML SSO (Team/Enterprise plans only)
199# OPTIONAL — disables SAML login if absent
200# -----------------------------------------------------------------------------
201SAML_ENTITY_ID=https://admin.alecrae.com/saml/metadata
202SAML_ACS_URL=https://admin.alecrae.com/saml/acs
203SAML_CERT=<pem_x509_cert>
204SAML_PRIVATE_KEY=<pem_private_key>
205
206
207# -----------------------------------------------------------------------------
208# Observability — OpenTelemetry
209# -----------------------------------------------------------------------------
210OTEL_ENABLED=true
211OTEL_EXPORTER_OTLP_ENDPOINT=<otlp_collector_url>
212OTEL_SERVICE_NAME=alecrae-api
213
214
215# -----------------------------------------------------------------------------
216# CORS
217# -----------------------------------------------------------------------------
218CORS_ORIGINS=https://alecrae.com,https://mail.alecrae.com,https://admin.alecrae.com
0219
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts