horizon-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/assets/python/highlights.scm +137 -0
  2. package/assets/python/tree-sitter-python.wasm +0 -0
  3. package/bin/horizon.js +2 -0
  4. package/package.json +40 -0
  5. package/src/ai/client.ts +369 -0
  6. package/src/ai/system-prompt.ts +86 -0
  7. package/src/app.ts +1454 -0
  8. package/src/chat/messages.ts +48 -0
  9. package/src/chat/renderer.ts +243 -0
  10. package/src/chat/types.ts +18 -0
  11. package/src/components/code-panel.ts +329 -0
  12. package/src/components/footer.ts +72 -0
  13. package/src/components/hooks-panel.ts +224 -0
  14. package/src/components/input-bar.ts +193 -0
  15. package/src/components/mode-bar.ts +245 -0
  16. package/src/components/session-panel.ts +294 -0
  17. package/src/components/settings-panel.ts +372 -0
  18. package/src/components/splash.ts +156 -0
  19. package/src/components/strategy-panel.ts +489 -0
  20. package/src/components/tab-bar.ts +112 -0
  21. package/src/components/tutorial-panel.ts +680 -0
  22. package/src/components/widgets/progress-bar.ts +38 -0
  23. package/src/components/widgets/sparkline.ts +57 -0
  24. package/src/hooks/executor.ts +109 -0
  25. package/src/index.ts +22 -0
  26. package/src/keys/handler.ts +198 -0
  27. package/src/platform/auth.ts +36 -0
  28. package/src/platform/client.ts +159 -0
  29. package/src/platform/config.ts +121 -0
  30. package/src/platform/session-sync.ts +158 -0
  31. package/src/platform/supabase.ts +376 -0
  32. package/src/platform/sync.ts +149 -0
  33. package/src/platform/tiers.ts +103 -0
  34. package/src/platform/tools.ts +163 -0
  35. package/src/platform/types.ts +86 -0
  36. package/src/platform/usage.ts +224 -0
  37. package/src/research/apis.ts +367 -0
  38. package/src/research/tools.ts +205 -0
  39. package/src/research/widgets.ts +523 -0
  40. package/src/state/store.ts +256 -0
  41. package/src/state/types.ts +109 -0
  42. package/src/strategy/ascii-chart.ts +74 -0
  43. package/src/strategy/code-stream.ts +146 -0
  44. package/src/strategy/dashboard.ts +140 -0
  45. package/src/strategy/persistence.ts +82 -0
  46. package/src/strategy/prompts.ts +626 -0
  47. package/src/strategy/sandbox.ts +137 -0
  48. package/src/strategy/tools.ts +764 -0
  49. package/src/strategy/validator.ts +216 -0
  50. package/src/strategy/widgets.ts +270 -0
  51. package/src/syntax/setup.ts +54 -0
  52. package/src/theme/colors.ts +107 -0
  53. package/src/theme/icons.ts +27 -0
  54. package/src/util/hyperlink.ts +21 -0
