CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

docs: add launch readiness audit and email sending runbook #4089

Merged⚡ AI-generatedXLccantynz wants to mergeclaude/launch-readiness-checklist-lVjmhclaude/build-email-service-3R7eoopened May 8, 20264/4 tasks
31 changed files+2851−36
Modified.env.example+7−0View fileUnifiedSplit
9898POSTGRES_PASSWORD=dev_password
9999MINIO_ROOT_USER=alecrae
100100MINIO_ROOT_PASSWORD=dev_password
101
102# -- Admin (admin.alecrae.com) login ---------------------------------------
103# Generate all three values at once with: bun run scripts/generate-admin-hash.ts
104# Set them in Vercel → admin project → Settings → Environment Variables.
105# ADMIN_EMAIL=you@alecrae.com
106# ADMIN_PASSWORD_HASH=scrypt$16384$8$1$<saltHex>$<keyHex>
107# ADMIN_SESSION_SECRET=<32+ random hex bytes>
Modified.env.production+7−0View fileUnifiedSplit
109109CLOUDFLARE_ACCOUNT_ID=YOUR_CF_ACCOUNT_ID
110110CLOUDFLARE_API_TOKEN=YOUR_CF_API_TOKEN
111111CLOUDFLARE_ZONE_ID=YOUR_ALECRAE_COM_ZONE_ID
112
113# ─── Admin login (admin.alecrae.com) ──────────────────────────────────────────
114# Generate all three at once with: bun run scripts/generate-admin-hash.ts
115# Required only on the apps/admin Vercel project (not the API).
116ADMIN_EMAIL=YOUR_ADMIN_EMAIL
117ADMIN_PASSWORD_HASH=YOUR_SCRYPT_HASH_FROM_SCRIPT
118ADMIN_SESSION_SECRET=YOUR_RANDOM_32_BYTE_HEX
ModifiedCLAUDE.md+11−8View fileUnifiedSplit
586586| 26 | No WCAG / EAA / ADA accessibility statement | MEDIUM | 2026-04-16 | FIXED 2026-04-16 — /accessibility page (WCAG 2.2 AA target, EAA, ADA, Section 508) |
587587| 27 | Missing Impressum, Children's Privacy, Refund, Corporate Compliance pages | HIGH | 2026-04-16 | FIXED 2026-04-16 — 4 new pages (TMG §5, COPPA, EU 14-day withdrawal, FCPA/Modern Slavery/OFAC) |
588588| 28 | Domain inconsistency — 32x `alecrae.dev` emails in legal copy should be `alecrae.com` | MEDIUM | 2026-04-16 | FIXED 2026-04-16 — normalised across terms/privacy/aup/dmca/dpa/sla/cookies/subprocessors/domains |
589| 29 | alecrae.com returning Cloudflare 521 — apex+www A records pointed at dead Vultr IP `45.76.171.37` with proxy ON | CRITICAL | 2026-05-08 | FIXED 2026-05-08 — repointed to Vercel `76.76.21.21` apex + `cname.vercel-dns.com` www, proxy off (DNS only). Site live, HTTP 200 confirmed. |
590| 30 | Admin had no real login — only SAML SSO scaffolding + a localStorage API-key tab that never validated server-side | HIGH | 2026-05-08 | FIXED 2026-05-08 — env-var-backed email+password auth (scrypt + HMAC-signed session cookie + edge middleware gate). `bun run scripts/generate-admin-hash.ts` generates hash locally; ADMIN_EMAIL/ADMIN_PASSWORD_HASH/ADMIN_SESSION_SECRET stored only in Vercel env. No new deps. |
591| 31 | API Key login tab on admin still uses localStorage with no server validation | LOW | 2026-05-08 | NOTED — superseded by password login above; tab can be removed in a follow-up PR. |
589592
590593---
591594
615618
616619The platform's moat is the AI flywheel — every user action makes every AI feature better, and the data that compounds cannot be cloned by Gmail or Outlook. The signals + learning cycles are wired in `.ai-flywheel/config.json`. What's missing is **visibility, virality, and instrumentation**. These are the highest-leverage next builds AFTER the email-sending stack is operational:
617620
618| # | Item | Why | Effort |
621| # | Item | Why | Status |
619622|---|---|---|---|
620| F1 | **"Your AlecRae" page** — voice-profile confidence over time, drafts accepted, time saved, words AI has learned | Without it, users can't see the wheel turning, which kills retention narrative + marketing screenshots | ~2 days |
621| F2 | **Voice-primed referral loop** — "Invite a contact → they get 3 months free, you get 1 month free, AND their voice profile is primed from the emails you've already exchanged" | Ties acquisition to the moat; Gmail/Superhuman literally cannot ship the parenthetical | ~1 week |
622| F3 | **AlecRae↔AlecRae network features** — real-time read/draft state, "Sarah is replying", presence in compose, calendar slot proposals that auto-resolve when both ends are on AlecRae | Turns AlecRae from "a client" into "a platform" — each new user makes existing users' product better | ~2 weeks |
623| F4 | **Flywheel instrumentation** — wire `.ai-flywheel/config.json` signals to ClickHouse + surface weekly RPM in `/admin` | Right now the wheel turns but we can't measure it. Need weekly compose-acceptance, triage-accuracy, voice-edit-distance trend lines | ~3 days |
623| F1 | **"Your AI" page** — voice-profile confidence over time, drafts accepted, time saved, words AI has learned | Without it, users can't see the wheel turning, which kills retention narrative + marketing screenshots | ✅ DONE 2026-05-08 — `/your-ai` ships pre-launch with placeholder state, lights up automatically when signals flow |
624| F2 | **Voice-primed referral loop** — "Invite a contact → they get 3 months free, you get 1 month free, AND their voice profile is primed from the emails you've already exchanged" | Ties acquisition to the moat; Gmail/Superhuman literally cannot ship the parenthetical | ⏳ ~1 week (next) |
625| F3 | **AlecRae↔AlecRae network features** — real-time read/draft state, "Sarah is replying", presence in compose, calendar slot proposals that auto-resolve when both ends are on AlecRae | Turns AlecRae from "a client" into "a platform" — each new user makes existing users' product better | ⏳ ~2 weeks |
626| F4 | **Flywheel instrumentation** — typed signal pipeline + aggregator + admin RPM dashboard | Right now the wheel turns but we couldn't measure it. Now we can: 8 metrics, weekly trends, composite RPM. | ✅ DONE 2026-05-08 — `packages/flywheel` + `flywheel_signals` table + `/v1/flywheel/{signal,metrics,me,global}` + `/admin/flywheel` + 8 unit tests |
624627
625**Build order:** F4 → F1 → F2 → F3. Instrument first so we know the wheel is real, then make it visible, then tie it to virality, then to network effects. None of these block launch — but all four together are what turns AlecRae into a $2.5M MRR exit instead of a $10M lifestyle business.
628**Build order:** F4 → F1 → F2 → F3. Instrument first so we know the wheel is real, then make it visible, then tie it to virality, then to network effects. **F4 + F1 shipped 2026-05-08.** F2 and F3 next, in order.
626629
627630---
628631
699702
700703## 📅 STATUS
701704
702**Date last updated:** 2026-04-16
705**Date last updated:** 2026-05-08
703706**Current phase:** Phase 1 — Launch Imminent
704707**Current focus:** Build 100% clean (29/29 static pages). Legal compliance merged (9 new legal pages, consent banner, GDPR/CCPA/EU AI Act coverage, RFC 9116 security.txt). Landing page rewritten, admin console live. CI hardened. All code complete — blocked only on Craig's infra (Neon, Upstash, Stripe, API keys, DNS, Crontec deploy).
705708**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 + full legal compliance surface
779782| C3 | **Admin console SSO** | SAML for enterprise sales | DONE |
780783| C4 | **SOC 2 Type I → Type II** | Required for enterprise | NOT STARTED |
781784| C5 | **GDPR DPA template** | Legal pages exist, need DPA workflow | NOT STARTED |
782| C6 | **Bug bounty program** | HackerOne or Intigriti | NOT STARTED |
785| C6 | **Bug bounty program** | HackerOne or Intigriti | ✅ DONE 2026-05-08 — `/security/bounty` page with full scope, severity tiers, USD reward bands, rules of engagement, and CFAA/Computer-Misuse-Act safe harbour. Linked from /security and /.well-known/security.txt. |
783786| C7 | **Public roadmap** | Trello/Linear public board | NOT STARTED |
784787| C8 | **Changelog page** | changelog.alecrae.com | DONE |
785788| C9 | **Migration guides** | "From Gmail to AlecRae in 5 minutes" | DONE |
Addedapps/admin/app/api/login/route.ts+47−0View fileUnifiedSplit
1/**
2 * POST /api/login — verify admin email + password and set session cookie.
3 *
4 * Returns 200 with `{ ok: true }` on success and a Set-Cookie header.
5 * Returns 401 with `{ ok: false, reason }` on failure.
6 * Returns 503 if env vars not configured.
7 */
8
9import { NextResponse } from "next/server";
10import { attemptLogin } from "../../../lib/auth-password";
11
12export const runtime = "nodejs";
13
14interface LoginBody {
15 readonly email?: unknown;
16 readonly password?: unknown;
17}
18
19export async function POST(req: Request): Promise<Response> {
20 let body: LoginBody;
21 try {
22 body = (await req.json()) as LoginBody;
23 } catch {
24 return NextResponse.json(
25 { ok: false, reason: "invalid_request" },
26 { status: 400 },
27 );
28 }
29
30 if (typeof body.email !== "string" || typeof body.password !== "string") {
31 return NextResponse.json(
32 { ok: false, reason: "invalid_request" },
33 { status: 400 },
34 );
35 }
36
37 const result = attemptLogin(body.email, body.password);
38
39 if (!result.ok) {
40 const status = result.reason === "not_configured" ? 503 : 401;
41 return NextResponse.json({ ok: false, reason: result.reason }, { status });
42 }
43
44 const res = NextResponse.json({ ok: true }, { status: 200 });
45 res.headers.set("Set-Cookie", result.cookie);
46 return res;
47}
Addedapps/admin/app/api/logout/route.ts+14−0View fileUnifiedSplit
1/**
2 * POST /api/logout — clear the admin session cookie.
3 */
4
5import { NextResponse } from "next/server";
6import { clearSessionCookie } from "../../../lib/auth-password";
7
8export const runtime = "nodejs";
9
10export async function POST(): Promise<Response> {
11 const res = NextResponse.json({ ok: true }, { status: 200 });
12 res.headers.set("Set-Cookie", clearSessionCookie());
13 return res;
14}
Addedapps/admin/app/flywheel/page.tsx+249−0View fileUnifiedSplit
1"use client";
2
3/**
4 * /admin/flywheel — internal RPM dashboard.
5 *
6 * Polls /v1/flywheel/global for the cross-account snapshot. Renders:
7 * - Composite "Wheel RPM" headline
8 * - One tile per metric with target gap + 12-week sparkline
9 * - "No data yet" empty state pre-launch (the wheel is built but not spun)
10 *
11 * Read-only. Rendering only — all aggregation happens server-side.
12 */
13
14import { useCallback, useEffect, useState } from "react";
15import { Box, Text } from "@alecrae/ui";
16import type { FlywheelMetric, FlywheelSnapshot } from "@alecrae/flywheel";
17
18const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
19const REFRESH_MS = 60_000;
20
21interface LoadState {
22 readonly status: "loading" | "ready" | "offline" | "error";
23 readonly snapshot: FlywheelSnapshot | null;
24 readonly error: string | null;
25}
26
27const INITIAL: LoadState = { status: "loading", snapshot: null, error: null };
28
29function formatRate(v: number): string {
30 return `${(v * 100).toFixed(1)}%`;
31}
32
33function formatEditDistance(v: number): string {
34 return v.toFixed(3);
35}
36
37function formatMetric(m: FlywheelMetric): string {
38 if (m.unit === "rate") return formatRate(m.value);
39 if (m.unit === "edit_distance") return formatEditDistance(m.value);
40 if (m.unit === "ms") return `${Math.round(m.value)}ms`;
41 return Math.round(m.value).toLocaleString();
42}
43
44function formatTarget(m: FlywheelMetric): string {
45 const t =
46 m.unit === "rate"
47 ? formatRate(m.target)
48 : m.unit === "edit_distance"
49 ? formatEditDistance(m.target)
50 : m.unit === "ms"
51 ? `${Math.round(m.target)}ms`
52 : Math.round(m.target).toString();
53 return m.direction === "minimize" ? `≤ ${t}` : `≥ ${t}`;
54}
55
56function trendStrokeFor(direction: "maximize" | "minimize"): string {
57 return direction === "minimize" ? "#dc2626" : "#16a34a";
58}
59
60interface SparklineProps {
61 readonly points: readonly { value: number; sampleSize: number }[];
62 readonly stroke: string;
63 readonly normalize: number;
64}
65
66function Sparkline({ points, stroke, normalize }: SparklineProps): React.ReactElement {
67 const width = 160;
68 const height = 40;
69 const pad = 2;
70 if (points.length === 0) {
71 return (
72 <Box className="text-content-tertiary text-xs italic">No data yet</Box>
73 );
74 }
75 const xs = (i: number): number =>
76 pad + (i * (width - 2 * pad)) / Math.max(1, points.length - 1);
77 const ys = (v: number): number => {
78 const clamped = Math.max(0, Math.min(normalize, v));
79 return height - pad - (clamped * (height - 2 * pad)) / Math.max(0.0001, normalize);
80 };
81 const path = points
82 .map((p, i) => `${i === 0 ? "M" : "L"} ${xs(i).toFixed(1)} ${ys(p.value).toFixed(1)}`)
83 .join(" ");
84 return (
85 <Box
86 as="svg"
87 className="block"
88 width={width}
89 height={height}
90 role="img"
91 aria-label="12 week trend"
92 >
93 <Box as="path" d={path} fill="none" stroke={stroke} strokeWidth={1.5} />
94 </Box>
95 );
96}
97
98function MetricTile({ metric }: { metric: FlywheelMetric }): React.ReactElement {
99 const samples = metric.trend.reduce((s, p) => s + p.sampleSize, 0);
100 const onTarget =
101 metric.direction === "maximize"
102 ? metric.value >= metric.target
103 : metric.value <= metric.target;
104 const stroke = trendStrokeFor(metric.direction);
105 const normalize =
106 metric.unit === "rate" ? 1 : metric.unit === "edit_distance" ? 0.5 : Math.max(...metric.trend.map((p) => p.value), 1);
107
108 return (
109 <Box className="rounded-xl border border-border bg-surface-secondary p-5 flex flex-col gap-3">
110 <Box className="flex items-start justify-between gap-3">
111 <Box>
112 <Text variant="body-sm" className="text-content-secondary">
113 {metric.label}
114 </Text>
115 <Box className="mt-1 flex items-baseline gap-2">
116 <Text variant="heading-md" className="font-bold text-content">
117 {samples === 0 ? "—" : formatMetric(metric)}
118 </Text>
119 <Text variant="caption" className="text-content-tertiary">
120 target {formatTarget(metric)}
121 </Text>
122 </Box>
123 </Box>
124 <Box
125 className={`rounded-full px-2 py-1 text-xs font-medium border ${
126 samples === 0
127 ? "bg-slate-500/10 text-slate-500 border-slate-500/30"
128 : onTarget
129 ? "bg-green-500/10 text-green-600 border-green-500/30"
130 : "bg-amber-500/10 text-amber-600 border-amber-500/30"
131 }`}
132 >
133 {samples === 0 ? "no data" : onTarget ? "on target" : "below target"}
134 </Box>
135 </Box>
136 <Sparkline points={metric.trend.map((p) => ({ value: p.value, sampleSize: p.sampleSize }))} stroke={stroke} normalize={normalize} />
137 <Text variant="caption" className="text-content-tertiary">
138 {samples.toLocaleString()} samples · 12 weeks
139 </Text>
140 </Box>
141 );
142}
143
144export default function FlywheelPage(): React.ReactElement {
145 const [state, setState] = useState<LoadState>(INITIAL);
146
147 const load = useCallback(async (): Promise<void> => {
148 try {
149 const res = await fetch(`${API_BASE}/v1/flywheel/global`, {
150 credentials: "include",
151 });
152 if (!res.ok) {
153 if (res.status === 503 || res.status === 0) {
154 setState({ status: "offline", snapshot: null, error: null });
155 return;
156 }
157 throw new Error(`API ${res.status}`);
158 }
159 const snapshot = (await res.json()) as FlywheelSnapshot;
160 setState({ status: "ready", snapshot, error: null });
161 } catch (err) {
162 const msg = err instanceof Error ? err.message : "Unknown error";
163 // Treat fetch failure (API not deployed yet) as offline, not error.
164 if (msg.includes("Failed to fetch") || msg.includes("NetworkError")) {
165 setState({ status: "offline", snapshot: null, error: null });
166 return;
167 }
168 setState({ status: "error", snapshot: null, error: msg });
169 }
170 }, []);
171
172 useEffect(() => {
173 void load();
174 const t = setInterval(() => void load(), REFRESH_MS);
175 return () => clearInterval(t);
176 }, [load]);
177
178 const rpmPct = state.snapshot ? Math.round(state.snapshot.rpm * 100) : 0;
179
180 return (
181 <Box className="min-h-screen bg-surface p-6 sm:p-10">
182 <Box className="max-w-6xl mx-auto flex flex-col gap-8">
183 <Box>
184 <Text variant="heading-lg" className="text-content font-bold">
185 Flywheel
186 </Text>
187 <Text variant="body-sm" className="text-content-secondary mt-1">
188 Composite RPM + per-metric trend over the last 12 weeks. Higher RPM = the AI moat is compounding faster than the competition can copy it.
189 </Text>
190 </Box>
191
192 {/* RPM headline */}
193 <Box className="rounded-2xl border border-border bg-surface-secondary p-8 flex flex-col gap-4">
194 <Text variant="caption" className="text-content-tertiary uppercase tracking-wide">
195 Wheel RPM
196 </Text>
197 <Box className="flex items-baseline gap-3">
198 <Text variant="heading-lg" className="text-content font-bold text-6xl">
199 {state.status === "ready" ? `${rpmPct}` : "—"}
200 </Text>
201 <Text variant="body-sm" className="text-content-secondary">
202 / 100
203 </Text>
204 </Box>
205 <Box className="rounded-full bg-surface h-2 overflow-hidden">
206 <Box
207 className="h-full bg-gradient-to-r from-amber-400 to-green-500"
208 style={{ width: `${rpmPct}%`, transition: "width 0.6s ease-out" }}
209 />
210 </Box>
211 <Text variant="caption" className="text-content-tertiary">
212 {state.status === "ready" && state.snapshot
213 ? `${state.snapshot.totalSignals.toLocaleString()} signals across ${state.snapshot.metrics.length} metrics · last refreshed ${new Date(state.snapshot.generatedAtIso).toLocaleTimeString()}`
214 : state.status === "offline"
215 ? "API offline — wheel is wired but not yet spinning. Provision Neon + deploy the API to start collecting signals."
216 : state.status === "error"
217 ? `Error: ${state.error}`
218 : "Loading…"}
219 </Text>
220 </Box>
221
222 {/* Metric tiles */}
223 <Box className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
224 {state.snapshot
225 ? state.snapshot.metrics.map((m) => <MetricTile key={m.key} metric={m} />)
226 : Array.from({ length: 8 }).map((_, i) => (
227 <Box
228 key={i}
229 className="rounded-xl border border-border bg-surface-secondary p-5 h-40 animate-pulse opacity-60"
230 />
231 ))}
232 </Box>
233
234 {/* Reading the wheel */}
235 <Box className="rounded-xl border border-border bg-surface-secondary p-6">
236 <Text variant="body-md" className="text-content font-semibold mb-2">
237 How to read this page
238 </Text>
239 <Box as="ul" className="text-content-secondary text-sm space-y-1.5 list-disc pl-5">
240 <Box as="li">RPM is a sample-weighted composite of every metric's score-against-target. 100 means every metric is at or past target.</Box>
241 <Box as="li">Green tiles = at or past target. Amber tiles = below target but data is flowing.</Box>
242 <Box as="li">Sparklines show 12 weeks of weekly aggregates — sample size is reported below each tile.</Box>
243 <Box as="li">"No data" is the expected state pre-launch. As soon as users start composing, triaging, and replying, the wheel begins spinning automatically.</Box>
244 </Box>
245 </Box>
246 </Box>
247 </Box>
248 );
249}
Modifiedapps/admin/app/login/page.tsx+147−5View fileUnifiedSplit
4141 );
4242}
4343
44type LoginMode = "sso" | "api-key";
44function MailIcon(): React.ReactElement {
45 return (
46 <Box
47 as="svg"
48 className="w-5 h-5"
49 viewBox="0 0 24 24"
50 fill="none"
51 stroke="currentColor"
52 strokeWidth="2"
53 strokeLinecap="round"
54 strokeLinejoin="round"
55 aria-hidden="true"
56 >
57 <Box as="path" d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
58 <Box as="polyline" points="22,6 12,13 2,6" />
59 </Box>
60 );
61}
62
63type LoginMode = "password" | "sso" | "api-key";
4564
4665export default function LoginPage(): React.ReactElement {
47 const [mode, setMode] = useState<LoginMode>("sso");
66 const [mode, setMode] = useState<LoginMode>("password");
67 const [email, setEmail] = useState("");
68 const [password, setPassword] = useState("");
4869 const [accountId, setAccountId] = useState("");
4970 const [apiKey, setApiKey] = useState("");
5071 const [error, setError] = useState<string | null>(null);
96117 window.location.href = "/";
97118 }, [apiKey]);
98119
120 const handlePasswordLogin = useCallback(async () => {
121 if (!email.trim() || !password) {
122 setError("Email and password are required");
123 return;
124 }
125
126 setLoading(true);
127 setError(null);
128
129 try {
130 const res = await fetch("/api/login", {
131 method: "POST",
132 headers: { "Content-Type": "application/json" },
133 body: JSON.stringify({ email: email.trim(), password }),
134 credentials: "same-origin",
135 });
136
137 if (res.ok) {
138 const next = new URLSearchParams(window.location.search).get("next");
139 window.location.href = next && next.startsWith("/") ? next : "/";
140 return;
141 }
142
143 let reason = "invalid_credentials";
144 try {
145 const body = (await res.json()) as { reason?: string };
146 if (typeof body.reason === "string") reason = body.reason;
147 } catch {
148 // ignore parse error, keep default reason
149 }
150
151 if (reason === "not_configured") {
152 setError(
153 "Admin login is not configured. Set ADMIN_EMAIL, ADMIN_PASSWORD_HASH, and ADMIN_SESSION_SECRET in your environment.",
154 );
155 } else {
156 setError("Email or password is incorrect.");
157 }
158 setLoading(false);
159 } catch (err: unknown) {
160 setError(err instanceof Error ? err.message : "Login failed");
161 setLoading(false);
162 }
163 }, [email, password]);
164
99165 const handleSubmit = useCallback(
100166 (e: React.FormEvent) => {
101167 e.preventDefault();
102168 if (mode === "sso") {
103169 void handleSsoLogin();
170 } else if (mode === "password") {
171 void handlePasswordLogin();
104172 } else {
105173 handleApiKeyLogin();
106174 }
107175 },
108 [mode, handleSsoLogin, handleApiKeyLogin],
176 [mode, handleSsoLogin, handlePasswordLogin, handleApiKeyLogin],
109177 );
110178
111179 return (
143211 role="tablist"
144212 aria-label="Login method"
145213 >
214 <Box
215 as="button"
216 role="tab"
217 aria-selected={mode === "password"}
218 onClick={() => {
219 setMode("password");
220 setError(null);
221 }}
222 className={`flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-md text-sm font-medium transition-colors ${
223 mode === "password"
224 ? "bg-surface text-content shadow-sm"
225 : "text-content-secondary hover:text-content"
226 }`}
227 >
228 <MailIcon />
229 <Text as="span" variant="body-sm" className="font-medium">
230 Email
231 </Text>
232 </Box>
146233 <Box
147234 as="button"
148235 role="tab"
159246 >
160247 <SsoIcon />
161248 <Text as="span" variant="body-sm" className="font-medium">
162 SSO / SAML
249 SSO
163250 </Text>
164251 </Box>
165252 <Box
201288 onSubmit={handleSubmit}
202289 className="rounded-xl bg-surface-secondary border border-border p-6"
203290 >
204 {mode === "sso" ? (
291 {mode === "password" ? (
292 <Box className="flex flex-col gap-4">
293 <Box>
294 <Text
295 as="label"
296 variant="body-sm"
297 className="text-content font-medium mb-1.5 block"
298 >
299 Email
300 </Text>
301 <Input
302 type="email"
303 value={email}
304 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
305 setEmail(e.target.value)
306 }
307 placeholder="you@alecrae.com"
308 aria-label="Admin email"
309 autoComplete="username"
310 className="w-full"
311 autoFocus
312 />
313 </Box>
314 <Box>
315 <Text
316 as="label"
317 variant="body-sm"
318 className="text-content font-medium mb-1.5 block"
319 >
320 Password
321 </Text>
322 <Input
323 type="password"
324 value={password}
325 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
326 setPassword(e.target.value)
327 }
328 placeholder="••••••••"
329 aria-label="Admin password"
330 autoComplete="current-password"
331 className="w-full"
332 />
333 <Text variant="caption" className="text-content-tertiary mt-1.5">
334 Single-admin login. Credentials are stored as env vars (ADMIN_EMAIL, ADMIN_PASSWORD_HASH) and never in the repo.
335 </Text>
336 </Box>
337 <Button
338 type="submit"
339 variant="primary"
340 disabled={loading || !email.trim() || !password}
341 className="w-full"
342 >
343 {loading ? "Signing in..." : "Sign in"}
344 </Button>
345 </Box>
346 ) : mode === "sso" ? (
205347 <Box className="flex flex-col gap-4">
206348 <Box>
207349 <Text
Addedapps/admin/lib/auth-edge.ts+90−0View fileUnifiedSplit
1/**
2 * Edge-runtime-safe session verification for Next.js middleware.
3 *
4 * Uses Web Crypto only (crypto.subtle). The Node-side library in
5 * `auth-password.ts` is the source of truth for issuing sessions; this
6 * module mirrors the verify path so middleware can gate routes without
7 * pulling node:crypto into the Edge runtime.
8 *
9 * The session token format is identical: "<payloadBase64Url>.<sigBase64Url>"
10 * with HMAC-SHA256 over the payload using ADMIN_SESSION_SECRET.
11 */
12
13export interface EdgeSessionPayload {
14 readonly email: string;
15 readonly iat: number;
16 readonly exp: number;
17}
18
19function base64UrlDecodeToBytes(input: string): Uint8Array | null {
20 try {
21 const padded = input.replace(/-/g, "+").replace(/_/g, "/");
22 const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
23 const binary = atob(padded + pad);
24 const out = new Uint8Array(binary.length);
25 for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
26 return out;
27 } catch {
28 return null;
29 }
30}
31
32function bytesToBase64Url(bytes: Uint8Array): string {
33 let binary = "";
34 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
35 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
36}
37
38function constantTimeEqual(a: string, b: string): boolean {
39 if (a.length !== b.length) return false;
40 let mismatch = 0;
41 for (let i = 0; i < a.length; i++) {
42 mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
43 }
44 return mismatch === 0;
45}
46
47async function hmacSign(payload: string, secret: string): Promise<string> {
48 const enc = new TextEncoder();
49 const key = await crypto.subtle.importKey(
50 "raw",
51 enc.encode(secret),
52 { name: "HMAC", hash: "SHA-256" },
53 false,
54 ["sign"],
55 );
56 const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
57 return bytesToBase64Url(new Uint8Array(sig));
58}
59
60export async function verifySessionEdge(
61 token: string | undefined | null,
62 secret: string | undefined | null,
63): Promise<EdgeSessionPayload | null> {
64 if (!token || !secret || secret.length < 16) return null;
65 const dot = token.indexOf(".");
66 if (dot < 1 || dot === token.length - 1) return null;
67
68 const payloadEncoded = token.slice(0, dot);
69 const providedSig = token.slice(dot + 1);
70 const expectedSig = await hmacSign(payloadEncoded, secret);
71 if (!constantTimeEqual(providedSig, expectedSig)) return null;
72
73 const payloadBytes = base64UrlDecodeToBytes(payloadEncoded);
74 if (!payloadBytes) return null;
75 let parsed: EdgeSessionPayload;
76 try {
77 parsed = JSON.parse(new TextDecoder().decode(payloadBytes)) as EdgeSessionPayload;
78 } catch {
79 return null;
80 }
81 if (
82 typeof parsed.email !== "string" ||
83 typeof parsed.iat !== "number" ||
84 typeof parsed.exp !== "number"
85 ) {
86 return null;
87 }
88 if (Math.floor(Date.now() / 1000) >= parsed.exp) return null;
89 return parsed;
90}
Addedapps/admin/lib/auth-password.ts+233−0View fileUnifiedSplit
1/**
2 * Admin password auth — single-user, env-var-backed.
3 *
4 * Pre-launch admin login for Craig. Uses node:crypto only — no new deps.
5 * Replaceable with SAML / passkey later without breaking the cookie format.
6 *
7 * Storage shape (set in env, never in repo):
8 * ADMIN_EMAIL the admin's email address
9 * ADMIN_PASSWORD_HASH "scrypt$N$r$p$saltHex$keyHex" (generate with scripts/generate-admin-hash.ts)
10 * ADMIN_SESSION_SECRET 32+ random bytes hex/base64; signs the session cookie
11 *
12 * Session cookie format: "<payloadBase64Url>.<sigBase64Url>"
13 * Payload: { email, iat, exp } JSON. Sig: HMAC-SHA256(payload, ADMIN_SESSION_SECRET).
14 */
15
16import {
17 scryptSync,
18 timingSafeEqual,
19 createHmac,
20 randomBytes,
21} from "node:crypto";
22
23// ─── Constants ──────────────────────────────────────────────────────────────
24
25export const ADMIN_SESSION_COOKIE = "alecrae_admin_session";
26const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
27
28// ─── Types ──────────────────────────────────────────────────────────────────
29
30export interface SessionPayload {
31 readonly email: string;
32 readonly iat: number;
33 readonly exp: number;
34}
35
36export type LoginResult =
37 | { readonly ok: true; readonly cookie: string; readonly maxAge: number }
38 | { readonly ok: false; readonly reason: LoginFailureReason };
39
40export type LoginFailureReason =
41 | "not_configured"
42 | "invalid_credentials"
43 | "rate_limited";
44
45// ─── Password hashing (scrypt) ──────────────────────────────────────────────
46
47interface ParsedHash {
48 readonly N: number;
49 readonly r: number;
50 readonly p: number;
51 readonly salt: Buffer;
52 readonly key: Buffer;
53}
54
55function parseStoredHash(stored: string): ParsedHash | null {
56 const parts = stored.split("$");
57 if (parts.length !== 6) return null;
58 const [scheme, nStr, rStr, pStr, saltHex, keyHex] = parts;
59 if (scheme !== "scrypt") return null;
60 const N = Number(nStr);
61 const r = Number(rStr);
62 const p = Number(pStr);
63 if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) {
64 return null;
65 }
66 if (!saltHex || !keyHex) return null;
67 try {
68 return {
69 N,
70 r,
71 p,
72 salt: Buffer.from(saltHex, "hex"),
73 key: Buffer.from(keyHex, "hex"),
74 };
75 } catch {
76 return null;
77 }
78}
79
80export function hashPassword(password: string): string {
81 const N = 16384;
82 const r = 8;
83 const p = 1;
84 const salt = randomBytes(16);
85 const key = scryptSync(password.normalize("NFKC"), salt, 64, { N, r, p });
86 return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${key.toString("hex")}`;
87}
88
89function verifyPassword(password: string, stored: string): boolean {
90 const parsed = parseStoredHash(stored);
91 if (!parsed) return false;
92 let derived: Buffer;
93 try {
94 derived = scryptSync(password.normalize("NFKC"), parsed.salt, parsed.key.length, {
95 N: parsed.N,
96 r: parsed.r,
97 p: parsed.p,
98 });
99 } catch {
100 return false;
101 }
102 if (derived.length !== parsed.key.length) return false;
103 return timingSafeEqual(derived, parsed.key);
104}
105
106// ─── Session cookie (HMAC-signed) ──────────────────────────────────────────
107
108function base64UrlEncode(buf: Buffer): string {
109 return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
110}
111
112function base64UrlDecode(input: string): Buffer | null {
113 try {
114 const padded = input.replace(/-/g, "+").replace(/_/g, "/");
115 const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
116 return Buffer.from(padded + pad, "base64");
117 } catch {
118 return null;
119 }
120}
121
122function sign(payload: string, secret: string): string {
123 return base64UrlEncode(createHmac("sha256", secret).update(payload).digest());
124}
125
126function readSecret(): string | null {
127 const s = process.env.ADMIN_SESSION_SECRET;
128 if (!s || s.length < 16) return null;
129 return s;
130}
131
132export function issueSession(email: string): { token: string; maxAge: number } | null {
133 const secret = readSecret();
134 if (!secret) return null;
135 const now = Math.floor(Date.now() / 1000);
136 const payload: SessionPayload = {
137 email: email.toLowerCase(),
138 iat: now,
139 exp: now + SESSION_TTL_SECONDS,
140 };
141 const payloadEncoded = base64UrlEncode(Buffer.from(JSON.stringify(payload)));
142 const sig = sign(payloadEncoded, secret);
143 return { token: `${payloadEncoded}.${sig}`, maxAge: SESSION_TTL_SECONDS };
144}
145
146export function verifySession(token: string | undefined | null): SessionPayload | null {
147 if (!token) return null;
148 const secret = readSecret();
149 if (!secret) return null;
150 const dot = token.indexOf(".");
151 if (dot < 1 || dot === token.length - 1) return null;
152 const payloadEncoded = token.slice(0, dot);
153 const providedSig = token.slice(dot + 1);
154
155 const expectedSig = sign(payloadEncoded, secret);
156 const a = Buffer.from(expectedSig);
157 const b = Buffer.from(providedSig);
158 if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
159
160 const payloadBuf = base64UrlDecode(payloadEncoded);
161 if (!payloadBuf) return null;
162 let parsed: SessionPayload;
163 try {
164 parsed = JSON.parse(payloadBuf.toString("utf8")) as SessionPayload;
165 } catch {
166 return null;
167 }
168 if (
169 typeof parsed.email !== "string" ||
170 typeof parsed.iat !== "number" ||
171 typeof parsed.exp !== "number"
172 ) {
173 return null;
174 }
175 if (Math.floor(Date.now() / 1000) >= parsed.exp) return null;
176 return parsed;
177}
178
179// ─── Login ─────────────────────────────────────────────────────────────────
180
181export function attemptLogin(email: string, password: string): LoginResult {
182 const expectedEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
183 const expectedHash = process.env.ADMIN_PASSWORD_HASH;
184 const secret = readSecret();
185
186 if (!expectedEmail || !expectedHash || !secret) {
187 return { ok: false, reason: "not_configured" };
188 }
189
190 const submitted = email.trim().toLowerCase();
191 // Compare email constant-time so we don't leak which field was wrong.
192 const emailA = Buffer.from(submitted.padEnd(128, "\0").slice(0, 128));
193 const emailB = Buffer.from(expectedEmail.padEnd(128, "\0").slice(0, 128));
194 const emailMatches = timingSafeEqual(emailA, emailB);
195 const passwordMatches = verifyPassword(password, expectedHash);
196
197 if (!emailMatches || !passwordMatches) {
198 return { ok: false, reason: "invalid_credentials" };
199 }
200
201 const session = issueSession(expectedEmail);
202 if (!session) {
203 return { ok: false, reason: "not_configured" };
204 }
205
206 const isProd = process.env.NODE_ENV === "production";
207 const cookie = [
208 `${ADMIN_SESSION_COOKIE}=${session.token}`,
209 "Path=/",
210 "HttpOnly",
211 "SameSite=Strict",
212 isProd ? "Secure" : "",
213 `Max-Age=${session.maxAge}`,
214 ]
215 .filter(Boolean)
216 .join("; ");
217
218 return { ok: true, cookie, maxAge: session.maxAge };
219}
220
221export function clearSessionCookie(): string {
222 const isProd = process.env.NODE_ENV === "production";
223 return [
224 `${ADMIN_SESSION_COOKIE}=`,
225 "Path=/",
226 "HttpOnly",
227 "SameSite=Strict",
228 isProd ? "Secure" : "",
229 "Max-Age=0",
230 ]
231 .filter(Boolean)
232 .join("; ");
233}
Addedapps/admin/middleware.ts+51−0View fileUnifiedSplit
1/**
2 * Admin route gate.
3 *
4 * Allows: /login, /api/login, /api/logout, Next.js internals, static assets.
5 * Everything else requires a valid signed session cookie.
6 */
7
8import { NextResponse, type NextRequest } from "next/server";
9import { verifySessionEdge } from "./lib/auth-edge";
10
11const ADMIN_SESSION_COOKIE = "alecrae_admin_session";
12
13const PUBLIC_PATHS: readonly string[] = ["/login", "/api/login", "/api/logout"];
14
15function isPublic(pathname: string): boolean {
16 if (PUBLIC_PATHS.includes(pathname)) return true;
17 if (pathname.startsWith("/_next/")) return true;
18 if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
19 return false;
20}
21
22export async function middleware(req: NextRequest): Promise<Response> {
23 const { pathname, search } = req.nextUrl;
24
25 if (isPublic(pathname)) {
26 return NextResponse.next();
27 }
28
29 const token = req.cookies.get(ADMIN_SESSION_COOKIE)?.value;
30 const secret = process.env.ADMIN_SESSION_SECRET;
31 const session = await verifySessionEdge(token, secret);
32
33 if (!session) {
34 const url = req.nextUrl.clone();
35 url.pathname = "/login";
36 url.search = "";
37 if (pathname !== "/") {
38 url.searchParams.set("next", pathname + search);
39 }
40 return NextResponse.redirect(url);
41 }
42
43 return NextResponse.next();
44}
45
46export const config = {
47 matcher: [
48 // Match everything except static assets and the public paths
49 "/((?!_next/static|_next/image|favicon.ico|robots.txt).*)",
50 ],
51};
Modifiedapps/admin/package.json+1−0View fileUnifiedSplit
1515 "dependencies": {
1616 "@alecrae/ui": "workspace:*",
1717 "@alecrae/shared": "workspace:*",
18 "@alecrae/flywheel": "workspace:*",
1819 "next": "^15.1.0",
1920 "react": "^19.0.0",
2021 "react-dom": "^19.0.0"
Modifiedapps/api/package.json+1−0View fileUnifiedSplit
2020 "@alecrae/crypto": "workspace:*",
2121 "@alecrae/db": "workspace:*",
2222 "@alecrae/dns": "workspace:*",
23 "@alecrae/flywheel": "workspace:*",
2324 "@alecrae/mta": "workspace:*",
2425 "@alecrae/reputation": "workspace:*",
2526 "@alecrae/security": "workspace:*",
Addedapps/api/src/routes/flywheel.ts+180−0View fileUnifiedSplit
1/**
2 * Flywheel signal ingestion + metric retrieval routes (F4).
3 *
4 * POST /v1/flywheel/signal — record one or many AI signals (compose,
5 * triage, smart_reply, voice_profile,
6 * phishing, search, inbox_agent, voice_clone)
7 * GET /v1/flywheel/metrics — per-account aggregate snapshot (last 12 weeks)
8 * GET /v1/flywheel/me — per-user "Your AlecRae" stats
9 * GET /v1/flywheel/global — admin-only cross-account snapshot
10 */
11
12import { Hono } from "hono";
13import { z } from "zod";
14import { and, eq, gte } from "drizzle-orm";
15import { requireScope } from "../middleware/auth.js";
16import { validateBody, getValidatedBody } from "../middleware/validator.js";
17import { getDatabase, flywheelSignals } from "@alecrae/db";
18import {
19 SignalPayloadSchema,
20 aggregateSnapshot,
21 aggregateUserStats,
22 type RawSignal,
23 type SignalPayload,
24} from "@alecrae/flywheel";
25
26// ─── Schemas ───────────────────────────────────────────────────────────────
27
28const SignalIngestSchema = z.object({
29 signals: z
30 .array(
31 z.object({
32 payload: SignalPayloadSchema,
33 capturedAtIso: z.string().datetime().optional(),
34 }),
35 )
36 .min(1)
37 .max(100),
38});
39
40type SignalIngestInput = z.infer<typeof SignalIngestSchema>;
41
42// ─── Helpers ───────────────────────────────────────────────────────────────
43
44const WINDOW_DAYS = 12 * 7;
45
46function rowsToRaw(
47 rows: ReadonlyArray<{
48 id: string;
49 userId: string | null;
50 payload: unknown;
51 capturedAt: Date;
52 }>,
53): RawSignal[] {
54 const out: RawSignal[] = [];
55 for (const r of rows) {
56 const parsed = SignalPayloadSchema.safeParse(r.payload);
57 if (!parsed.success) continue;
58 out.push({
59 id: r.id,
60 userId: r.userId,
61 capturedAtIso: r.capturedAt.toISOString(),
62 payload: parsed.data as SignalPayload,
63 });
64 }
65 return out;
66}
67
68// ─── Router ────────────────────────────────────────────────────────────────
69
70const flywheel = new Hono();
71
72// POST /signal — ingest
73flywheel.post(
74 "/signal",
75 requireScope("flywheel:write"),
76 validateBody(SignalIngestSchema),
77 async (c) => {
78 const auth = c.get("auth");
79 const body = getValidatedBody<SignalIngestInput>(c);
80 const db = getDatabase();
81
82 const rows = body.signals.map((s) => ({
83 accountId: auth.accountId,
84 userId: auth.userId ?? null,
85 category: s.payload.category,
86 event: s.payload.event,
87 payload: s.payload,
88 capturedAt: s.capturedAtIso ? new Date(s.capturedAtIso) : new Date(),
89 }));
90
91 await db.insert(flywheelSignals).values(rows);
92 return c.json({ ok: true, recorded: rows.length });
93 },
94);
95
96// GET /metrics — per-account snapshot
97flywheel.get("/metrics", requireScope("flywheel:read"), async (c) => {
98 const auth = c.get("auth");
99 const db = getDatabase();
100 const cutoff = new Date(Date.now() - WINDOW_DAYS * 86_400_000);
101
102 const rows = await db
103 .select({
104 id: flywheelSignals.id,
105 userId: flywheelSignals.userId,
106 payload: flywheelSignals.payload,
107 capturedAt: flywheelSignals.capturedAt,
108 })
109 .from(flywheelSignals)
110 .where(
111 and(
112 eq(flywheelSignals.accountId, auth.accountId),
113 gte(flywheelSignals.capturedAt, cutoff),
114 ),
115 );
116
117 const snapshot = aggregateSnapshot(rowsToRaw(rows), {
118 now: new Date(),
119 windowDays: WINDOW_DAYS,
120 });
121 return c.json(snapshot);
122});
123
124// GET /me — per-user view
125flywheel.get("/me", requireScope("flywheel:read"), async (c) => {
126 const auth = c.get("auth");
127 if (!auth.userId) {
128 return c.json(
129 { error: { type: "auth", message: "User context required", code: "no_user" } },
130 400,
131 );
132 }
133 const db = getDatabase();
134 const cutoff = new Date(Date.now() - WINDOW_DAYS * 86_400_000);
135
136 const rows = await db
137 .select({
138 id: flywheelSignals.id,
139 userId: flywheelSignals.userId,
140 payload: flywheelSignals.payload,
141 capturedAt: flywheelSignals.capturedAt,
142 })
143 .from(flywheelSignals)
144 .where(
145 and(
146 eq(flywheelSignals.userId, auth.userId),
147 gte(flywheelSignals.capturedAt, cutoff),
148 ),
149 );
150
151 const stats = aggregateUserStats(rowsToRaw(rows), {
152 now: new Date(),
153 userId: auth.userId,
154 });
155 return c.json(stats);
156});
157
158// GET /global — admin-only cross-account snapshot
159flywheel.get("/global", requireScope("admin:read"), async (c) => {
160 const db = getDatabase();
161 const cutoff = new Date(Date.now() - WINDOW_DAYS * 86_400_000);
162
163 const rows = await db
164 .select({
165 id: flywheelSignals.id,
166 userId: flywheelSignals.userId,
167 payload: flywheelSignals.payload,
168 capturedAt: flywheelSignals.capturedAt,
169 })
170 .from(flywheelSignals)
171 .where(gte(flywheelSignals.capturedAt, cutoff));
172
173 const snapshot = aggregateSnapshot(rowsToRaw(rows), {
174 now: new Date(),
175 windowDays: WINDOW_DAYS,
176 });
177 return c.json(snapshot);
178});
179
180export { flywheel };
Modifiedapps/api/src/server.ts+3−0View fileUnifiedSplit
7575import { changelog } from "./routes/changelog.js";
7676import { heatmapAnalytics } from "./routes/heatmap.js";
7777import { voiceMessageRouter } from "./routes/voice-message.js";
78import { flywheel } from "./routes/flywheel.js";
7879import { scripts } from "./routes/scripts.js";
7980import { emailQuery } from "./routes/email-query.js";
8081import { fbl } from "./routes/fbl.js";
613614app.route("/v1/analytics", recipientPatterns);
614615// A3: Inbox Heatmap analytics (heatmap grid, hourly chart, stats dashboard)
615616app.route("/v1/analytics", heatmapAnalytics);
617// F4: Flywheel — signal ingestion + aggregated metrics for /admin/flywheel + /your-ai
618app.route("/v1/flywheel", flywheel);
616619app.route("/v1/compose-assist", composeAssist);
617620app.route("/v1/compose/spellcheck", spellcheckRouter);
618621app.route("/v1/todo", todo);
Addedapps/web/app/(legal)/security/bounty/page.tsx+362−0View fileUnifiedSplit
1/**
2 * /security/bounty — public bug bounty programme (C6).
3 *
4 * Companion page to /security (RFC 9116 disclosure policy already live).
5 * Sets out scope, rewards, ineligible report types, and the legal
6 * safe-harbour. Designed so a researcher can read this page and submit
7 * a report without contacting us first.
8 */
9
10import type { Metadata } from "next";
11import { Box, Text, Card, CardContent } from "@alecrae/ui";
12import Link from "next/link";
13
14export const metadata: Metadata = {
15 title: "Bug Bounty Programme | AlecRae",
16 description:
17 "AlecRae's public bug bounty programme — scope, severity tiers, reward ranges, ineligible reports, and the safe-harbour terms protecting good-faith researchers.",
18};
19
20export default function BugBountyPage(): React.JSX.Element {
21 return (
22 <Box className="space-y-10">
23 <Box>
24 <Text as="h1" className="text-3xl font-bold text-content mb-2">
25 Bug Bounty Programme
26 </Text>
27 <Text className="text-content-tertiary">
28 Effective Date: May 8, 2026 &middot; Programme version 1.0
29 </Text>
30 </Box>
31
32 <Card className="border-amber-500/30 bg-amber-500/5">
33 <CardContent>
34 <Box className="p-2 space-y-2">
35 <Text as="h2" className="text-lg font-semibold text-amber-700">
36 Pre-launch programme — limited rewards, full safe harbour
37 </Text>
38 <Text className="text-content-secondary leading-relaxed">
39 We pay for high-impact bugs starting today, even before public
40 launch. Submit reports to{" "}
41 <a
42 className="text-brand-600 hover:underline"
43 href="mailto:security@alecrae.com"
44 >
45 security@alecrae.com
46 </a>
47 . Good-faith research is protected by the safe-harbour clause
48 at the bottom of this page.
49 </Text>
50 </Box>
51 </CardContent>
52 </Card>
53
54 <Box className="space-y-4">
55 <Text as="h2" className="text-xl font-bold text-content">
56 1. In scope
57 </Text>
58 <Box as="ul" className="list-disc pl-6 space-y-2 text-content-secondary leading-relaxed">
59 <Box as="li">
60 <Box as="code" className="text-content font-mono text-sm">
61 alecrae.com
62 </Box>{" "}
63 and all subdomains under it (mail, api, admin, status, docs,
64 mx1, mx2, smtp, send, bounce).
65 </Box>
66 <Box as="li">
67 The web app, admin console, and public API documented at{" "}
68 <Box as="code" className="font-mono text-sm">
69 docs.alecrae.com
70 </Box>
71 .
72 </Box>
73 <Box as="li">
74 The mobile apps (iOS, Android) and desktop app (Electron / Tauri)
75 published under the AlecRae developer account.
76 </Box>
77 <Box as="li">
78 The MTA — outbound deliverability, header injection, SMTP smuggling,
79 DKIM bypass, DMARC alignment failures, queue manipulation.
80 </Box>
81 <Box as="li">
82 All AI surfaces — prompt injection that exfiltrates user data,
83 jailbreaks that bypass safety filters, model-output poisoning that
84 persists across sessions.
85 </Box>
86 </Box>
87 </Box>
88
89 <Box className="space-y-4">
90 <Text as="h2" className="text-xl font-bold text-content">
91 2. Out of scope
92 </Text>
93 <Box as="ul" className="list-disc pl-6 space-y-2 text-content-secondary leading-relaxed">
94 <Box as="li">Third-party services (Stripe, Cloudflare, Vercel, Neon, Upstash, AWS SES) — report to those vendors directly.</Box>
95 <Box as="li">Best-practice findings without a demonstrable security impact (missing security headers on static pages, lack of HSTS preload, weak SPF on staging-only zones, etc.).</Box>
96 <Box as="li">Self-XSS and clickjacking on pages without sensitive actions.</Box>
97 <Box as="li">Reports from automated scanners without an explanation of impact.</Box>
98 <Box as="li">Findings on the marketing site that do not affect production data or authenticated sessions.</Box>
99 <Box as="li">DoS or volumetric attacks. Rate limits exist; do not test them in ways that affect other users.</Box>
100 <Box as="li">Social engineering against staff, contractors, or end users.</Box>
101 <Box as="li">Physical attacks against AlecRae offices, data centres, or staff.</Box>
102 </Box>
103 </Box>
104
105 <Box className="space-y-4">
106 <Text as="h2" className="text-xl font-bold text-content">
107 3. Severity &amp; rewards
108 </Text>
109 <Text className="text-content-secondary leading-relaxed">
110 We use the CVSS 3.1 base score plus product-specific impact to
111 assign severity. Reward bands are USD and represent the typical
112 range — exceptional reports can pay above the band, and reports
113 that we have already received internally pay nothing.
114 </Text>
115
116 <Box className="overflow-x-auto rounded-xl border border-border bg-surface-secondary">
117 <Box as="table" className="w-full text-sm">
118 <Box as="thead" className="bg-surface">
119 <Box as="tr">
120 <Box as="th" className="px-4 py-3 text-left font-semibold text-content">
121 Severity
122 </Box>
123 <Box as="th" className="px-4 py-3 text-left font-semibold text-content">
124 Reward (USD)
125 </Box>
126 <Box as="th" className="px-4 py-3 text-left font-semibold text-content">
127 Examples
128 </Box>
129 </Box>
130 </Box>
131 <Box as="tbody" className="divide-y divide-border">
132 <Box as="tr">
133 <Box as="td" className="px-4 py-3 font-medium text-red-600">
134 Critical
135 </Box>
136 <Box as="td" className="px-4 py-3 text-content">
137 $5,000 – $20,000
138 </Box>
139 <Box as="td" className="px-4 py-3 text-content-secondary">
140 RCE on the API server, full DB read of production tables, account takeover without user interaction, ability to read any user's mailbox, private key extraction.
141 </Box>
142 </Box>
143 <Box as="tr">
144 <Box as="td" className="px-4 py-3 font-medium text-amber-600">
145 High
146 </Box>
147 <Box as="td" className="px-4 py-3 text-content">
148 $1,500 – $5,000
149 </Box>
150 <Box as="td" className="px-4 py-3 text-content-secondary">
151 Authenticated access to other users' data, persistent stored XSS in the inbox or compose, DKIM-signed mail injection, MTA SMTP smuggling, prompt-injection that exfiltrates a different user's data.
152 </Box>
153 </Box>
154 <Box as="tr">
155 <Box as="td" className="px-4 py-3 font-medium text-yellow-600">
156 Medium
157 </Box>
158 <Box as="td" className="px-4 py-3 text-content">
159 $300 – $1,500
160 </Box>
161 <Box as="td" className="px-4 py-3 text-content-secondary">
162 CSRF on sensitive actions, IDOR with limited blast radius, OAuth token leakage to a controlled redirect, reflected XSS gated by user interaction, header-based session fixation.
163 </Box>
164 </Box>
165 <Box as="tr">
166 <Box as="td" className="px-4 py-3 font-medium text-blue-600">
167 Low
168 </Box>
169 <Box as="td" className="px-4 py-3 text-content">
170 $100 – $300
171 </Box>
172 <Box as="td" className="px-4 py-3 text-content-secondary">
173 Information disclosure with limited impact, rate-limit gaps on non-sensitive endpoints, missing CSRF on read-only endpoints with side effects.
174 </Box>
175 </Box>
176 <Box as="tr">
177 <Box as="td" className="px-4 py-3 font-medium text-content-secondary">
178 Hall of Fame
179 </Box>
180 <Box as="td" className="px-4 py-3 text-content">
181 Recognition only
182 </Box>
183 <Box as="td" className="px-4 py-3 text-content-secondary">
184 Valid bugs that don't qualify for a paid reward but are useful — e.g. minor info disclosure, a clever theoretical attack we should mitigate.
185 </Box>
186 </Box>
187 </Box>
188 </Box>
189 </Box>
190 </Box>
191
192 <Box className="space-y-4">
193 <Text as="h2" className="text-xl font-bold text-content">
194 4. Reporting a bug
195 </Text>
196 <Box as="ol" className="list-decimal pl-6 space-y-2 text-content-secondary leading-relaxed">
197 <Box as="li">
198 Email{" "}
199 <a className="text-brand-600 hover:underline" href="mailto:security@alecrae.com">
200 security@alecrae.com
201 </a>
202 . PGP optional — the key is published in{" "}
203 <Box as="code" className="font-mono text-sm">
204 /.well-known/security.txt
205 </Box>
206 .
207 </Box>
208 <Box as="li">
209 Subject line:{" "}
210 <Box as="code" className="font-mono text-sm">
211 [Bounty] short description
212 </Box>
213 .
214 </Box>
215 <Box as="li">
216 Include: target URL or component, reproduction steps, payload(s),
217 screenshots or PCAP if relevant, CVSS 3.1 vector you propose, and
218 your impact analysis. Don't email video links — attach a clip if
219 you must.
220 </Box>
221 <Box as="li">
222 Do not publish, share, or discuss the report until we confirm a
223 fix has shipped or 90 days have passed since acknowledgement,
224 whichever comes first.
225 </Box>
226 <Box as="li">
227 We acknowledge within 2 business days. Triage decision (in/out of
228 scope, severity, reward band) within 7 calendar days. Fix and
229 payout depend on severity — Critical and High get same-week
230 attention.
231 </Box>
232 </Box>
233 </Box>
234
235 <Box className="space-y-4">
236 <Text as="h2" className="text-xl font-bold text-content">
237 5. Rules of engagement
238 </Text>
239 <Box as="ul" className="list-disc pl-6 space-y-2 text-content-secondary leading-relaxed">
240 <Box as="li">
241 Use only test accounts you create yourself. Never access another
242 user's mailbox, contacts, or attachments. If you accidentally
243 access user data, stop, delete it, and tell us.
244 </Box>
245 <Box as="li">
246 Do not deploy malware, ransomware, or persistent backdoors. Do not
247 modify or delete data you didn't write.
248 </Box>
249 <Box as="li">
250 Do not attempt to access or test on third-party infrastructure
251 (Stripe, Cloudflare, Vercel, etc.) — even if reachable from our
252 stack.
253 </Box>
254 <Box as="li">
255 Throttle automated tooling. If a script you run causes service
256 degradation, you are responsible for stopping it. We reserve the
257 right to disqualify reports that come from disruptive testing.
258 </Box>
259 <Box as="li">
260 Only one researcher per finding gets paid — duplicates are paid to
261 the first valid report received.
262 </Box>
263 <Box as="li">
264 Reports must come from individuals or teams not employed by
265 AlecRae and not under contract with AlecRae at the time of
266 discovery.
267 </Box>
268 </Box>
269 </Box>
270
271 <Box className="space-y-4">
272 <Text as="h2" className="text-xl font-bold text-content">
273 6. Safe harbour
274 </Text>
275 <Card className="border-green-500/30 bg-green-500/5">
276 <CardContent>
277 <Box className="p-2 space-y-3 text-content-secondary leading-relaxed">
278 <Text>
279 AlecRae will not pursue or support any legal action related to
280 your research, provided you act in good faith and follow this
281 policy. Specifically:
282 </Text>
283 <Box as="ul" className="list-disc pl-6 space-y-1">
284 <Box as="li">
285 We consider your research authorised under the Computer Fraud
286 and Abuse Act (US), the Computer Misuse Act 1990 (UK), and
287 equivalent statutes in jurisdictions where AlecRae operates.
288 </Box>
289 <Box as="li">
290 We will not bring a DMCA claim against you for circumventing
291 technical measures used to protect AlecRae's services where
292 doing so was necessary for the research.
293 </Box>
294 <Box as="li">
295 If a third party brings legal action against you for research
296 conducted under this policy, we will make our authorisation
297 known to that party.
298 </Box>
299 </Box>
300 <Text>
301 If you are unsure whether something is permitted, ask first at{" "}
302 <a className="text-brand-600 hover:underline" href="mailto:security@alecrae.com">
303 security@alecrae.com
304 </a>
305 . Asking does not affect your eligibility.
306 </Text>
307 </Box>
308 </CardContent>
309 </Card>
310 </Box>
311
312 <Box className="space-y-4">
313 <Text as="h2" className="text-xl font-bold text-content">
314 7. Hall of Fame
315 </Text>
316 <Text className="text-content-secondary leading-relaxed">
317 With your permission, we credit valid reports on{" "}
318 <Link
319 href="/security"
320 className="text-brand-600 hover:underline"
321 >
322 our security page
323 </Link>{" "}
324 with your name (or handle) and the rough class of finding. Researchers
325 may also opt in to a private list shared with prospective enterprise
326 customers as a trust signal.
327 </Text>
328 </Box>
329
330 <Box className="space-y-4">
331 <Text as="h2" className="text-xl font-bold text-content">
332 8. Programme changes
333 </Text>
334 <Text className="text-content-secondary leading-relaxed">
335 We may update this policy. Material changes are announced on{" "}
336 <Link href="/security" className="text-brand-600 hover:underline">
337 the security page
338 </Link>
339 . Reports submitted under a prior version of the policy are honoured
340 under the rules in effect at submission time.
341 </Text>
342 </Box>
343
344 <Box className="rounded-xl border border-border bg-surface-secondary p-6">
345 <Text variant="body-sm" className="text-content-secondary">
346 Quick links:{" "}
347 <a className="text-brand-600 hover:underline" href="/.well-known/security.txt">
348 security.txt
349 </a>{" "}
350 ·{" "}
351 <Link href="/security" className="text-brand-600 hover:underline">
352 disclosure policy
353 </Link>{" "}
354 ·{" "}
355 <a className="text-brand-600 hover:underline" href="mailto:security@alecrae.com">
356 security@alecrae.com
357 </a>
358 </Text>
359 </Box>
360 </Box>
361 );
362}
Addedapps/web/app/your-ai/page.tsx+24−0View fileUnifiedSplit
1/**
2 * /your-ai — the visible flywheel (F1).
3 *
4 * Marketing-grade page that shows each user how their AI is sharpening
5 * over time. Server component for the shell + a client island that
6 * fetches /v1/flywheel/me and renders live numbers.
7 *
8 * Pre-launch / pre-data state is the default — we ship a beautiful
9 * "your wheel hasn't started spinning yet" empty state so screenshots
10 * still sell the product.
11 */
12
13import type { Metadata } from "next";
14import { YourAiClient } from "./your-ai-client";
15
16export const metadata: Metadata = {
17 title: "Your AI",
18 description:
19 "Watch the AI learn your voice, sharpen your inbox, and give you back hours every week.",
20};
21
22export default function YourAiPage(): React.JSX.Element {
23 return <YourAiClient />;
24}
Addedapps/web/app/your-ai/your-ai-client.tsx+234−0View fileUnifiedSplit
1"use client";
2
3import { useCallback, useEffect, useState } from "react";
4import { Box, Text } from "@alecrae/ui";
5import type { UserFlywheelStats } from "@alecrae/flywheel";
6
7const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "https://api.alecrae.com";
8const REFRESH_MS = 90_000;
9
10const PLACEHOLDER: UserFlywheelStats = {
11 userId: "you",
12 generatedAtIso: new Date().toISOString(),
13 voiceProfileConfidence: 0,
14 draftsAcceptedCount: 0,
15 draftsAcceptedPct: 0,
16 minutesSavedEstimate: 0,
17 wordsLearned: 0,
18 daysActive: 0,
19 maturityLabel: "new",
20};
21
22const MATURITY_COPY: Record<UserFlywheelStats["maturityLabel"], { label: string; sub: string }> = {
23 new: {
24 label: "New",
25 sub: "AlecRae is meeting you. Send a few emails — the wheel starts turning fast.",
26 },
27 warming: {
28 label: "Warming up",
29 sub: "AlecRae is learning your rhythm. Drafts are getting closer to your voice every day.",
30 },
31 tuned: {
32 label: "Tuned",
33 sub: "AlecRae sounds like you in most replies. You're saving real time now.",
34 },
35 expert: {
36 label: "Expert",
37 sub: "AlecRae is indistinguishable from your own writing. Most drafts ship unedited.",
38 },
39};
40
41function formatPct(v: number): string {
42 return `${Math.round(v * 100)}%`;
43}
44
45function formatMinutes(v: number): string {
46 if (v < 60) return `${Math.round(v)} min`;
47 const hours = v / 60;
48 return `${hours.toFixed(1)} hrs`;
49}
50
51function formatWords(v: number): string {
52 if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
53 if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
54 return Math.round(v).toLocaleString();
55}
56
57interface StatProps {
58 readonly label: string;
59 readonly value: string;
60 readonly hint: string;
61 readonly progress?: number;
62}
63
64function StatCard({ label, value, hint, progress }: StatProps): React.JSX.Element {
65 return (
66 <Box className="rounded-2xl border border-border bg-surface-secondary p-6 flex flex-col gap-3">
67 <Text variant="caption" className="text-content-tertiary uppercase tracking-wide">
68 {label}
69 </Text>
70 <Text variant="heading-lg" className="font-bold text-content text-4xl">
71 {value}
72 </Text>
73 {progress !== undefined && (
74 <Box className="rounded-full bg-surface h-1.5 overflow-hidden">
75 <Box
76 className="h-full bg-gradient-to-r from-amber-400 to-green-500"
77 style={{
78 width: `${Math.max(0, Math.min(100, progress * 100))}%`,
79 transition: "width 0.6s ease-out",
80 }}
81 />
82 </Box>
83 )}
84 <Text variant="body-sm" className="text-content-secondary">
85 {hint}
86 </Text>
87 </Box>
88 );
89}
90
91export function YourAiClient(): React.JSX.Element {
92 const [stats, setStats] = useState<UserFlywheelStats>(PLACEHOLDER);
93 const [status, setStatus] = useState<"placeholder" | "ready" | "error">("placeholder");
94
95 const load = useCallback(async (): Promise<void> => {
96 try {
97 const res = await fetch(`${API_BASE}/v1/flywheel/me`, {
98 credentials: "include",
99 });
100 if (!res.ok) {
101 setStatus("placeholder");
102 return;
103 }
104 const data = (await res.json()) as UserFlywheelStats;
105 setStats(data);
106 setStatus("ready");
107 } catch {
108 setStatus("placeholder");
109 }
110 }, []);
111
112 useEffect(() => {
113 void load();
114 const t = setInterval(() => void load(), REFRESH_MS);
115 return () => clearInterval(t);
116 }, [load]);
117
118 const maturity = MATURITY_COPY[stats.maturityLabel];
119
120 return (
121 <Box className="min-h-screen bg-surface">
122 <Box className="max-w-5xl mx-auto px-6 py-16 sm:py-24 flex flex-col gap-12">
123 {/* Hero */}
124 <Box className="flex flex-col gap-4">
125 <Text variant="caption" className="text-content-tertiary uppercase tracking-widest">
126 Your AlecRae
127 </Text>
128 <Text variant="heading-lg" className="font-bold text-content text-5xl sm:text-6xl leading-tight">
129 The longer you use AlecRae,
130 <br />
131 <Box as="span" className="text-amber-500">
132 the more it sounds like you.
133 </Box>
134 </Text>
135 <Text variant="body-md" className="text-content-secondary max-w-2xl">
136 Every email you send, every draft you accept, every reply you edit teaches the
137 AI something Gmail and Outlook can never copy: your voice. This page is the
138 wheel turning, in real time.
139 </Text>
140 </Box>
141
142 {/* Maturity badge */}
143 <Box className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-6 flex flex-col sm:flex-row sm:items-center gap-4">
144 <Box className="rounded-xl bg-amber-500/10 px-3 py-1.5 inline-flex w-fit">
145 <Text variant="body-sm" className="font-semibold text-amber-600">
146 {maturity.label}
147 </Text>
148 </Box>
149 <Text variant="body-md" className="text-content">
150 {maturity.sub}
151 </Text>
152 </Box>
153
154 {/* Stats grid */}
155 <Box className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
156 <StatCard
157 label="Voice confidence"
158 value={status === "ready" ? formatPct(stats.voiceProfileConfidence) : "—"}
159 hint="How closely AI drafts match your final sent text. Climbs as the AI learns your phrasing."
160 progress={status === "ready" ? stats.voiceProfileConfidence : 0}
161 />
162 <StatCard
163 label="Drafts accepted"
164 value={status === "ready" ? stats.draftsAcceptedCount.toLocaleString() : "—"}
165 hint={
166 status === "ready" && stats.draftsAcceptedPct > 0
167 ? `${formatPct(stats.draftsAcceptedPct)} of suggestions used.`
168 : "Number of AI drafts you've sent (with or without edits)."
169 }
170 />
171 <StatCard
172 label="Time saved"
173 value={status === "ready" ? formatMinutes(stats.minutesSavedEstimate) : "—"}
174 hint="Estimated minutes back in your day from AI compose + smart reply."
175 />
176 <StatCard
177 label="Words learned"
178 value={status === "ready" ? formatWords(stats.wordsLearned) : "—"}
179 hint="Words of your writing AlecRae has analysed to sharpen your voice profile."
180 />
181 </Box>
182
183 {/* The wheel explanation */}
184 <Box className="rounded-2xl border border-border bg-surface-secondary p-8 flex flex-col gap-4">
185 <Text variant="heading-md" className="text-content font-bold">
186 Why this matters
187 </Text>
188 <Text variant="body-md" className="text-content-secondary">
189 Most AI email tools start over with every user. AlecRae compounds. The voice
190 profile, the triage rules, the relationship memory — they all sharpen as you
191 use the product, and they belong to you. Switching providers later means
192 losing months of accumulated value. That's the moat. This page is how you
193 watch it grow.
194 </Text>
195 <Box as="ul" className="text-content-secondary space-y-2 pl-5 list-disc">
196 <Box as="li">
197 <Box as="strong" className="text-content">
198 Voice confidence
199 </Box>{" "}
200 climbs every time you send an email — whether AI-drafted or not.
201 </Box>
202 <Box as="li">
203 <Box as="strong" className="text-content">
204 Drafts accepted
205 </Box>{" "}
206 tracks how often the AI gets it right on the first try.
207 </Box>
208 <Box as="li">
209 <Box as="strong" className="text-content">
210 Time saved
211 </Box>{" "}
212 is conservative — only counts compose and smart-reply wins, not triage.
213 </Box>
214 <Box as="li">
215 <Box as="strong" className="text-content">
216 Days active
217 </Box>{" "}
218 is {status === "ready" ? `${stats.daysActive}` : "—"} so far. Aim for 60 to reach Expert.
219 </Box>
220 </Box>
221 </Box>
222
223 {/* Footer note */}
224 <Box className="text-center">
225 <Text variant="caption" className="text-content-tertiary">
226 Updated every 90 seconds while this tab is open.
227 {status !== "ready" &&
228 " Stats appear as soon as you sign in and start using AlecRae."}
229 </Text>
230 </Box>
231 </Box>
232 </Box>
233 );
234}
Modifiedapps/web/middleware.ts+45−23View fileUnifiedSplit
11import { NextResponse, type NextRequest } from "next/server";
22
3const PUBLIC_PATHS = new Set([
4 "/",
5 "/login",
6 "/register",
7 "/privacy",
8 "/terms",
9 "/cookies",
10 "/dpa",
11 "/sla",
12 "/dmca",
13 "/acceptable-use",
14 "/subprocessors",
15]);
16
17export function middleware(request: NextRequest) {
18 const { pathname } = request.nextUrl;
3/**
4 * Auth gate for the marketing + dashboard surfaces.
5 *
6 * Default-allow with a specific-deny prefix list. Pre-launch, the site
7 * is mostly public — landing, legal, roadmap, admin preview, security,
8 * bounty, your-ai, etc. — and only the authenticated dashboard sections
9 * are gated.
10 *
11 * Why this shape: the previous default-deny version inadvertently
12 * gated every new page (admin preview, roadmap, your-ai, security/bounty,
13 * most legal pages) behind a session cookie that the auth flow can't
14 * yet issue. Pages were unreachable. This version fails open for
15 * marketing/legal/preview routes and only redirects when a user tries
16 * to enter their actual mailbox.
17 */
1918
20 // Allow public paths
21 if (PUBLIC_PATHS.has(pathname)) {
22 return NextResponse.next();
19// Routes that REQUIRE a logged-in session. Add prefixes here as new
20// dashboard areas land. Anything not in this list is public.
21const PROTECTED_PREFIXES: readonly string[] = [
22 "/inbox",
23 "/sent",
24 "/drafts",
25 "/compose",
26 "/settings",
27 "/contacts",
28 "/templates",
29 "/domains",
30 "/analytics",
31 "/onboarding",
32 "/snoozed",
33];
34
35function isProtected(pathname: string): boolean {
36 for (const prefix of PROTECTED_PREFIXES) {
37 if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return true;
2338 }
39 return false;
40}
41
42export function middleware(request: NextRequest): NextResponse {
43 const { pathname } = request.nextUrl;
2444
25 // Allow static files and API routes
45 // Static, Next internals, API, and asset paths always pass.
2646 if (
2747 pathname.startsWith("/_next") ||
2848 pathname.startsWith("/api") ||
3151 return NextResponse.next();
3252 }
3353
34 // Check for auth token in cookies
35 const token = request.cookies.get("alecrae_session")?.value;
54 // Public-by-default. Only protected routes need a session.
55 if (!isProtected(pathname)) {
56 return NextResponse.next();
57 }
3658
59 const token = request.cookies.get("alecrae_session")?.value;
3760 if (!token) {
38 // Redirect to login with return URL
3961 const loginUrl = new URL("/login", request.url);
4062 loginUrl.searchParams.set("returnTo", pathname);
4163 return NextResponse.redirect(loginUrl);
Modifiedapps/web/package.json+1−0View fileUnifiedSplit
1313 "clean": "rm -rf .next"
1414 },
1515 "dependencies": {
16 "@alecrae/flywheel": "workspace:*",
1617 "@alecrae/shared": "workspace:*",
1718 "@alecrae/ui": "workspace:*",
1819 "@mlc-ai/web-llm": "^0.2.73",
Modifiedbun.lock+15−0View fileUnifiedSplit
2424 "name": "@alecrae/admin",
2525 "version": "0.1.0",
2626 "dependencies": {
27 "@alecrae/flywheel": "workspace:*",
2728 "@alecrae/shared": "workspace:*",
2829 "@alecrae/ui": "workspace:*",
2930 "next": "^15.1.0",
4849 "@alecrae/crypto": "workspace:*",
4950 "@alecrae/db": "workspace:*",
5051 "@alecrae/dns": "workspace:*",
52 "@alecrae/flywheel": "workspace:*",
5153 "@alecrae/mta": "workspace:*",
5254 "@alecrae/reputation": "workspace:*",
5355 "@alecrae/security": "workspace:*",
187189 "name": "@alecrae/web",
188190 "version": "0.1.0",
189191 "dependencies": {
192 "@alecrae/flywheel": "workspace:*",
190193 "@alecrae/shared": "workspace:*",
191194 "@alecrae/ui": "workspace:*",
192195 "@mlc-ai/web-llm": "^0.2.73",
261264 "vitest": "^2.1.0",
262265 },
263266 },
267 "packages/flywheel": {
268 "name": "@alecrae/flywheel",
269 "version": "0.1.0",
270 "dependencies": {
271 "zod": "^3.23.0",
272 },
273 "devDependencies": {
274 "typescript": "^5.7.0",
275 },
276 },
264277 "packages/sdk": {
265278 "name": "@alecrae/sdk",
266279 "version": "0.1.0",
554567
555568 "@alecrae/email-parser": ["@alecrae/email-parser@workspace:packages/email-parser"],
556569
570 "@alecrae/flywheel": ["@alecrae/flywheel@workspace:packages/flywheel"],
571
557572 "@alecrae/imap": ["@alecrae/imap@workspace:services/imap"],
558573
559574 "@alecrae/inbound": ["@alecrae/inbound@workspace:services/inbound"],
Modifiedpackages/db/src/index.ts+10−0View fileUnifiedSplit
10501050export type NewKnowledgeRelationship = InferInsertModel<typeof knowledgeRelationships>;
10511051export type KnowledgeExtraction = InferSelectModel<typeof knowledgeExtractions>;
10521052export type NewKnowledgeExtraction = InferInsertModel<typeof knowledgeExtractions>;
1053
1054// Schema - Flywheel
1055export {
1056 flywheelSignals,
1057 flywheelSignalsRelations,
1058} from "./schema/flywheel-signals.js";
1059export type {
1060 FlywheelSignal,
1061 NewFlywheelSignal,
1062} from "./schema/flywheel-signals.js";
Addedpackages/db/src/schema/flywheel-signals.ts+60−0View fileUnifiedSplit
1/**
2 * Flywheel signals — append-only timeline of every measurable AI event.
3 *
4 * Aggregated by `@alecrae/flywheel` into FlywheelSnapshot for /admin/flywheel
5 * and UserFlywheelStats for /your-ai. Cold storage after 365 days per
6 * `.ai-flywheel/config.json` retention policy.
7 */
8
9import {
10 pgTable,
11 text,
12 timestamp,
13 jsonb,
14 index,
15 uuid,
16} from "drizzle-orm/pg-core";
17import { relations, type InferSelectModel, type InferInsertModel } from "drizzle-orm";
18import { accounts, users } from "./users.js";
19
20export const flywheelSignals = pgTable(
21 "flywheel_signals",
22 {
23 id: uuid("id").primaryKey().defaultRandom(),
24 accountId: text("account_id")
25 .notNull()
26 .references(() => accounts.id, { onDelete: "cascade" }),
27 userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
28 category: text("category").notNull(),
29 event: text("event").notNull(),
30 payload: jsonb("payload").notNull(),
31 capturedAt: timestamp("captured_at", { withTimezone: true })
32 .notNull()
33 .defaultNow(),
34 },
35 (t) => ({
36 byAccountTime: index("flywheel_signals_account_time_idx").on(
37 t.accountId,
38 t.capturedAt,
39 ),
40 byUserTime: index("flywheel_signals_user_time_idx").on(t.userId, t.capturedAt),
41 byCategoryTime: index("flywheel_signals_category_time_idx").on(
42 t.category,
43 t.capturedAt,
44 ),
45 }),
46);
47
48export const flywheelSignalsRelations = relations(flywheelSignals, ({ one }) => ({
49 account: one(accounts, {
50 fields: [flywheelSignals.accountId],
51 references: [accounts.id],
52 }),
53 user: one(users, {
54 fields: [flywheelSignals.userId],
55 references: [users.id],
56 }),
57}));
58
59export type FlywheelSignal = InferSelectModel<typeof flywheelSignals>;
60export type NewFlywheelSignal = InferInsertModel<typeof flywheelSignals>;
Addedpackages/flywheel/package.json+27−0View fileUnifiedSplit
1{
2 "name": "@alecrae/flywheel",
3 "version": "0.1.0",
4 "private": true,
5 "type": "module",
6 "exports": {
7 ".": {
8 "types": "./dist/index.d.ts",
9 "import": "./dist/index.js"
10 }
11 },
12 "main": "./dist/index.js",
13 "types": "./dist/index.d.ts",
14 "scripts": {
15 "build": "tsc",
16 "dev": "tsc --watch",
17 "typecheck": "tsc --noEmit",
18 "clean": "rm -rf dist",
19 "test": "bun test"
20 },
21 "dependencies": {
22 "zod": "^3.23.0"
23 },
24 "devDependencies": {
25 "typescript": "^5.7.0"
26 }
27}
Addedpackages/flywheel/src/aggregate.test.ts+126−0View fileUnifiedSplit
1import { describe, it, expect } from "vitest";
2import { aggregateSnapshot, aggregateUserStats } from "./aggregate.js";
3import type { RawSignal } from "./aggregate.js";
4
5const NOW = new Date("2026-05-08T12:00:00Z");
6
7function sig(
8 capturedDaysAgo: number,
9 payload: RawSignal["payload"],
10 userId: string | null = "u1",
11): RawSignal {
12 return {
13 id: `s-${Math.random().toString(36).slice(2)}`,
14 userId,
15 capturedAtIso: new Date(NOW.getTime() - capturedDaysAgo * 86_400_000).toISOString(),
16 payload,
17 };
18}
19
20describe("aggregateSnapshot", () => {
21 it("returns zero-value metrics with empty trend on no signals", () => {
22 const snap = aggregateSnapshot([], { now: NOW });
23 expect(snap.totalSignals).toBe(0);
24 expect(snap.rpm).toBe(0);
25 expect(snap.metrics.length).toBeGreaterThan(0);
26 for (const m of snap.metrics) {
27 expect(m.trend).toHaveLength(12);
28 expect(m.value).toBe(0);
29 }
30 });
31
32 it("computes compose acceptance rate from accepted vs discarded", () => {
33 const signals: RawSignal[] = [
34 sig(1, { category: "compose", event: "suggestion_accepted" }),
35 sig(1, { category: "compose", event: "suggestion_accepted" }),
36 sig(1, { category: "compose", event: "suggestion_accepted" }),
37 sig(1, { category: "compose", event: "suggestion_discarded" }),
38 ];
39 const snap = aggregateSnapshot(signals, { now: NOW });
40 const compose = snap.metrics.find((m) => m.key === "compose_acceptance_rate");
41 expect(compose).toBeDefined();
42 expect(compose?.value).toBeCloseTo(0.75, 2);
43 });
44
45 it("computes triage accuracy from actionMatchesPriority", () => {
46 const signals: RawSignal[] = [
47 sig(2, { category: "triage", event: "user_replied", actionMatchesPriority: true }),
48 sig(2, { category: "triage", event: "user_replied", actionMatchesPriority: true }),
49 sig(2, { category: "triage", event: "user_archived", actionMatchesPriority: false }),
50 ];
51 const snap = aggregateSnapshot(signals, { now: NOW });
52 const triage = snap.metrics.find((m) => m.key === "triage_accuracy");
53 expect(triage?.value).toBeCloseTo(2 / 3, 2);
54 });
55
56 it("excludes signals outside the window", () => {
57 const insideWindow: RawSignal = sig(10, {
58 category: "compose",
59 event: "suggestion_accepted",
60 });
61 const outsideWindow: RawSignal = sig(120, {
62 category: "compose",
63 event: "suggestion_accepted",
64 });
65 const snap = aggregateSnapshot([insideWindow, outsideWindow], {
66 now: NOW,
67 windowDays: 30,
68 });
69 expect(snap.totalSignals).toBe(1);
70 });
71
72 it("RPM is between 0 and 1 when sample size is non-zero", () => {
73 const signals: RawSignal[] = [
74 sig(1, { category: "compose", event: "suggestion_accepted" }),
75 sig(1, { category: "compose", event: "suggestion_accepted" }),
76 sig(1, { category: "triage", event: "user_replied", actionMatchesPriority: true }),
77 ];
78 const snap = aggregateSnapshot(signals, { now: NOW });
79 expect(snap.rpm).toBeGreaterThanOrEqual(0);
80 expect(snap.rpm).toBeLessThanOrEqual(1);
81 });
82});
83
84describe("aggregateUserStats", () => {
85 it("returns 'new' maturity with zero everything for unknown user", () => {
86 const stats = aggregateUserStats([], { now: NOW, userId: "ghost" });
87 expect(stats.maturityLabel).toBe("new");
88 expect(stats.draftsAcceptedCount).toBe(0);
89 expect(stats.voiceProfileConfidence).toBe(0);
90 });
91
92 it("counts drafts accepted and computes acceptance pct", () => {
93 const signals: RawSignal[] = [
94 sig(1, { category: "compose", event: "suggestion_shown" }),
95 sig(1, { category: "compose", event: "suggestion_accepted" }),
96 sig(1, { category: "compose", event: "suggestion_accepted" }),
97 sig(1, { category: "compose", event: "suggestion_discarded" }),
98 ];
99 const stats = aggregateUserStats(signals, { now: NOW, userId: "u1" });
100 expect(stats.draftsAcceptedCount).toBe(2);
101 // 2 accepted / (1 shown + 2 accepted + 1 discarded = 4)
102 expect(stats.draftsAcceptedPct).toBeCloseTo(0.5, 2);
103 });
104
105 it("scales voice confidence inversely with average edit distance", () => {
106 const lowDistance: RawSignal[] = [
107 sig(1, {
108 category: "voice_profile",
109 event: "draft_sent",
110 editDistanceFromDraft: 0.05,
111 }),
112 ];
113 const highDistance: RawSignal[] = [
114 sig(1, {
115 category: "voice_profile",
116 event: "draft_sent",
117 editDistanceFromDraft: 0.5,
118 }),
119 ];
120 const lowStats = aggregateUserStats(lowDistance, { now: NOW, userId: "u1" });
121 const highStats = aggregateUserStats(highDistance, { now: NOW, userId: "u1" });
122 expect(lowStats.voiceProfileConfidence).toBeGreaterThan(highStats.voiceProfileConfidence);
123 expect(lowStats.voiceProfileConfidence).toBeGreaterThan(0.85);
124 expect(highStats.voiceProfileConfidence).toBeLessThanOrEqual(0.05);
125 });
126});
Addedpackages/flywheel/src/aggregate.ts+334−0View fileUnifiedSplit
1/**
2 * Aggregate raw signals into FlywheelMetric / FlywheelSnapshot views.
3 *
4 * Pure, deterministic. No I/O. Same signals + same now → same snapshot.
5 * The DB layer feeds raw rows in; admin pages render the result out.
6 */
7
8import type {
9 FlywheelMetric,
10 FlywheelSnapshot,
11 SignalCategory,
12 SignalPayload,
13 TrendPoint,
14 UserFlywheelStats,
15} from "./types.js";
16import { METRIC_TARGETS, scoreAgainstTarget, type MetricTarget } from "./targets.js";
17
18export interface RawSignal {
19 readonly id: string;
20 readonly userId: string | null;
21 readonly capturedAtIso: string;
22 readonly payload: SignalPayload;
23}
24
25const MS_PER_DAY = 86_400_000;
26const WEEKS_OF_TREND = 12;
27
28function startOfWeekUtc(d: Date): Date {
29 const utc = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
30 // Monday-aligned. getUTCDay: 0=Sunday … 6=Saturday.
31 const day = utc.getUTCDay();
32 const diff = day === 0 ? 6 : day - 1;
33 utc.setUTCDate(utc.getUTCDate() - diff);
34 return utc;
35}
36
37function isoWeekStart(d: Date): string {
38 const w = startOfWeekUtc(d);
39 return w.toISOString().slice(0, 10);
40}
41
42interface Bucket {
43 readonly numerator: number;
44 readonly denominator: number;
45}
46
47const EMPTY_BUCKET: Bucket = { numerator: 0, denominator: 0 };
48
49function bucketRate(weeks: Map<string, Bucket>, key: string, win: boolean): void {
50 const cur = weeks.get(key) ?? EMPTY_BUCKET;
51 weeks.set(key, {
52 numerator: cur.numerator + (win ? 1 : 0),
53 denominator: cur.denominator + 1,
54 });
55}
56
57function bucketAvg(weeks: Map<string, Bucket>, key: string, value: number): void {
58 const cur = weeks.get(key) ?? EMPTY_BUCKET;
59 weeks.set(key, {
60 numerator: cur.numerator + value,
61 denominator: cur.denominator + 1,
62 });
63}
64
65function rollUp(buckets: Map<string, Bucket>, weekKeys: readonly string[]): {
66 readonly value: number;
67 readonly trend: readonly TrendPoint[];
68} {
69 let totalNum = 0;
70 let totalDen = 0;
71 const trend: TrendPoint[] = weekKeys.map((week) => {
72 const b = buckets.get(week) ?? EMPTY_BUCKET;
73 totalNum += b.numerator;
74 totalDen += b.denominator;
75 return {
76 weekStartIso: week,
77 value: b.denominator === 0 ? 0 : b.numerator / b.denominator,
78 sampleSize: b.denominator,
79 };
80 });
81 return {
82 value: totalDen === 0 ? 0 : totalNum / totalDen,
83 trend,
84 };
85}
86
87function buildWeekKeys(now: Date): readonly string[] {
88 const keys: string[] = [];
89 for (let i = WEEKS_OF_TREND - 1; i >= 0; i--) {
90 const d = new Date(now.getTime() - i * 7 * MS_PER_DAY);
91 keys.push(isoWeekStart(d));
92 }
93 return keys;
94}
95
96// ─── Per-metric aggregators ────────────────────────────────────────────────
97
98type AggregatorFn = (signals: readonly RawSignal[], weekKeys: readonly string[]) => {
99 value: number;
100 trend: readonly TrendPoint[];
101};
102
103function makeRateAggregator(
104 category: SignalCategory,
105 isWin: (p: SignalPayload) => boolean,
106 isCounted: (p: SignalPayload) => boolean,
107): AggregatorFn {
108 return (signals, weekKeys) => {
109 const buckets = new Map<string, Bucket>();
110 for (const s of signals) {
111 if (s.payload.category !== category) continue;
112 if (!isCounted(s.payload)) continue;
113 const week = isoWeekStart(new Date(s.capturedAtIso));
114 bucketRate(buckets, week, isWin(s.payload));
115 }
116 return rollUp(buckets, weekKeys);
117 };
118}
119
120function makeAvgAggregator(
121 category: SignalCategory,
122 pick: (p: SignalPayload) => number | undefined,
123): AggregatorFn {
124 return (signals, weekKeys) => {
125 const buckets = new Map<string, Bucket>();
126 for (const s of signals) {
127 if (s.payload.category !== category) continue;
128 const v = pick(s.payload);
129 if (typeof v !== "number" || !Number.isFinite(v)) continue;
130 const week = isoWeekStart(new Date(s.capturedAtIso));
131 bucketAvg(buckets, week, v);
132 }
133 return rollUp(buckets, weekKeys);
134 };
135}
136
137const AGGREGATORS: Record<string, AggregatorFn> = {
138 compose_acceptance_rate: makeRateAggregator(
139 "compose",
140 (p) => p.category === "compose" && (p.event === "suggestion_accepted" || p.event === "email_sent"),
141 (p) =>
142 p.category === "compose" &&
143 (p.event === "suggestion_accepted" ||
144 p.event === "suggestion_discarded" ||
145 p.event === "email_sent"),
146 ),
147 triage_accuracy: makeRateAggregator(
148 "triage",
149 (p) => p.category === "triage" && p.actionMatchesPriority === true,
150 (p) => p.category === "triage" && typeof p.actionMatchesPriority === "boolean",
151 ),
152 smart_reply_acceptance_rate: makeRateAggregator(
153 "smart_reply",
154 (p) => p.category === "smart_reply" && p.event === "chosen",
155 (p) =>
156 p.category === "smart_reply" &&
157 (p.event === "chosen" || p.event === "discarded"),
158 ),
159 voice_profile_edit_distance: makeAvgAggregator("voice_profile", (p) =>
160 p.category === "voice_profile" ? p.editDistanceFromDraft : undefined,
161 ),
162 phishing_false_positive_rate: makeRateAggregator(
163 "phishing",
164 (p) => p.category === "phishing" && p.event === "false_positive",
165 (p) =>
166 p.category === "phishing" &&
167 (p.event === "user_confirmed_phishing" ||
168 p.event === "user_marked_safe" ||
169 p.event === "false_positive"),
170 ),
171 search_satisfaction_rate: makeRateAggregator(
172 "search",
173 (p) => p.category === "search" && p.event === "found_what_needed",
174 (p) =>
175 p.category === "search" &&
176 (p.event === "found_what_needed" || p.event === "abandoned"),
177 ),
178 inbox_agent_approval_rate: makeRateAggregator(
179 "inbox_agent",
180 (p) => p.category === "inbox_agent" && p.event === "draft_approved",
181 (p) =>
182 p.category === "inbox_agent" &&
183 (p.event === "draft_approved" || p.event === "draft_rejected"),
184 ),
185 voice_clone_unchanged_rate: makeRateAggregator(
186 "voice_clone",
187 (p) => p.category === "voice_clone" && p.event === "draft_sent_unchanged",
188 (p) =>
189 p.category === "voice_clone" &&
190 (p.event === "draft_sent_unchanged" ||
191 p.event === "draft_sent_edited" ||
192 p.event === "draft_discarded"),
193 ),
194};
195
196// ─── Public API ────────────────────────────────────────────────────────────
197
198export interface AggregateOptions {
199 readonly now: Date;
200 readonly windowDays?: number;
201}
202
203export function aggregateSnapshot(
204 signals: readonly RawSignal[],
205 opts: AggregateOptions,
206): FlywheelSnapshot {
207 const windowDays = opts.windowDays ?? WEEKS_OF_TREND * 7;
208 const cutoff = opts.now.getTime() - windowDays * MS_PER_DAY;
209 const inWindow = signals.filter(
210 (s) => new Date(s.capturedAtIso).getTime() >= cutoff,
211 );
212 const weekKeys = buildWeekKeys(opts.now);
213
214 const metrics: FlywheelMetric[] = METRIC_TARGETS.map((target: MetricTarget) => {
215 const agg = AGGREGATORS[target.key];
216 const result = agg
217 ? agg(inWindow, weekKeys)
218 : { value: 0, trend: weekKeys.map((w) => ({ weekStartIso: w, value: 0, sampleSize: 0 })) };
219 return {
220 key: target.key,
221 label: target.label,
222 category: target.category,
223 value: result.value,
224 unit: target.unit,
225 target: target.target,
226 direction: target.direction,
227 trend: result.trend,
228 };
229 });
230
231 // Composite RPM: average of each metric's score-against-target, weighted by sample size.
232 let weightedScore = 0;
233 let totalWeight = 0;
234 for (const m of metrics) {
235 const target = METRIC_TARGETS.find((t) => t.key === m.key);
236 if (!target) continue;
237 const samples = m.trend.reduce((sum, p) => sum + p.sampleSize, 0);
238 if (samples === 0) continue;
239 weightedScore += scoreAgainstTarget(m.value, target) * samples;
240 totalWeight += samples;
241 }
242 const rpm = totalWeight === 0 ? 0 : weightedScore / totalWeight;
243
244 return {
245 generatedAtIso: opts.now.toISOString(),
246 windowDays,
247 totalSignals: inWindow.length,
248 metrics,
249 rpm,
250 };
251}
252
253// ─── Per-user "Your AlecRae" rollup ────────────────────────────────────────
254
255export interface UserStatsOptions {
256 readonly now: Date;
257 readonly userId: string;
258}
259
260export function aggregateUserStats(
261 signals: readonly RawSignal[],
262 opts: UserStatsOptions,
263): UserFlywheelStats {
264 const userSignals = signals.filter((s) => s.userId === opts.userId);
265
266 const composeSent = userSignals.filter(
267 (s) => s.payload.category === "compose" && s.payload.event === "email_sent",
268 );
269 const composeShown = userSignals.filter(
270 (s) =>
271 s.payload.category === "compose" &&
272 (s.payload.event === "suggestion_shown" ||
273 s.payload.event === "suggestion_accepted" ||
274 s.payload.event === "suggestion_discarded"),
275 );
276 const composeAccepted = userSignals.filter(
277 (s) => s.payload.category === "compose" && s.payload.event === "suggestion_accepted",
278 );
279
280 const draftsAcceptedCount = composeAccepted.length;
281 const draftsAcceptedPct =
282 composeShown.length === 0 ? 0 : composeAccepted.length / composeShown.length;
283
284 const editDistances = userSignals
285 .map((s) =>
286 s.payload.category === "voice_profile" ? s.payload.editDistanceFromDraft : undefined,
287 )
288 .filter((v): v is number => typeof v === "number");
289 // Confidence: 1 when avg edit distance ~0, 0 when ≥0.5, 0 when there's no data yet.
290 const voiceProfileConfidence =
291 editDistances.length === 0
292 ? 0
293 : Math.max(
294 0,
295 Math.min(
296 1,
297 1 - editDistances.reduce((a, b) => a + b, 0) / editDistances.length / 0.5,
298 ),
299 );
300
301 // Crude "minutes saved" estimate: 90s per accepted draft, 30s per smart reply chosen.
302 const smartReplyChosen = userSignals.filter(
303 (s) => s.payload.category === "smart_reply" && s.payload.event === "chosen",
304 ).length;
305 const minutesSavedEstimate = (composeAccepted.length * 90 + smartReplyChosen * 30) / 60;
306
307 // "Words learned": sum of finalEmailLength across sent compose emails.
308 const wordsLearned = composeSent.reduce((sum, s) => {
309 if (s.payload.category !== "compose") return sum;
310 return sum + (s.payload.finalEmailLength ?? 0);
311 }, 0);
312
313 const dayKeys = new Set(
314 userSignals.map((s) => s.capturedAtIso.slice(0, 10)),
315 );
316 const daysActive = dayKeys.size;
317
318 let maturityLabel: UserFlywheelStats["maturityLabel"] = "new";
319 if (daysActive >= 60 && voiceProfileConfidence >= 0.75) maturityLabel = "expert";
320 else if (daysActive >= 21 && voiceProfileConfidence >= 0.5) maturityLabel = "tuned";
321 else if (daysActive >= 7) maturityLabel = "warming";
322
323 return {
324 userId: opts.userId,
325 generatedAtIso: opts.now.toISOString(),
326 voiceProfileConfidence,
327 draftsAcceptedCount,
328 draftsAcceptedPct,
329 minutesSavedEstimate,
330 wordsLearned,
331 daysActive,
332 maturityLabel,
333 };
334}
Addedpackages/flywheel/src/index.ts+37−0View fileUnifiedSplit
1/**
2 * @alecrae/flywheel — typed signal collection + aggregation.
3 *
4 * Public surface: types, schemas, the per-metric aggregator, the
5 * per-user "Your AlecRae" rollup, and the FlywheelTracker class for
6 * browser/server signal recording.
7 */
8
9export {
10 SignalCategory,
11 ComposeSignalSchema,
12 TriageSignalSchema,
13 SmartReplySignalSchema,
14 VoiceProfileSignalSchema,
15 PhishingSignalSchema,
16 SearchSignalSchema,
17 InboxAgentSignalSchema,
18 VoiceCloneSignalSchema,
19 SignalPayloadSchema,
20} from "./types.js";
21
22export type {
23 SignalPayload,
24 FlywheelMetric,
25 FlywheelSnapshot,
26 TrendPoint,
27 UserFlywheelStats,
28} from "./types.js";
29
30export { METRIC_TARGETS, scoreAgainstTarget } from "./targets.js";
31export type { MetricTarget } from "./targets.js";
32
33export { aggregateSnapshot, aggregateUserStats } from "./aggregate.js";
34export type { RawSignal, AggregateOptions, UserStatsOptions } from "./aggregate.js";
35
36export { FlywheelTracker } from "./tracker.js";
37export type { TrackerConfig } from "./tracker.js";
Addedpackages/flywheel/src/targets.ts+114−0View fileUnifiedSplit
1/**
2 * Improvement targets per metric, lifted from `.ai-flywheel/config.json`.
3 *
4 * Each metric has a target value, direction (maximize or minimize), and a
5 * threshold below/above which it counts as "warming" vs "tuned" vs "expert".
6 * Used by both /admin/flywheel and /your-ai rendering.
7 */
8
9import type { SignalCategory } from "./types.js";
10
11export interface MetricTarget {
12 readonly key: string;
13 readonly label: string;
14 readonly category: SignalCategory;
15 readonly target: number;
16 readonly direction: "maximize" | "minimize";
17 readonly unit: "rate" | "count" | "ms" | "edit_distance";
18 readonly description: string;
19}
20
21export const METRIC_TARGETS: readonly MetricTarget[] = [
22 {
23 key: "compose_acceptance_rate",
24 label: "Compose acceptance",
25 category: "compose",
26 target: 0.65,
27 direction: "maximize",
28 unit: "rate",
29 description:
30 "Share of AI-drafted emails sent without being discarded. Higher = drafts feel like the user wrote them.",
31 },
32 {
33 key: "triage_accuracy",
34 label: "Triage accuracy",
35 category: "triage",
36 target: 0.92,
37 direction: "maximize",
38 unit: "rate",
39 description:
40 "Share of priority-inbox classifications where the user's action matched the AI's priority.",
41 },
42 {
43 key: "smart_reply_acceptance_rate",
44 label: "Smart reply acceptance",
45 category: "smart_reply",
46 target: 0.55,
47 direction: "maximize",
48 unit: "rate",
49 description: "Share of suggested replies that were chosen and sent.",
50 },
51 {
52 key: "voice_profile_edit_distance",
53 label: "Voice profile edit distance",
54 category: "voice_profile",
55 target: 0.1,
56 direction: "minimize",
57 unit: "edit_distance",
58 description:
59 "Average edit distance between AI draft and final sent message. Lower = AI sounds more like the user.",
60 },
61 {
62 key: "phishing_false_positive_rate",
63 label: "Phishing false positive rate",
64 category: "phishing",
65 target: 0.02,
66 direction: "minimize",
67 unit: "rate",
68 description: "Share of phishing flags the user marked as safe. Lower = fewer over-cautious blocks.",
69 },
70 {
71 key: "search_satisfaction_rate",
72 label: "Search satisfaction",
73 category: "search",
74 target: 0.8,
75 direction: "maximize",
76 unit: "rate",
77 description: "Share of searches where the user clicked a result and didn't immediately re-query.",
78 },
79 {
80 key: "inbox_agent_approval_rate",
81 label: "Inbox agent approval",
82 category: "inbox_agent",
83 target: 0.7,
84 direction: "maximize",
85 unit: "rate",
86 description: "Share of overnight-agent draft proposals approved by the user in the morning briefing.",
87 },
88 {
89 key: "voice_clone_unchanged_rate",
90 label: "Voice clone unchanged-on-send",
91 category: "voice_clone",
92 target: 0.6,
93 direction: "maximize",
94 unit: "rate",
95 description:
96 "Share of voice-cloned drafts sent without any edits. Higher = the clone is indistinguishable from the user.",
97 },
98] as const;
99
100/**
101 * Score a single metric against its target. Returns 0..1 where 1 = at-or-past target.
102 */
103export function scoreAgainstTarget(value: number, target: MetricTarget): number {
104 if (target.direction === "maximize") {
105 if (target.target <= 0) return 1;
106 return Math.max(0, Math.min(1, value / target.target));
107 }
108 // minimize: target is the *cap*; perfect = 0, terrible = target * 5
109 if (target.target <= 0) return value <= 0 ? 1 : 0;
110 if (value <= target.target) return 1;
111 const cap = target.target * 5;
112 if (value >= cap) return 0;
113 return Math.max(0, Math.min(1, 1 - (value - target.target) / (cap - target.target)));
114}
Addedpackages/flywheel/src/tracker.ts+128−0View fileUnifiedSplit
1/**
2 * Browser + server tracker — fire signals to /v1/flywheel/signal.
3 *
4 * Fail-soft: a tracking failure must never break the user-facing feature
5 * that fired it. All errors are swallowed (and surfaced to the console
6 * in dev only).
7 *
8 * Browser path uses navigator.sendBeacon when available so signals
9 * survive page navigation. Server path uses fetch.
10 */
11
12import { SignalPayloadSchema, type SignalPayload } from "./types.js";
13
14export interface TrackerConfig {
15 readonly endpoint: string; // e.g. https://api.alecrae.com/v1/flywheel/signal
16 readonly authToken?: () => string | null | undefined;
17 readonly onError?: (err: unknown) => void;
18 readonly buffer?: boolean; // batch sends (default true in browser)
19}
20
21interface QueuedSignal {
22 readonly payload: SignalPayload;
23 readonly capturedAtIso: string;
24}
25
26const FLUSH_INTERVAL_MS = 4_000;
27const MAX_QUEUE = 50;
28
29export class FlywheelTracker {
30 private readonly config: TrackerConfig;
31 private queue: QueuedSignal[] = [];
32 private flushTimer: ReturnType<typeof setTimeout> | null = null;
33 private readonly inBrowser: boolean;
34
35 constructor(config: TrackerConfig) {
36 this.config = config;
37 this.inBrowser =
38 typeof window !== "undefined" && typeof navigator !== "undefined";
39 if (this.inBrowser) {
40 window.addEventListener("pagehide", () => this.flushSync());
41 window.addEventListener("beforeunload", () => this.flushSync());
42 }
43 }
44
45 /**
46 * Validate + enqueue (or send immediately when buffering disabled).
47 */
48 record(payload: SignalPayload): void {
49 let parsed: SignalPayload;
50 try {
51 parsed = SignalPayloadSchema.parse(payload);
52 } catch (err) {
53 this.fail(err);
54 return;
55 }
56
57 const queued: QueuedSignal = {
58 payload: parsed,
59 capturedAtIso: new Date().toISOString(),
60 };
61
62 if (this.config.buffer === false) {
63 void this.send([queued], /* sync */ false);
64 return;
65 }
66
67 this.queue.push(queued);
68 if (this.queue.length >= MAX_QUEUE) {
69 void this.flush();
70 } else if (!this.flushTimer) {
71 this.flushTimer = setTimeout(() => void this.flush(), FLUSH_INTERVAL_MS);
72 }
73 }
74
75 async flush(): Promise<void> {
76 if (this.flushTimer) {
77 clearTimeout(this.flushTimer);
78 this.flushTimer = null;
79 }
80 if (this.queue.length === 0) return;
81 const batch = this.queue;
82 this.queue = [];
83 await this.send(batch, /* sync */ false);
84 }
85
86 flushSync(): void {
87 if (this.queue.length === 0) return;
88 const batch = this.queue;
89 this.queue = [];
90 void this.send(batch, /* sync */ true);
91 }
92
93 private async send(batch: readonly QueuedSignal[], sync: boolean): Promise<void> {
94 const body = JSON.stringify({ signals: batch });
95 const headers: Record<string, string> = { "Content-Type": "application/json" };
96 const token = this.config.authToken?.();
97 if (token) headers.Authorization = `Bearer ${token}`;
98
99 if (sync && this.inBrowser && typeof navigator.sendBeacon === "function") {
100 try {
101 const blob = new Blob([body], { type: "application/json" });
102 navigator.sendBeacon(this.config.endpoint, blob);
103 } catch (err) {
104 this.fail(err);
105 }
106 return;
107 }
108
109 try {
110 await fetch(this.config.endpoint, {
111 method: "POST",
112 headers,
113 body,
114 keepalive: this.inBrowser,
115 });
116 } catch (err) {
117 this.fail(err);
118 }
119 }
120
121 private fail(err: unknown): void {
122 if (this.config.onError) this.config.onError(err);
123 else if (typeof console !== "undefined" && process.env.NODE_ENV !== "production") {
124 // eslint-disable-next-line no-console
125 console.warn("[flywheel] tracker error", err);
126 }
127 }
128}
Addedpackages/flywheel/src/types.ts+188−0View fileUnifiedSplit
1/**
2 * AlecRae Flywheel — typed signal definitions.
3 *
4 * Mirrors `.ai-flywheel/config.json`. Every AI feature emits structured
5 * signals; the flywheel aggregates them into RPM-style metrics that prove
6 * the moat is compounding (or warn when it isn't).
7 *
8 * Signal categories:
9 * - compose AI Compose suggestions
10 * - triage Priority-inbox classifications
11 * - smart_reply Quick reply suggestions
12 * - voice_profile User's writing-style fingerprint accuracy
13 * - phishing Phishing detector accuracy
14 * - search Natural-language search relevance
15 * - inbox_agent Overnight agent draft acceptance
16 * - voice_clone Voice-cloned reply acceptance
17 *
18 * NOTE: All signal payloads are non-PII by construction. Email IDs are
19 * opaque references; recipient addresses are never recorded.
20 */
21
22import { z } from "zod";
23
24// ─── Category enum ──────────────────────────────────────────────────────────
25
26export const SignalCategory = {
27 Compose: "compose",
28 Triage: "triage",
29 SmartReply: "smart_reply",
30 VoiceProfile: "voice_profile",
31 Phishing: "phishing",
32 Search: "search",
33 InboxAgent: "inbox_agent",
34 VoiceClone: "voice_clone",
35} as const;
36
37export type SignalCategory =
38 (typeof SignalCategory)[keyof typeof SignalCategory];
39
40// ─── Per-category payload schemas (zod) ─────────────────────────────────────
41
42export const ComposeSignalSchema = z.object({
43 category: z.literal("compose"),
44 event: z.enum([
45 "suggestion_shown",
46 "suggestion_accepted",
47 "suggestion_edited",
48 "suggestion_discarded",
49 "email_sent",
50 ]),
51 wordsChangedAfterAcceptPct: z.number().min(0).max(1).optional(),
52 finalEmailLength: z.number().int().nonnegative().optional(),
53 timeToSendAfterComposeMs: z.number().int().nonnegative().optional(),
54});
55
56export const TriageSignalSchema = z.object({
57 category: z.literal("triage"),
58 event: z.enum([
59 "priority_assigned",
60 "user_kept",
61 "user_archived",
62 "user_snoozed",
63 "user_replied",
64 "user_deleted",
65 ]),
66 aiPriority: z.enum(["urgent", "important", "normal", "low"]).optional(),
67 timeToActionMs: z.number().int().nonnegative().optional(),
68 actionMatchesPriority: z.boolean().optional(),
69});
70
71export const SmartReplySignalSchema = z.object({
72 category: z.literal("smart_reply"),
73 event: z.enum(["shown", "chosen", "edited_before_send", "discarded"]),
74 suggestionsShown: z.number().int().min(0).max(10).optional(),
75 suggestionIndexChosen: z.number().int().min(0).max(9).optional(),
76 editDistanceFromSuggestion: z.number().min(0).optional(),
77});
78
79export const VoiceProfileSignalSchema = z.object({
80 category: z.literal("voice_profile"),
81 event: z.enum(["draft_generated", "draft_sent", "draft_edited", "profile_retrained"]),
82 wasAiDrafted: z.boolean().optional(),
83 editDistanceFromDraft: z.number().min(0).optional(),
84 formalityScore: z.number().min(0).max(1).optional(),
85 avgSentenceLength: z.number().min(0).optional(),
86 vocabularyMatchPct: z.number().min(0).max(1).optional(),
87});
88
89export const PhishingSignalSchema = z.object({
90 category: z.literal("phishing"),
91 event: z.enum([
92 "risk_score_assigned",
93 "user_confirmed_phishing",
94 "user_marked_safe",
95 "false_positive",
96 "false_negative",
97 ]),
98 riskScore: z.number().min(0).max(1).optional(),
99});
100
101export const SearchSignalSchema = z.object({
102 category: z.literal("search"),
103 event: z.enum(["query_run", "result_clicked", "found_what_needed", "abandoned"]),
104 queryType: z.enum(["keyword", "natural_language", "operator"]).optional(),
105 resultsCount: z.number().int().nonnegative().optional(),
106 resultClickedPosition: z.number().int().nonnegative().optional(),
107});
108
109export const InboxAgentSignalSchema = z.object({
110 category: z.literal("inbox_agent"),
111 event: z.enum([
112 "run_started",
113 "draft_proposed",
114 "draft_approved",
115 "draft_rejected",
116 "draft_edited",
117 "briefing_viewed",
118 ]),
119 confidence: z.number().min(0).max(1).optional(),
120});
121
122export const VoiceCloneSignalSchema = z.object({
123 category: z.literal("voice_clone"),
124 event: z.enum([
125 "draft_generated",
126 "draft_sent_unchanged",
127 "draft_sent_edited",
128 "draft_discarded",
129 ]),
130 profileId: z.string().min(1).max(64).optional(),
131 editDistanceFromDraft: z.number().min(0).optional(),
132});
133
134// ─── Discriminated union (this is what callers send to recordSignal) ────────
135
136export const SignalPayloadSchema = z.discriminatedUnion("category", [
137 ComposeSignalSchema,
138 TriageSignalSchema,
139 SmartReplySignalSchema,
140 VoiceProfileSignalSchema,
141 PhishingSignalSchema,
142 SearchSignalSchema,
143 InboxAgentSignalSchema,
144 VoiceCloneSignalSchema,
145]);
146
147export type SignalPayload = z.infer<typeof SignalPayloadSchema>;
148
149// ─── Aggregated metrics (what /admin/flywheel and /your-ai read) ────────────
150
151export interface FlywheelMetric {
152 readonly key: string;
153 readonly label: string;
154 readonly category: SignalCategory;
155 readonly value: number; // 0..1 for rates, raw for counts
156 readonly unit: "rate" | "count" | "ms" | "edit_distance";
157 readonly target: number;
158 readonly direction: "maximize" | "minimize";
159 readonly trend: readonly TrendPoint[]; // last 12 weeks ordered oldest→newest
160}
161
162export interface TrendPoint {
163 readonly weekStartIso: string; // YYYY-MM-DD (Monday)
164 readonly value: number;
165 readonly sampleSize: number;
166}
167
168export interface FlywheelSnapshot {
169 readonly generatedAtIso: string;
170 readonly windowDays: number;
171 readonly totalSignals: number;
172 readonly metrics: readonly FlywheelMetric[];
173 readonly rpm: number; // 0..1 composite "is the wheel turning?" score
174}
175
176// ─── Per-user (Your AlecRae) view ───────────────────────────────────────────
177
178export interface UserFlywheelStats {
179 readonly userId: string;
180 readonly generatedAtIso: string;
181 readonly voiceProfileConfidence: number; // 0..1
182 readonly draftsAcceptedCount: number;
183 readonly draftsAcceptedPct: number; // 0..1
184 readonly minutesSavedEstimate: number;
185 readonly wordsLearned: number;
186 readonly daysActive: number;
187 readonly maturityLabel: "new" | "warming" | "tuned" | "expert";
188}
Addedpackages/flywheel/tsconfig.json+10−0View fileUnifiedSplit
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "outDir": "./dist",
5 "rootDir": "./src",
6 "lib": ["ES2022", "DOM"]
7 },
8 "include": ["src/**/*.ts"],
9 "exclude": ["node_modules", "dist", "**/*.test.ts"]
10}
Addedscripts/generate-admin-hash.ts+95−0View fileUnifiedSplit
1#!/usr/bin/env bun
2/**
3 * Generate the env vars for admin login.
4 *
5 * Usage:
6 * bun run scripts/generate-admin-hash.ts
7 *
8 * Prompts for email + password (password input is hidden) and prints the
9 * three env vars to paste into Vercel / your local .env. The password
10 * itself is never written to disk and never leaves your machine.
11 */
12
13import { hashPassword } from "../apps/admin/lib/auth-password";
14import { randomBytes } from "node:crypto";
15import * as readline from "node:readline";
16
17function ask(question: string, hidden: boolean): Promise<string> {
18 return new Promise((resolve) => {
19 const rl = readline.createInterface({
20 input: process.stdin,
21 output: process.stdout,
22 terminal: true,
23 });
24
25 if (!hidden) {
26 rl.question(question, (answer) => {
27 rl.close();
28 resolve(answer);
29 });
30 return;
31 }
32
33 // Hidden input: write the prompt, intercept stdout, capture line.
34 process.stdout.write(question);
35 const stdin = process.stdin;
36 stdin.resume();
37 stdin.setRawMode?.(true);
38 let buffer = "";
39 const onData = (ch: Buffer): void => {
40 const s = ch.toString("utf8");
41 for (const c of s) {
42 if (c === "") {
43 process.stdout.write("\n");
44 process.exit(130);
45 }
46 if (c === "\r" || c === "\n") {
47 stdin.setRawMode?.(false);
48 stdin.pause();
49 stdin.removeListener("data", onData);
50 rl.close();
51 process.stdout.write("\n");
52 resolve(buffer);
53 return;
54 }
55 if (c === "") {
56 if (buffer.length > 0) buffer = buffer.slice(0, -1);
57 continue;
58 }
59 buffer += c;
60 }
61 };
62 stdin.on("data", onData);
63 });
64}
65
66async function main(): Promise<void> {
67 const email = (await ask("Admin email: ", false)).trim().toLowerCase();
68 if (!email || !email.includes("@")) {
69 console.error("Email must contain @");
70 process.exit(1);
71 }
72 const password = await ask("Password (hidden): ", true);
73 if (password.length < 12) {
74 console.error("Password must be at least 12 characters.");
75 process.exit(1);
76 }
77 const confirm = await ask("Confirm password (hidden): ", true);
78 if (password !== confirm) {
79 console.error("Passwords do not match.");
80 process.exit(1);
81 }
82
83 const hash = hashPassword(password);
84 const sessionSecret = randomBytes(32).toString("hex");
85
86 console.log("\n# Paste these into Vercel → Settings → Environment Variables");
87 console.log("# (or your local apps/admin/.env.local for development)");
88 console.log("# Keep them out of the repo. ADMIN_PASSWORD_HASH is safe to share, the password is not.\n");
89 console.log(`ADMIN_EMAIL=${email}`);
90 console.log(`ADMIN_PASSWORD_HASH=${hash}`);
91 console.log(`ADMIN_SESSION_SECRET=${sessionSecret}`);
92 console.log("");
93}
94
95void main();
096
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts