Merge pull request 'Stage: pricing buttons, API keys, reasoning note, enterprise contacts' (#91) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m19s
Dev — Build & local smoke test / build-smoke (push) Successful in 2m19s
Merge PR #91: dev stage batch
This commit was merged in pull request #91.
This commit is contained in:
Vendored
+4
@@ -1903,7 +1903,11 @@ el_val_t page_css(void) {
|
||||
" text-align: center;\n"
|
||||
" padding: 0.875rem 1.5rem;\n"
|
||||
" transition: background 300ms, opacity 300ms;\n"
|
||||
" background: var(--navy);\n"
|
||||
" color: #fff;\n"
|
||||
" box-shadow: 0 2px 16px rgba(0,82,160,.25);\n"
|
||||
" }\n"
|
||||
" button.pricing-cta-navy:hover, button.pricing-cta-solid:hover, button.pricing-cta-ghost:hover { background: #0078D4; }\n"
|
||||
" button[disabled] { opacity: 0.6; cursor: not-allowed; }\n"
|
||||
"\n"
|
||||
" \n</style>"
|
||||
|
||||
@@ -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);
|
||||
+124
-1
@@ -493,7 +493,29 @@ 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: 1.5rem; }
|
||||
.api-key-entry { border-bottom: 1px solid var(--border); padding-bottom: 1.25rem; }
|
||||
.api-key-entry:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.api-key-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: .625rem; }
|
||||
.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-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.api-key-actions { display: flex; gap: .5rem; flex-shrink: 0; }
|
||||
.api-key-help { margin-top: .625rem; }
|
||||
.api-key-help summary { font-size: .75rem; font-weight: 500; letter-spacing: .03em; color: var(--navy-65); cursor: pointer; list-style: none; padding: .25rem 0; user-select: none; }
|
||||
.api-key-help summary::-webkit-details-marker { display: none; }
|
||||
.api-key-help summary::before { content: \"\\25B8 \"; font-size: .6rem; }
|
||||
.api-key-help[open] summary::before { content: \"\\25BE \"; }
|
||||
.api-key-help-body { padding: .75rem 0 .125rem; }
|
||||
.api-key-help-body ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: .375rem; }
|
||||
.api-key-help-body li { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.55; }
|
||||
.api-key-help-body a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.api-key-help-body a:hover { color: var(--navy); }
|
||||
.api-key-note { font-size: .75rem; font-weight: 300; color: var(--t3); margin-top: .625rem; line-height: 1.55; padding: .5rem .75rem; background: var(--bg2); border-left: 2px solid var(--navy-b); }
|
||||
.api-key-note a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.api-key-model-note { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.65; padding: .75rem 1rem; background: var(--navy-d); border-left: 2px solid var(--navy); margin-bottom: 1.5rem; }
|
||||
.api-key-model-note strong { font-weight: 600; color: var(--t1); }"
|
||||
"<style>" + css + "</style>"
|
||||
}
|
||||
|
||||
@@ -804,6 +826,106 @@ fn account_devices_card() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn api_key_provider_row(provider_id: String, provider_name: String, placeholder: String, instructions: String) -> String {
|
||||
el_div(
|
||||
"class=\"api-key-entry\"",
|
||||
el_div(
|
||||
"class=\"api-key-header\"",
|
||||
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-row\"",
|
||||
"<input type=\"password\" id=\"apikey-input-" + provider_id + "\" class=\"acct-input\" placeholder=\"" + placeholder + "\" autocomplete=\"off\" style=\"margin-bottom:0;flex:1\">" +
|
||||
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")
|
||||
)
|
||||
) +
|
||||
instructions
|
||||
)
|
||||
}
|
||||
|
||||
fn account_api_keys_section() -> String {
|
||||
let openai_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get an OpenAI key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://platform.openai.com/api-keys\" target=\"_blank\" rel=\"noopener\">platform.openai.com/api-keys ↗</a></li>" +
|
||||
"<li>Click <strong>Create new secret key</strong></li>" +
|
||||
"<li>Give it a name (e.g. “Neuron”), then click <strong>Create secret key</strong></li>" +
|
||||
"<li>Copy the key immediately — it is only shown once</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">You need billing set up before making API calls. Add a payment method at <a href=\"https://platform.openai.com/settings/organization/billing\" target=\"_blank\" rel=\"noopener\">platform.openai.com ↗</a></p>" +
|
||||
"</div></details>"
|
||||
|
||||
let anthropic_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get an Anthropic key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://console.anthropic.com/settings/keys\" target=\"_blank\" rel=\"noopener\">console.anthropic.com/settings/keys ↗</a></li>" +
|
||||
"<li>Click <strong>Create Key</strong></li>" +
|
||||
"<li>Give it a name (e.g. “Neuron”), then click <strong>Create Key</strong></li>" +
|
||||
"<li>Copy the key immediately — it is only shown once</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">You need to add credits before making API calls. Go to <a href=\"https://console.anthropic.com/settings/plans\" target=\"_blank\" rel=\"noopener\">console.anthropic.com → Plans & Billing ↗</a> to add credits.</p>" +
|
||||
"</div></details>"
|
||||
|
||||
let gemini_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get a Gemini key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://aistudio.google.com/apikey\" target=\"_blank\" rel=\"noopener\">aistudio.google.com/apikey ↗</a></li>" +
|
||||
"<li>Sign in with your Google account if prompted</li>" +
|
||||
"<li>Click <strong>Create API key</strong></li>" +
|
||||
"<li>Select an existing Google Cloud project or create a new one</li>" +
|
||||
"<li>Copy the key</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">The free tier (Gemini 1.5 Flash) has generous rate limits. Paid usage is billed through your Google Cloud account.</p>" +
|
||||
"</div></details>"
|
||||
|
||||
let grok_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get a Grok key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://console.x.ai/\" target=\"_blank\" rel=\"noopener\">console.x.ai ↗</a></li>" +
|
||||
"<li>Sign in with your X (Twitter) account</li>" +
|
||||
"<li>In the left sidebar, click <strong>API Keys</strong></li>" +
|
||||
"<li>Click <strong>Create API Key</strong>, give it a name</li>" +
|
||||
"<li>Copy the key immediately — it is only shown once</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">xAI offers free monthly credits to new accounts. Usage beyond the free tier is billed per token.</p>" +
|
||||
"</div></details>"
|
||||
|
||||
let providers: String = el_div(
|
||||
"class=\"api-key-list\"",
|
||||
api_key_provider_row("openai", "OpenAI", "sk-...", openai_help) +
|
||||
api_key_provider_row("anthropic", "Anthropic", "sk-ant-...", anthropic_help) +
|
||||
api_key_provider_row("gemini", "Gemini", "AIza...", gemini_help) +
|
||||
api_key_provider_row("grok", "Grok", "xai-...", grok_help)
|
||||
)
|
||||
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."
|
||||
)
|
||||
) +
|
||||
el_div(
|
||||
"class=\"api-key-model-note\"",
|
||||
"<strong>For best performance, use a reasoning model.</strong> o4-mini or o3 (OpenAI) · Claude Sonnet 4 (Anthropic) · Gemini 2.5 Pro (Google) · Grok-3 (xAI). You choose the model in the app — any model works, reasoning models are where Neuron shines. <a href=\"#\" style=\"color:var(--navy-65);text-decoration:underline;text-underline-offset:2px\">Neuron Inference</a> — our own model layer, priced below the major APIs — launches Q3 2026 and becomes the default."
|
||||
) +
|
||||
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 +950,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() +
|
||||
|
||||
+64
-2
@@ -189,6 +189,22 @@ fn enterprise() -> String {
|
||||
)
|
||||
)
|
||||
|
||||
let contact_block: String = el_div(
|
||||
"class=\"ent-contact-block reveal\"",
|
||||
el_div(
|
||||
"class=\"ent-contact-card\"",
|
||||
el_p("class=\"ent-contact-role\"", "Sales") +
|
||||
el_a("mailto:enterprise@neurontechnologies.ai", "class=\"ent-contact-email\"", "enterprise@neurontechnologies.ai") +
|
||||
el_p("class=\"ent-contact-desc\"", "Pricing, deployment options, and enterprise agreements.")
|
||||
) +
|
||||
el_div(
|
||||
"class=\"ent-contact-card\"",
|
||||
el_p("class=\"ent-contact-role\"", "Security") +
|
||||
el_a("mailto:security@neurontechnologies.ai", "class=\"ent-contact-email\"", "security@neurontechnologies.ai") +
|
||||
el_p("class=\"ent-contact-desc\"", "Vulnerability disclosure, compliance review, and security documentation.")
|
||||
)
|
||||
)
|
||||
|
||||
let enterprise_box: String = el_div(
|
||||
"class=\"enterprise-box reveal\"",
|
||||
el_p("class=\"ent-who-label\"", "Who I work with") +
|
||||
@@ -203,7 +219,53 @@ fn enterprise() -> String {
|
||||
enterprise_inquiry_form()
|
||||
)
|
||||
|
||||
let style_css: String = ".ent-inquiry-form {
|
||||
let style_css: String = ".ent-contact-block {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.ent-contact-card {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(0,82,160,.18);
|
||||
background: rgba(0,82,160,.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
}
|
||||
.ent-contact-role {
|
||||
font-family: var(--body);
|
||||
font-size: .65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--navy);
|
||||
margin-bottom: .1rem;
|
||||
}
|
||||
.ent-contact-email {
|
||||
font-family: var(--body);
|
||||
font-size: .9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--t1);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(0,82,160,.25);
|
||||
padding-bottom: .1rem;
|
||||
transition: border-color 200ms, color 200ms;
|
||||
width: fit-content;
|
||||
}
|
||||
.ent-contact-email:hover { color: var(--navy); border-color: var(--navy); }
|
||||
.ent-contact-desc {
|
||||
font-family: var(--body);
|
||||
font-size: .8125rem;
|
||||
font-weight: 300;
|
||||
color: var(--t3);
|
||||
line-height: 1.55;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ent-contact-block { grid-template-columns: 1fr; }
|
||||
}
|
||||
.ent-inquiry-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
@@ -216,7 +278,7 @@ fn enterprise() -> String {
|
||||
|
||||
el_section(
|
||||
"id=\"enterprise\" aria-label=\"Enterprise\"",
|
||||
el_div("class=\"container\"", header + enterprise_cap_cards() + enterprise_box) +
|
||||
el_div("class=\"container\"", header + enterprise_cap_cards() + contact_block + enterprise_box) +
|
||||
"<style>" + style_css + "</style>" +
|
||||
el_script_src("/js/enterprise.js", true)
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -295,11 +295,11 @@ fn main() -> Void {
|
||||
session.messages.push({ role: 'ai', text: reply });
|
||||
saveSession(session);
|
||||
} else {
|
||||
addMsg('ai', 'Hey. What\'s on your mind?', true);
|
||||
addMsg('ai', \"Hey. What's on your mind?\", true);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
addMsg('ai', 'Hey. What\'s on your mind?', true);
|
||||
addMsg('ai', \"Hey. What's on your mind?\", true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -532,7 +532,7 @@ fn main() -> Void {
|
||||
var ss = secsLeft % 60;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
||||
var ts = hh > 0 ? (hh + ':' + pad(mm) + ':' + pad(ss)) : (pad(mm) + ':' + pad(ss));
|
||||
return 'You\'ve had 10 conversations today. Come back in ' + ts + '.';
|
||||
return \"You've had 10 conversations today. Come back in \" + ts + \".\";
|
||||
};
|
||||
addMsg('ai', _showRateTimer());
|
||||
// Update the last ai message with a live ticker
|
||||
@@ -544,7 +544,7 @@ fn main() -> Void {
|
||||
if (lastAi) { lastAi.textContent = _showRateTimer(); }
|
||||
if (Math.floor(Date.now() / 1000) >= d.reset_at) {
|
||||
clearInterval(_timerInterval);
|
||||
if (lastAi) { lastAi.textContent = 'You\'re all set — conversations reset. Say hello!'; }
|
||||
if (lastAi) { lastAi.textContent = \"You're all set — conversations reset. Say hello!\"; }
|
||||
if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; }
|
||||
if (btn) { btn.disabled = false; }
|
||||
}
|
||||
|
||||
+92
@@ -1172,6 +1172,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
|| str_eq(req_origin, "https://www.neurontechnologies.ai")
|
||||
|| str_starts_with(req_origin, "http://localhost:")
|
||||
|| str_starts_with(req_origin, "http://127.0.0.1:")
|
||||
|| str_starts_with(req_origin, "https://marketing-stage-")
|
||||
if !origin_ok {
|
||||
return "{\"__status__\":403,\"error\":\"forbidden\"}"
|
||||
}
|
||||
@@ -2144,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: "<jwt>" }
|
||||
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: "<jwt>", provider: "openai"|"anthropic"|"gemini"|"grok", key: "<value>" }
|
||||
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: "<jwt>", 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\"}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user