@@ -0,0 +1,121 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
4
+
5
+ const CONFIG_DIR = join(homedir(), ".horizon");
6
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
7
+
8
+ // ── Hardcoded constants (same Supabase project as the platform) ──
9
+ // The anon key is public — it's in client-side JS on the website. RLS protects data.
10
+ export const SUPABASE_URL = "https://pjhydvnnptabkiiluoji.supabase.co";
11
+ export const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBqaHlkdm5ucHRhYmtpaWx1b2ppIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA3NTMwMDQsImV4cCI6MjA4NjMyOTAwNH0.vwa7fwDIwDX9rJkWDq9K_mgWyCMsGqnKE3OMNOR8KOM";
12
+ export const AUTH_URL = "https://api.mathematicalcompany.com"; // API key portal (auth, keys, billing)
13
+ export const PLATFORM_URL = "https://horizon.mathematicalcompany.com"; // Deploy platform (strategies, deployments, metrics)
14
+
15
+ export interface HorizonConfig {
16
+ api_key?: string;
17
+ user_email?: string;
18
+ user_id?: string;
19
+ supabase_session?: {
20
+ access_token: string;
21
+ refresh_token: string;
22
+ };
23
+ session_encrypted?: boolean;
24
+ encrypted_env?: Record<string, string>;
25
+ settings?: Record<string, unknown>;
26
+ hooks?: Array<{ event: string; command: string; enabled: boolean; label?: string }>;
27
+ has_launched?: boolean;
28
+ open_chat_ids?: string[];
29
+ }
30
+
31
+ function ensureDir(): void {
32
+ if (!existsSync(CONFIG_DIR)) {
33
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
34
+ }
35
+ }
36
+
37
+ export function loadConfig(): HorizonConfig {
38
+ try {
39
+ if (!existsSync(CONFIG_FILE)) return {};
40
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
41
+ return JSON.parse(raw);
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ export function saveConfig(config: HorizonConfig): void {
48
+ ensureDir();
49
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
50
+ }
51
+
52
+ export function getApiKey(): string | null {
53
+ return process.env.HORIZON_API_KEY ?? loadConfig().api_key ?? null;
54
+ }
55
+
56
+ export function getPlatformUrl(): string {
57
+ return process.env.HORIZON_PLATFORM_URL ?? PLATFORM_URL;
58
+ }
59
+
60
+ export function getAuthUrl(): string {
61
+ return process.env.HORIZON_AUTH_URL ?? AUTH_URL;
62
+ }
63
+
64
+ // ── Encrypted environment variables ──
65
+ // Encrypted with AES-256-GCM, key derived from the user's API key
66
+
67
+ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
68
+
69
+ function deriveKey(apiKey: string): Buffer {
70
+ return pbkdf2Sync(apiKey, "horizon-env-v1", 100000, 32, "sha256");
71
+ }
72
+
73
+ export function encryptEnvVar(value: string): string {
74
+ const apiKey = getApiKey();
75
+ if (!apiKey) throw new Error("Not authenticated");
76
+ const key = deriveKey(apiKey);
77
+ const iv = randomBytes(12);
78
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
79
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
80
+ const tag = cipher.getAuthTag();
81
+ return Buffer.concat([iv, encrypted, tag]).toString("base64");
82
+ }
83
+
84
+ export function decryptEnvVar(ciphertext: string): string {
85
+ const apiKey = getApiKey();
86
+ if (!apiKey) throw new Error("Not authenticated");
87
+ const key = deriveKey(apiKey);
88
+ const buf = Buffer.from(ciphertext, "base64");
89
+ const iv = buf.subarray(0, 12);
90
+ const tag = buf.subarray(buf.length - 16);
91
+ const data = buf.subarray(12, buf.length - 16);
92
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
93
+ decipher.setAuthTag(tag);
94
+ return decipher.update(data) + decipher.final("utf8");
95
+ }
96
+
97
+ export function setEncryptedEnv(name: string, value: string): void {
98
+ const config = loadConfig();
99
+ if (!config.encrypted_env) config.encrypted_env = {};
100
+ config.encrypted_env[name] = encryptEnvVar(value);
101
+ saveConfig(config);
102
+ }
103
+
104
+ export function removeEncryptedEnv(name: string): void {
105
+ const config = loadConfig();
106
+ if (config.encrypted_env) {
107
+ delete config.encrypted_env[name];
108
+ saveConfig(config);
109
+ }
110
+ }
111
+
112
+ export function getDecryptedEnv(name: string): string | null {
113
+ const config = loadConfig();
114
+ const enc = config.encrypted_env?.[name];
115
+ if (!enc) return null;
116
+ try { return decryptEnvVar(enc); } catch { return null; }
117
+ }
118
+
119
+ export function listEncryptedEnvNames(): string[] {
120
+ return Object.keys(loadConfig().encrypted_env ?? {});
121
+ }
@@ -0,0 +1,158 @@
1
+ // Session sync — persists chat sessions to Supabase
2
+ // Saves are fire-and-forget (async, non-blocking). Loads happen on startup.
3
+
4
+ import { store } from "../state/store.ts";
5
+ import {
6
+ fetchSessions, createDbSession, updateDbSession, deleteDbSession,
7
+ fetchMessages, insertMessage,
8
+ isLoggedIn,
9
+ } from "./supabase.ts";
10
+ import type { Session } from "../state/types.ts";
11
+ import type { Message } from "../chat/types.ts";
12
+
13
+ // Track which sessions/messages have been synced
14
+ const syncedMessageIds = new Set<string>();
15
+ const knownDbSessionIds = new Set<string>();
16
+
17
+ /**
18
+ * Load sessions from Supabase on startup.
19
+ * Merges with any existing in-memory sessions.
20
+ */
21
+ export async function loadSessions(): Promise<void> {
22
+ if (!(await isLoggedIn())) return;
23
+
24
+ try {
25
+ const dbSessions = await fetchSessions();
26
+ for (const dbs of dbSessions) knownDbSessionIds.add(dbs.id);
27
+ if (dbSessions.length === 0) return;
28
+
29
+ const sessions: Session[] = [];
30
+
31
+ for (const dbs of dbSessions) {
32
+ const dbMessages = await fetchMessages(dbs.id);
33
+
34
+ // Skip empty sessions — don't load chats with no messages
35
+ if (dbMessages.length === 0) continue;
36
+
37
+ sessions.push({
38
+ id: dbs.id,
39
+ name: dbs.name,
40
+ messages: dbMessages.map((m) => ({
41
+ id: m.id,
42
+ role: m.role as any,
43
+ content: m.content,
44
+ timestamp: new Date(m.created_at).getTime(),
45
+ status: m.status as any,
46
+ })),
47
+ createdAt: new Date(dbs.created_at).getTime(),
48
+ updatedAt: new Date(dbs.updated_at).getTime(),
49
+ pinned: false,
50
+ mode: (dbs.mode as any) ?? "research",
51
+ isStreaming: false,
52
+ streamingMsgId: null,
53
+ strategyDraft: dbs.strategy_draft ?? null,
54
+ });
55
+
56
+ // Mark all loaded messages as synced
57
+ for (const m of dbMessages) syncedMessageIds.add(m.id);
58
+ }
59
+
60
+ if (sessions.length > 0) {
61
+ const sessionIds = new Set(sessions.map((s) => s.id));
62
+
63
+ // Only keep in-memory sessions that have actual messages (user was actively chatting)
64
+ const currentState = store.get();
65
+ const unsaved = currentState.sessions.filter((s) =>
66
+ !sessionIds.has(s.id) && s.messages.length > 0
67
+ );
68
+ const merged = [...sessions, ...unsaved];
69
+ const mergedIds = new Set(merged.map((s) => s.id));
70
+
71
+ // Restore open chat IDs from config, filtered + deduped
72
+ const { loadConfig } = await import("./config.ts");
73
+ const cfg = loadConfig();
74
+ const savedOpenIds = [...new Set(
75
+ (cfg.open_chat_ids ?? []).filter((id: string) => mergedIds.has(id))
76
+ )];
77
+
78
+ const openIds = savedOpenIds.length > 0 ? savedOpenIds : [sessions[0]!.id];
79
+ const activeId = mergedIds.has(openIds[0]!) ? openIds[0]! : sessions[0]!.id;
80
+
81
+ store.update({
82
+ sessions: merged,
83
+ activeSessionId: activeId,
84
+ openTabIds: openIds,
85
+ });
86
+ }
87
+ } catch {}
88
+ }
89
+
90
+ /**
91
+ * Save the current active session to Supabase.
92
+ * Creates the session if it doesn't exist, inserts new messages.
93
+ */
94
+ export async function saveActiveSession(): Promise<void> {
95
+ if (!(await isLoggedIn())) return;
96
+
97
+ const session = store.getActiveSession();
98
+ if (!session || session.messages.length === 0) return;
99
+
100
+ try {
101
+ let dbId = session.id;
102
+
103
+ if (!knownDbSessionIds.has(session.id)) {
104
+ // Session not in DB yet — create it
105
+ const newId = await createDbSession(session.name, session.mode ?? "research");
106
+ if (!newId) return;
107
+
108
+ // Remap: update the session ID in the store to match the DB UUID
109
+ const oldId = session.id;
110
+ const state = store.get();
111
+ const sessions = state.sessions.map((s) =>
112
+ s.id === oldId ? { ...s, id: newId } : s
113
+ );
114
+ const openTabIds = state.openTabIds.map((id) => id === oldId ? newId : id);
115
+ const activeSessionId = state.activeSessionId === oldId ? newId : state.activeSessionId;
116
+ store.update({ sessions, openTabIds, activeSessionId });
117
+
118
+ knownDbSessionIds.add(newId);
119
+ dbId = newId;
120
+ }
121
+
122
+ // Batch insert unsynced messages
123
+ const unsynced = session.messages.filter((msg) => !syncedMessageIds.has(msg.id));
124
+ for (const msg of unsynced) {
125
+ await insertMessage(dbId, {
126
+ role: msg.role,
127
+ content: msg.content,
128
+ status: msg.status,
129
+ });
130
+ syncedMessageIds.add(msg.id);
131
+ }
132
+
133
+ // Update session metadata
134
+ await updateDbSession(dbId, { name: session.name }).catch(() => {});
135
+ } catch {}
136
+ }
137
+
138
+ /**
139
+ * Delete a session from Supabase.
140
+ */
141
+ export async function deleteSessionFromDb(sessionId: string): Promise<void> {
142
+ if (!(await isLoggedIn())) return;
143
+ try { await deleteDbSession(sessionId); } catch {}
144
+ }
145
+
146
+ /**
147
+ * Auto-save on a timer (every 30 seconds).
148
+ */
149
+ let saveTimer: ReturnType<typeof setInterval> | null = null;
150
+
151
+ export function startAutoSave(): void {
152
+ if (saveTimer) return;
153
+ saveTimer = setInterval(() => saveActiveSession(), 30000);
154
+ }
155
+
156
+ export function stopAutoSave(): void {
157
+ if (saveTimer) { clearInterval(saveTimer); saveTimer = null; }
158
+ }
@@ -0,0 +1,376 @@
1
+ // Supabase client — always-connected, auto-refreshing session
2
+ // Once authenticated, the client maintains the session automatically.
3
+ // DB operations (chats, messages) work as long as the user is logged in.
4
+
5
+ import { createClient, type SupabaseClient, type Session } from "@supabase/supabase-js";
6
+ import { loadConfig, saveConfig, getAuthUrl, SUPABASE_URL, SUPABASE_ANON_KEY, encryptEnvVar, decryptEnvVar, getApiKey } from "./config.ts";
7
+ import { platform } from "./client.ts";
8
+ import type { ContentBlock } from "../chat/types.ts";
9
+
10
+ let client: SupabaseClient | null = null;
11
+
12
+ // ── Client (singleton, auto-refreshes tokens) ──
13
+
14
+ export function getSupabase(): SupabaseClient {
15
+ if (client) return client;
16
+
17
+ client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
18
+ auth: {
19
+ persistSession: false,
20
+ autoRefreshToken: true,
21
+ detectSessionInUrl: false,
22
+ },
23
+ });
24
+
25
+ // Listen for auth state changes — keep config in sync
26
+ client.auth.onAuthStateChange((_event, session) => {
27
+ if (session) {
28
+ const config = loadConfig();
29
+ // Encrypt tokens at rest if we have an API key for key derivation
30
+ if (getApiKey()) {
31
+ try {
32
+ config.supabase_session = {
33
+ access_token: encryptEnvVar(session.access_token),
34
+ refresh_token: encryptEnvVar(session.refresh_token),
35
+ };
36
+ config.session_encrypted = true;
37
+ } catch {
38
+ // Fallback to plaintext if encryption fails
39
+ config.supabase_session = {
40
+ access_token: session.access_token,
41
+ refresh_token: session.refresh_token,
42
+ };
43
+ config.session_encrypted = false;
44
+ }
45
+ } else {
46
+ config.supabase_session = {
47
+ access_token: session.access_token,
48
+ refresh_token: session.refresh_token,
49
+ };
50
+ config.session_encrypted = false;
51
+ }
52
+ config.user_email = session.user.email ?? config.user_email;
53
+ config.user_id = session.user.id;
54
+ saveConfig(config);
55
+ }
56
+ });
57
+
58
+ return client;
59
+ }
60
+
61
+ // ── User info ──
62
+
63
+ /** Encrypt and save session tokens to config */
64
+ function saveSessionTokens(config: ReturnType<typeof loadConfig>, accessToken: string, refreshToken: string): void {
65
+ if (getApiKey()) {
66
+ try {
67
+ config.supabase_session = { access_token: encryptEnvVar(accessToken), refresh_token: encryptEnvVar(refreshToken) };
68
+ config.session_encrypted = true;
69
+ return;
70
+ } catch {}
71
+ }
72
+ config.supabase_session = { access_token: accessToken, refresh_token: refreshToken };
73
+ config.session_encrypted = false;
74
+ }
75
+
76
+ export function getUser(): { id: string; email: string } | null {
77
+ const config = loadConfig();
78
+ if (config.user_id) {
79
+ return { id: config.user_id, email: config.user_email ?? "" };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export async function isLoggedIn(): Promise<boolean> {
85
+ const sb = getSupabase();
86
+ const { data } = await sb.auth.getSession();
87
+ if (data.session) return true;
88
+
89
+ // Try to restore from config
90
+ const restored = await restoreSession();
91
+ return restored;
92
+ }
93
+
94
+ // ── Session restore ──
95
+
96
+ export async function restoreSession(): Promise<boolean> {
97
+ const sb = getSupabase();
98
+ const config = loadConfig();
99
+
100
+ if (config.supabase_session?.refresh_token) {
101
+ let accessToken = config.supabase_session.access_token;
102
+ let refreshToken = config.supabase_session.refresh_token;
103
+
104
+ // Decrypt if encrypted
105
+ if (config.session_encrypted && getApiKey()) {
106
+ try {
107
+ accessToken = decryptEnvVar(accessToken);
108
+ refreshToken = decryptEnvVar(refreshToken);
109
+ } catch {
110
+ // Decryption failed — tokens corrupted or key changed
111
+ delete config.supabase_session;
112
+ saveConfig(config);
113
+ return config.api_key ? (platform.setApiKey(config.api_key), true) : false;
114
+ }
115
+ }
116
+
117
+ const { data, error } = await sb.auth.setSession({
118
+ access_token: accessToken,
119
+ refresh_token: refreshToken,
120
+ });
121
+
122
+ if (!error && data.session) {
123
+ // Session restored — auto-refresh will keep it alive
124
+ if (config.api_key) platform.setApiKey(config.api_key);
125
+ return true;
126
+ }
127
+
128
+ // Tokens fully expired — user needs to /login again
129
+ delete config.supabase_session;
130
+ saveConfig(config);
131
+ }
132
+
133
+ // API key still works for the LLM proxy even without a Supabase session
134
+ if (config.api_key) {
135
+ platform.setApiKey(config.api_key);
136
+ return true;
137
+ }
138
+
139
+ return false;
140
+ }
141
+
142
+ // ── Has live Supabase session (needed for DB operations) ──
143
+
144
+ export async function hasLiveSession(): Promise<boolean> {
145
+ const sb = getSupabase();
146
+ const { data } = await sb.auth.getSession();
147
+ return !!data.session;
148
+ }
149
+
150
+ // ── Browser login flow ──
151
+
152
+ function generateSessionId(): string {
153
+ const bytes = new Uint8Array(32);
154
+ crypto.getRandomValues(bytes);
155
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
156
+ }
157
+
158
+ export async function loginWithBrowser(): Promise<{ success: boolean; error?: string; email?: string }> {
159
+ const sb = getSupabase();
160
+ const sessionId = generateSessionId();
161
+ const authBaseUrl = getAuthUrl();
162
+
163
+ // Create pending auth session in DB
164
+ const { error: insertError } = await sb.from("cli_auth_sessions").insert({
165
+ id: sessionId,
166
+ status: "pending",
167
+ });
168
+ if (insertError) {
169
+ return { success: false, error: `Auth init failed: ${insertError.message}` };
170
+ }
171
+
172
+ // Open browser
173
+ const authUrl = `${authBaseUrl}/auth/cli?session_id=${sessionId}`;
174
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
175
+ Bun.spawn([openCmd, authUrl], { stdout: "ignore", stderr: "ignore" });
176
+
177
+ // Poll until auth completes
178
+ const pollUrl = `${authBaseUrl}/api/auth/cli/poll?session_id=${sessionId}`;
179
+ const deadline = Date.now() + 90_000;
180
+
181
+ while (Date.now() < deadline) {
182
+ await new Promise((r) => setTimeout(r, 2000));
183
+ try {
184
+ const res = await fetch(pollUrl);
185
+ if (res.status === 202) continue;
186
+
187
+ if (res.ok) {
188
+ const data = await res.json() as { status: string; access_token: string; refresh_token: string; email: string };
189
+ if (data.status === "ready" && data.access_token) {
190
+ const { data: sd, error: se } = await sb.auth.setSession({
191
+ access_token: data.access_token,
192
+ refresh_token: data.refresh_token,
193
+ });
194
+ if (se || !sd.session) return { success: false, error: se?.message ?? "Session error" };
195
+
196
+ // Save everything
197
+ const config = loadConfig();
198
+ saveSessionTokens(config, sd.session.access_token, sd.session.refresh_token);
199
+ config.user_email = data.email || sd.session.user.email;
200
+ config.user_id = sd.session.user.id;
201
+
202
+ if (!config.api_key) {
203
+ try {
204
+ const apiKey = await createApiKey();
205
+ if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
206
+ } catch {}
207
+ } else {
208
+ platform.setApiKey(config.api_key);
209
+ }
210
+
211
+ saveConfig(config);
212
+ return { success: true, email: data.email };
213
+ }
214
+ }
215
+ if (res.status === 410) return { success: false, error: "Session expired. Type /login to sign in again." };
216
+ if (res.status === 404) return { success: false, error: "Auth session not found." };
217
+ } catch {}
218
+ }
219
+
220
+ try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch {}
221
+ return { success: false, error: "Login timed out (90s). Try /login again." };
222
+ }
223
+
224
+ // ── Email/password login ──
225
+
226
+ export async function loginWithPassword(email: string, password: string): Promise<{ success: boolean; error?: string; email?: string }> {
227
+ const sb = getSupabase();
228
+ const { data, error } = await sb.auth.signInWithPassword({ email, password });
229
+ if (error) return { success: false, error: error.message };
230
+ if (!data.session) return { success: false, error: "No session returned" };
231
+
232
+ const config = loadConfig();
233
+ saveSessionTokens(config, data.session.access_token, data.session.refresh_token);
234
+ config.user_email = data.session.user.email ?? email;
235
+ config.user_id = data.session.user.id;
236
+
237
+ if (!config.api_key) {
238
+ try {
239
+ const apiKey = await createApiKey();
240
+ if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
241
+ } catch {}
242
+ } else {
243
+ platform.setApiKey(config.api_key);
244
+ }
245
+
246
+ saveConfig(config);
247
+ return { success: true, email: data.session.user.email ?? email };
248
+ }
249
+
250
+ // ── API Key ──
251
+
252
+ async function createApiKey(): Promise<string | null> {
253
+ const sb = getSupabase();
254
+ const { data: { session } } = await sb.auth.getSession();
255
+ if (!session) return null;
256
+
257
+ try {
258
+ const bytes = new Uint8Array(32);
259
+ crypto.getRandomValues(bytes);
260
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
261
+ const rawKey = `hz_live_${hex}`;
262
+ const hashBuf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawKey));
263
+ const keyHash = Array.from(new Uint8Array(hashBuf)).map((b) => b.toString(16).padStart(2, "0")).join("");
264
+
265
+ const expiresAt = new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString();
266
+ const { error } = await sb.from("api_keys").insert({
267
+ user_id: session.user.id,
268
+ name: "Horizon CLI",
269
+ key_hash: keyHash,
270
+ key_prefix: rawKey.slice(0, 12),
271
+ expires_at: expiresAt,
272
+ scopes: ["read", "write", "deploy"],
273
+ });
274
+ if (error) return null;
275
+ return rawKey;
276
+ } catch { return null; }
277
+ }
278
+
279
+ // ── Logout ──
280
+
281
+ export async function signOut(): Promise<void> {
282
+ const sb = getSupabase();
283
+ await sb.auth.signOut().catch(() => {});
284
+ const config = loadConfig();
285
+ delete config.supabase_session;
286
+ saveConfig(config);
287
+ }
288
+
289
+ // ── Session CRUD (uses live Supabase session for RLS) ──
290
+
291
+ async function getUserId(): Promise<string | null> {
292
+ const sb = getSupabase();
293
+ const { data } = await sb.auth.getSession();
294
+ return data.session?.user.id ?? null;
295
+ }
296
+
297
+ export interface DbSession {
298
+ id: string; name: string; mode: string;
299
+ strategy_draft: any; created_at: string; updated_at: string;
300
+ }
301
+
302
+ export async function fetchSessions(): Promise<DbSession[]> {
303
+ const userId = await getUserId();
304
+ if (!userId) return [];
305
+ const sb = getSupabase();
306
+ const { data } = await sb.from("tui_sessions")
307
+ .select("id, name, mode, strategy_draft, created_at, updated_at")
308
+ .order("updated_at", { ascending: false }).limit(50);
309
+ return data ?? [];
310
+ }
311
+
312
+ export async function createDbSession(name: string, mode: string): Promise<string | null> {
313
+ const userId = await getUserId();
314
+ if (!userId) return null;
315
+ const sb = getSupabase();
316
+ const { data } = await sb.from("tui_sessions")
317
+ .insert({ user_id: userId, name, mode })
318
+ .select("id").single();
319
+ return data?.id ?? null;
320
+ }
321
+
322
+ export async function updateDbSession(sessionId: string, updates: {
323
+ name?: string; mode?: string; strategy_draft?: any;
324
+ }): Promise<void> {
325
+ const userId = await getUserId();
326
+ if (!userId) return;
327
+ const sb = getSupabase();
328
+ await sb.from("tui_sessions").update(updates).eq("id", sessionId);
329
+ }
330
+
331
+ export async function deleteDbSession(sessionId: string): Promise<void> {
332
+ const userId = await getUserId();
333
+ if (!userId) return;
334
+ const sb = getSupabase();
335
+ await sb.from("tui_sessions").delete().eq("id", sessionId);
336
+ }
337
+
338
+ // ── Message CRUD ──
339
+
340
+ export interface DbMessage {
341
+ id: string; role: string; content: ContentBlock[];
342
+ status: string; created_at: string;
343
+ }
344
+
345
+ export async function fetchMessages(sessionId: string): Promise<DbMessage[]> {
346
+ const userId = await getUserId();
347
+ if (!userId) return [];
348
+ const sb = getSupabase();
349
+ const { data } = await sb.from("tui_messages")
350
+ .select("id, role, content, status, created_at")
351
+ .eq("session_id", sessionId).order("created_at", { ascending: true });
352
+ return data ?? [];
353
+ }
354
+
355
+ export async function insertMessage(sessionId: string, message: {
356
+ role: string; content: ContentBlock[]; status: string;
357
+ }): Promise<string | null> {
358
+ const userId = await getUserId();
359
+ if (!userId) return null;
360
+ const sb = getSupabase();
361
+ const { data } = await sb.from("tui_messages")
362
+ .insert({
363
+ session_id: sessionId, user_id: userId,
364
+ role: message.role, content: message.content, status: message.status,
365
+ }).select("id").single();
366
+ return data?.id ?? null;
367
+ }
368
+
369
+ export async function updateDbMessage(messageId: string, updates: {
370
+ content?: ContentBlock[]; status?: string;
371
+ }): Promise<void> {
372
+ const userId = await getUserId();
373
+ if (!userId) return;
374
+ const sb = getSupabase();
375
+ await sb.from("tui_messages").update(updates).eq("id", messageId);
376
+ }