diff --git a/migrations/20260511000000_user_api_keys.sql b/migrations/20260511000000_user_api_keys.sql
new file mode 100644
index 0000000..92f0bf5
--- /dev/null
+++ b/migrations/20260511000000_user_api_keys.sql
@@ -0,0 +1,18 @@
+-- 20260511000000_user_api_keys.sql
+--
+-- Stores user-provisioned AI provider API keys.
+-- Service role only — the web backend verifies the user JWT before
+-- reading or writing. No public or anon access.
+
+CREATE TABLE IF NOT EXISTS public.user_api_keys (
+ id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
+ user_id uuid NOT NULL,
+ provider text NOT NULL, -- 'openai' | 'anthropic' | 'gemini' | 'grok'
+ key_value text NOT NULL DEFAULT '',
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now(),
+ UNIQUE(user_id, provider)
+);
+
+ALTER TABLE public.user_api_keys ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "service only" ON public.user_api_keys USING (false);
diff --git a/src/account.el b/src/account.el
index 11f8214..2f5a963 100644
--- a/src/account.el
+++ b/src/account.el
@@ -493,7 +493,13 @@ fn account_css() -> String {
.roadmap-items { list-style: none; display: flex; flex-direction: column; gap: .5rem; }
.roadmap-items li { font-family: var(--body); font-size: .875rem; font-weight: 300; color: var(--t2); line-height: 1.6; padding-left: 1rem; position: relative; }
.roadmap-items li::before { content: \"-\"; position: absolute; left: 0; color: var(--navy-65); }
- .signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }"
+ .signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }
+ .api-key-list { display: flex; flex-direction: column; gap: 1rem; }
+ .api-key-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
+ .api-key-provider { display: flex; flex-direction: column; gap: .2rem; min-width: 120px; }
+ .api-key-name { font-size: .875rem; font-weight: 500; color: var(--t1); }
+ .api-key-masked { font-size: .75rem; font-family: monospace; color: var(--t3); }
+ .api-key-actions { display: flex; gap: .5rem; }"
""
}
@@ -804,6 +810,48 @@ fn account_devices_card() -> String {
)
}
+fn api_key_provider_row(provider_id: String, provider_name: String, placeholder: String) -> String {
+ el_div(
+ "class=\"api-key-row\"",
+ el_div(
+ "class=\"api-key-provider\"",
+ el_span("class=\"api-key-name\"", provider_name) +
+ el_span("class=\"api-key-masked\" id=\"apikey-masked-" + provider_id + "\"", "Not configured")
+ ) +
+ "" +
+ el_div(
+ "class=\"api-key-actions\"",
+ el_button("type=\"button\" class=\"btn-primary\" style=\"padding:.5rem 1rem;font-size:.75rem\" onclick=\"saveApiKey('" + provider_id + "')\"", "Save") +
+ el_button("type=\"button\" class=\"btn-ghost\" style=\"padding:.5rem 1rem;font-size:.75rem;display:none\" id=\"apikey-del-" + provider_id + "\" onclick=\"deleteApiKey('" + provider_id + "')\"", "Remove")
+ )
+ )
+}
+
+fn account_api_keys_section() -> String {
+ let providers: String = el_div(
+ "class=\"api-key-list\"",
+ api_key_provider_row("openai", "OpenAI", "sk-...") +
+ api_key_provider_row("anthropic", "Anthropic", "sk-ant-...") +
+ api_key_provider_row("gemini", "Gemini", "AIza...") +
+ api_key_provider_row("grok", "Grok", "xai-...")
+ )
+ el_div(
+ "id=\"api-keys-section\" style=\"display:none\"",
+ el_div(
+ "class=\"card-dark\"",
+ el_div(
+ "class=\"acct-section-header\"",
+ el_p("class=\"card-label\"", "API Keys") +
+ el_p("style=\"font-size:.8125rem;font-weight:300;color:var(--t2);line-height:1.65\"",
+ "Add your own AI provider keys. Neuron uses them directly — your keys, your models, your data."
+ )
+ ) +
+ providers +
+ el_p("id=\"api-keys-msg\" style=\"display:none;font-size:.8rem;margin-top:.75rem\"", "")
+ )
+ )
+}
+
fn account_dashboard_section() -> String {
let header_row: String = el_div(
"class=\"acct-header-row\"",
@@ -828,6 +876,7 @@ fn account_dashboard_section() -> String {
el_div(
"class=\"account-section\"",
account_plan_card() +
+ account_api_keys_section() +
account_roadmap_section() +
account_family_section() +
account_badge_section() +
diff --git a/src/js/account-dashboard.el b/src/js/account-dashboard.el
index b7bdca8..af38ec1 100644
--- a/src/js/account-dashboard.el
+++ b/src/js/account-dashboard.el
@@ -246,11 +246,98 @@ fn main() -> Void {
}
}
+ async function loadApiKeys() {
+ var apiSection = document.getElementById('api-keys-section');
+ if (apiSection) apiSection.style.display = '';
+ try {
+ var sess = await sb.auth.getSession();
+ var token = sess.data && sess.data.session ? sess.data.session.access_token : '';
+ if (!token) return;
+ var r = await fetch('/api/api-keys', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({access_token: token})
+ });
+ var d = await r.json();
+ if (!d.rows || !d.rows.length) return;
+ d.rows.forEach(function(row) {
+ var provider = row.provider;
+ var keyVal = row.key_value || '';
+ var maskedEl = document.getElementById('apikey-masked-' + provider);
+ var delBtn = document.getElementById('apikey-del-' + provider);
+ if (keyVal) {
+ var klen = keyVal.length;
+ var masked = klen <= 10 ? '••••••••' : keyVal.slice(0, 6) + '••••' + keyVal.slice(-4);
+ if (maskedEl) maskedEl.textContent = masked;
+ if (delBtn) delBtn.style.display = '';
+ }
+ });
+ } catch (e) {}
+ }
+
+ window.saveApiKey = async function(provider) {
+ var input = document.getElementById('apikey-input-' + provider);
+ var msg = document.getElementById('api-keys-msg');
+ if (!input || !input.value.trim()) {
+ if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = 'Enter a key first.'; }
+ return;
+ }
+ var keyVal = input.value.trim();
+ var sess = await sb.auth.getSession();
+ var token = sess.data && sess.data.session ? sess.data.session.access_token : '';
+ if (!token) return;
+ try {
+ var r = await fetch('/api/api-keys/save', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({access_token: token, provider: provider, key: keyVal})
+ });
+ var d = await r.json();
+ if (d.ok) {
+ var maskedEl = document.getElementById('apikey-masked-' + provider);
+ var delBtn = document.getElementById('apikey-del-' + provider);
+ if (maskedEl) maskedEl.textContent = d.masked;
+ if (delBtn) delBtn.style.display = '';
+ input.value = '';
+ if (msg) { msg.style.display = 'block'; msg.style.color = 'var(--navy)'; msg.textContent = provider.charAt(0).toUpperCase() + provider.slice(1) + ' key saved.'; }
+ setTimeout(function() { if (msg) msg.style.display = 'none'; }, 3000);
+ } else {
+ if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = d.error || 'Save failed.'; }
+ }
+ } catch (e) {
+ if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = 'Network error.'; }
+ }
+ };
+
+ window.deleteApiKey = async function(provider) {
+ var sess = await sb.auth.getSession();
+ var token = sess.data && sess.data.session ? sess.data.session.access_token : '';
+ if (!token) return;
+ try {
+ var r = await fetch('/api/api-keys/delete', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({access_token: token, provider: provider})
+ });
+ var d = await r.json();
+ if (d.ok) {
+ var maskedEl = document.getElementById('apikey-masked-' + provider);
+ var delBtn = document.getElementById('apikey-del-' + provider);
+ if (maskedEl) maskedEl.textContent = 'Not configured';
+ if (delBtn) delBtn.style.display = 'none';
+ var msg = document.getElementById('api-keys-msg');
+ if (msg) { msg.style.display = 'block'; msg.style.color = 'var(--t3)'; msg.textContent = provider.charAt(0).toUpperCase() + provider.slice(1) + ' key removed.'; }
+ setTimeout(function() { if (msg) msg.style.display = 'none'; }, 3000);
+ }
+ } catch (e) {}
+ };
+
function showDashboard(user) {
hide('signin-section');
show('dashboard-section');
renderUserChip(user);
loadWaitlistData();
+ loadApiKeys();
}
async function init() {
diff --git a/src/main.el b/src/main.el
index 6fe4feb..9f64c5e 100644
--- a/src/main.el
+++ b/src/main.el
@@ -2145,6 +2145,97 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
return "{\"ok\":true}"
}
+ // ── API key provisioning — POST /api/api-keys ────────────────────────────
+ // Returns user's stored provider keys (masked) for display on /account.
+ // Body: { access_token: "" }
+ if str_eq(path, "/api/api-keys") && str_eq(method, "POST") {
+ let ak_jwt: String = json_get_string(body, "access_token")
+ if str_eq(ak_jwt, "") {
+ return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
+ }
+ let ak_url: String = state_get("__supabase_project_url__")
+ let ak_anon: String = state_get("__supabase_anon_key__")
+ let ak_service: String = state_get("__supabase_service_key__")
+ if str_eq(ak_url, "") {
+ return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
+ }
+ let ak_user: String = supabase_auth_user(ak_url, ak_anon, ak_jwt)
+ let ak_uid: String = json_get(ak_user, "id")
+ if str_eq(ak_uid, "") {
+ return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
+ }
+ let ak_q: String = "user_api_keys?select=provider,key_value&user_id=eq." + ak_uid
+ let ak_rows: String = supabase_get(ak_url, ak_service, ak_q)
+ return "{\"rows\":" + ak_rows + "}"
+ }
+
+ // ── API key provisioning — POST /api/api-keys/save ───────────────────────
+ // Upserts a single provider key for the authenticated user.
+ // Body: { access_token: "", provider: "openai"|"anthropic"|"gemini"|"grok", key: "" }
+ if str_eq(path, "/api/api-keys/save") {
+ let aks_jwt: String = json_get_string(body, "access_token")
+ let aks_provider: String = json_get_string(body, "provider")
+ let aks_key: String = json_get_string(body, "key")
+ if str_eq(aks_jwt, "") {
+ return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
+ }
+ if str_eq(aks_provider, "") || str_eq(aks_key, "") {
+ return "{\"__status__\":400,\"error\":\"missing_provider_or_key\"}"
+ }
+ let aks_valid_provider: Bool = str_eq(aks_provider, "openai")
+ || str_eq(aks_provider, "anthropic")
+ || str_eq(aks_provider, "gemini")
+ || str_eq(aks_provider, "grok")
+ if !aks_valid_provider {
+ return "{\"__status__\":400,\"error\":\"invalid_provider\"}"
+ }
+ let aks_url: String = state_get("__supabase_project_url__")
+ let aks_anon: String = state_get("__supabase_anon_key__")
+ let aks_service: String = state_get("__supabase_service_key__")
+ if str_eq(aks_url, "") {
+ return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
+ }
+ let aks_user: String = supabase_auth_user(aks_url, aks_anon, aks_jwt)
+ let aks_uid: String = json_get(aks_user, "id")
+ if str_eq(aks_uid, "") {
+ return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
+ }
+ let aks_row: String = "{\"user_id\":\"" + aks_uid + "\",\"provider\":\"" + aks_provider + "\",\"key_value\":\"" + aks_key + "\",\"updated_at\":\"now()\"}"
+ let _aks_resp: String = supabase_insert(aks_url, aks_service, "user_api_keys?on_conflict=user_id,provider", aks_row)
+ let aks_klen: Int = str_len(aks_key)
+ let aks_masked: String = if aks_klen <= 10 {
+ str_repeat("•", aks_klen)
+ } else {
+ str_slice(aks_key, 0, 6) + "••••" + str_slice(aks_key, aks_klen - 4, aks_klen)
+ }
+ return "{\"ok\":true,\"masked\":\"" + aks_masked + "\"}"
+ }
+
+ // ── API key provisioning — POST /api/api-keys/delete ─────────────────────
+ // Soft-deletes a provider key by clearing key_value for the authenticated user.
+ // Body: { access_token: "", provider: "openai"|"anthropic"|"gemini"|"grok" }
+ if str_eq(path, "/api/api-keys/delete") {
+ let akd_jwt: String = json_get_string(body, "access_token")
+ let akd_provider: String = json_get_string(body, "provider")
+ if str_eq(akd_jwt, "") {
+ return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
+ }
+ let akd_url: String = state_get("__supabase_project_url__")
+ let akd_anon: String = state_get("__supabase_anon_key__")
+ let akd_service: String = state_get("__supabase_service_key__")
+ if str_eq(akd_url, "") {
+ return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
+ }
+ let akd_user: String = supabase_auth_user(akd_url, akd_anon, akd_jwt)
+ let akd_uid: String = json_get(akd_user, "id")
+ if str_eq(akd_uid, "") {
+ return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
+ }
+ let akd_row: String = "{\"user_id\":\"" + akd_uid + "\",\"provider\":\"" + akd_provider + "\",\"key_value\":\"\",\"updated_at\":\"now()\"}"
+ let _akd_resp: String = supabase_insert(akd_url, akd_service, "user_api_keys?on_conflict=user_id,provider", akd_row)
+ return "{\"ok\":true}"
+ }
+
// ── Fallback ──────────────────────────────────────────────────────────────
return "{\"__status__\":404,\"error\":\"not found\"}"
}