From cf75cf761b29663f2e1b9242dcec55e0aa23aec1 Mon Sep 17 00:00:00 2001 From: Matt Lehman Date: Mon, 9 Feb 2026 22:57:08 -0500 Subject: [PATCH] Enforce parent BYOK billing mode for hosted usage --- README.md | 1 + docs/fly-api-rollout.md | 11 +-- fly.toml | 2 +- interview-app/.env.example | 6 +- interview-app/builder.js | 7 +- interview-app/fly.toml.example | 2 +- interview-app/mechanic.js | 7 +- interview-app/public/index.html | 163 +++++++++++++++++++++++++------- interview-app/run.sh | 4 +- interview-app/server.js | 89 +++++++++++++++-- interview-app/test.js | 91 ++++++++++++++++++ 11 files changed, 325 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 87bb72e..e114d22 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ make yappybara-launch - `YAPPY_BILLING_MODE=subscription` (default): subscription-first mode. If `ANTHROPIC_API_KEY` is set, Yappybara ignores it to avoid accidental API charges. - `YAPPY_BILLING_MODE=api`: usage-based API billing (requires `ANTHROPIC_API_KEY`). +- `YAPPY_BILLING_MODE=user_api`: parent BYOK mode. Server ignores its own API key and requires each family to provide their own key in-browser. - `YAPPY_BILLING_MODE=auto`: uses API billing when a key is present, otherwise Claude login/subscription. ### Claude Code Plugin diff --git a/docs/fly-api-rollout.md b/docs/fly-api-rollout.md index e69bc65..2a02314 100644 --- a/docs/fly-api-rollout.md +++ b/docs/fly-api-rollout.md @@ -13,7 +13,7 @@ This is the path to move Yappybara from local-only to a hosted service parents c - Native app deployment and config (`fly deploy`, `fly.toml`): [Fly Launch](https://fly.io/docs/launch/), [App Config](https://fly.io/docs/reference/configuration/). - Persistent storage with volumes: [Fly Volumes](https://fly.io/docs/volumes/overview/). -- Encrypted runtime secrets (`ANTHROPIC_API_KEY`): [Fly Secrets](https://fly.io/docs/apps/secrets/). +- Optional encrypted runtime secrets (only if you choose server-owned billing): [Fly Secrets](https://fly.io/docs/apps/secrets/). - Custom subdomains + certs: [Fly Custom Domains](https://fly.io/docs/networking/custom-domain/). - Pricing reference: [Fly Pricing](https://fly.io/docs/about/pricing/). @@ -35,12 +35,11 @@ fly apps create yappybara-api fly volumes create yappybara_data --region ord --size 3 ``` -Set required secrets: +Set required runtime env: ```bash -fly secrets set ANTHROPIC_API_KEY=... \ - YAPPY_MODE=claude \ - YAPPY_BILLING_MODE=api \ +fly secrets set YAPPY_MODE=claude \ + YAPPY_BILLING_MODE=user_api \ YAPPY_DEPLOY_DOMAIN=yappybara.dev \ YAPPY_DEPLOY_PRICE_USD=9 ``` @@ -121,7 +120,7 @@ Practical launch strategy: 2. Persist all queue/build data to `/data` volume. 3. Add rate limiting before public launch. 4. Keep parent-supervision disclaimers visible in hosted UI. -5. Keep API key only in Fly secrets, never in client code. +5. For `YAPPY_BILLING_MODE=user_api`, do not set a server Anthropic key; each family supplies their own key in browser. ## Next technical increments diff --git a/fly.toml b/fly.toml index 14e708c..99ef505 100644 --- a/fly.toml +++ b/fly.toml @@ -7,7 +7,7 @@ primary_region = "ord" [env] PORT = "8080" YAPPY_MODE = "claude" - YAPPY_BILLING_MODE = "api" + YAPPY_BILLING_MODE = "user_api" YAPPY_STORAGE_DIR = "/data" YAPPY_DEPLOY_DOMAIN = "yappybara.dev" YAPPY_DEPLOY_PRICE_USD = "9" diff --git a/interview-app/.env.example b/interview-app/.env.example index 26be710..51f8a49 100644 --- a/interview-app/.env.example +++ b/interview-app/.env.example @@ -1,12 +1,12 @@ # Minimal env for hosted API mode (Fly.io) PORT=8080 YAPPY_MODE=claude -YAPPY_BILLING_MODE=api +YAPPY_BILLING_MODE=user_api YAPPY_STORAGE_DIR=/data YAPPY_DEPLOY_DOMAIN=yappybara.dev YAPPY_DEPLOY_PRICE_USD=9 FORCE_HTTP=true AUTO_GENERATE_CERTS=false -# Required for Claude API mode -ANTHROPIC_API_KEY=your-api-key-here +# Optional for server-owned billing mode only (NOT needed in user_api mode) +# ANTHROPIC_API_KEY=your-api-key-here diff --git a/interview-app/builder.js b/interview-app/builder.js index 7cae6d3..04302fe 100644 --- a/interview-app/builder.js +++ b/interview-app/builder.js @@ -5,7 +5,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export async function runBuilder({ spec, conversation, appId }) { +export async function runBuilder({ spec, conversation, appId, runtimeApiKey }) { // Resolve absolute paths so there's zero ambiguity for Dash const buildsDir = path.join(__dirname, 'public', 'builds', appId || 'latest'); const buildFile = path.join(buildsDir, 'index.html'); @@ -74,6 +74,10 @@ Be FAST and BOLD. Build something they'll be excited to show their friends.`; let resultText = ''; try { + const env = { ...process.env }; + if (runtimeApiKey) { + env.ANTHROPIC_API_KEY = runtimeApiKey; + } const result = query({ prompt: `Build the app prototype now. Write the HTML file to: ${buildFile}`, options: { @@ -83,6 +87,7 @@ Be FAST and BOLD. Build something they'll be excited to show their friends.`; allowDangerouslySkipPermissions: true, maxTurns: 15, cwd: __dirname, + env, } }); diff --git a/interview-app/fly.toml.example b/interview-app/fly.toml.example index 7d3e987..1031cd8 100644 --- a/interview-app/fly.toml.example +++ b/interview-app/fly.toml.example @@ -7,7 +7,7 @@ primary_region = "ord" [env] PORT = "8080" YAPPY_MODE = "claude" - YAPPY_BILLING_MODE = "api" + YAPPY_BILLING_MODE = "user_api" YAPPY_STORAGE_DIR = "/data" YAPPY_DEPLOY_DOMAIN = "yappybara.dev" YAPPY_DEPLOY_PRICE_USD = "9" diff --git a/interview-app/mechanic.js b/interview-app/mechanic.js index b2f8003..295bdc9 100644 --- a/interview-app/mechanic.js +++ b/interview-app/mechanic.js @@ -38,7 +38,7 @@ Note: action log entries with type "dad_feedback" are observations from the kid' - Keep last-fix.md short and in kid-friendly language - Sign off in last-fix.md as "— Wrench" because that's your name`; -export async function runMechanic({ feedback, conversation, actionLog }) { +export async function runMechanic({ feedback, conversation, actionLog, runtimeApiKey }) { // Build the conversation summary (last 10 messages) let conversationText = 'No conversation available'; if (conversation && conversation.length > 0) { @@ -56,6 +56,10 @@ export async function runMechanic({ feedback, conversation, actionLog }) { console.log(''); let resultText = ''; + const env = { ...process.env }; + if (runtimeApiKey) { + env.ANTHROPIC_API_KEY = runtimeApiKey; + } const result = query({ prompt: 'Fix the issue described in my system prompt. Start by reading the relevant files.', options: { @@ -66,6 +70,7 @@ export async function runMechanic({ feedback, conversation, actionLog }) { allowedTools: ['Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob'], maxTurns: 10, cwd: __dirname, + env, } }); diff --git a/interview-app/public/index.html b/interview-app/public/index.html index 14598f1..c684d61 100644 --- a/interview-app/public/index.html +++ b/interview-app/public/index.html @@ -2234,6 +2234,9 @@

Parent Supervision Required

deployPriceUsd: 5, lastGuide: null, fallbackNotified: false, + requiresUserApiKey: false, + apiKeyHeader: 'x-anthropic-api-key', + anthropicApiKey: '', }; // ===== DOM refs ===== @@ -2268,6 +2271,58 @@

Parent Supervision Required

const $parentGateCheckbox = document.getElementById('parent-gate-checkbox'); const $parentGateContinue = document.getElementById('parent-gate-continue'); const PARENT_GATE_STORAGE_KEY = 'yappybara-parent-supervision-v1'; +const API_KEY_STORAGE_KEY = 'yappybara-anthropic-key-v1'; + +function loadStoredApiKey() { + try { + return String(localStorage.getItem(API_KEY_STORAGE_KEY) || '').trim(); + } catch (e) { + return ''; + } +} + +function isPlausibleAnthropicApiKey(value) { + const key = String(value || '').trim(); + return key.length >= 24 && key.startsWith('sk-ant-'); +} + +function setStoredApiKey(value) { + const key = String(value || '').trim(); + state.anthropicApiKey = key; + try { + if (key) { + localStorage.setItem(API_KEY_STORAGE_KEY, key); + } else { + localStorage.removeItem(API_KEY_STORAGE_KEY); + } + } catch (e) {} +} + +function authHeaders(extra = {}) { + const headers = { ...extra }; + const key = String(state.anthropicApiKey || '').trim(); + if (key) headers[state.apiKeyHeader || 'x-anthropic-api-key'] = key; + return headers; +} + +async function ensureParentApiKeyIfRequired() { + if (!state.requiresUserApiKey || state.assistMode === 'demo') return true; + if (isPlausibleAnthropicApiKey(state.anthropicApiKey)) return true; + + const entered = window.prompt( + 'Parent setup: paste your Anthropic API key (starts with sk-ant-). It is stored only in this browser/device.' + ); + if (!entered) return false; + if (!isPlausibleAnthropicApiKey(entered)) { + window.alert('That key format looks invalid. Please paste a valid Anthropic API key that starts with sk-ant-.'); + return false; + } + setStoredApiKey(entered); + updateModeHint(state.assistMode, state.assistModeReason); + return true; +} + +setStoredApiKey(loadStoredApiKey()); function updateModeHint(mode, reason) { if (!mode || !$modeHint) return; @@ -2278,7 +2333,10 @@

Parent Supervision Required

const lead = mode === 'demo' ? 'Practice mode: works without Claude login.' : 'Full AI mode: powered by Claude.'; - $modeHint.textContent = state.assistModeReason ? `${lead} ${state.assistModeReason}` : lead; + const keyHint = (mode !== 'demo' && state.requiresUserApiKey) + ? (state.anthropicApiKey ? 'Parent API key is connected on this device.' : 'Parent API key required before chat can start.') + : ''; + $modeHint.textContent = [lead, keyHint, state.assistModeReason].filter(Boolean).join(' '); } function sanitizeSubdomain(value) { @@ -3724,6 +3782,10 @@

Meet Your Build Crew!

async function sendMessage(text, imageInfo) { if (state.isWaiting) return; if (!text && !imageInfo) return; + if (!(await ensureParentApiKeyIfRequired())) { + addSystemMessage('Parent Anthropic API key is required before full AI mode can start.'); + return; + } state.isWaiting = true; $sendBtn.disabled = true; @@ -3759,7 +3821,7 @@

Meet Your Build Crew!

const resp = await fetch('/api/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body) }); @@ -3768,6 +3830,9 @@

Meet Your Build Crew!

if (!resp.ok) { const err = await resp.json().catch(() => ({ error: 'Server error' })); + if (err.requiresUserApiKey) { + setStoredApiKey(''); + } addSystemMessage(err.error || 'Oops, something went wrong. Try again!'); console.error('API error:', err); state.isWaiting = false; @@ -4317,6 +4382,8 @@

Meet Your Build Crew!

const r = await fetch('/api/session-info'); if (r.ok) { const info = await r.json(); + state.requiresUserApiKey = Boolean(info.requiresUserApiKey); + if (info.apiKeyHeader) state.apiKeyHeader = String(info.apiKeyHeader); if (info.mode) { updateModeHint(info.mode, info.modeReason || ''); } else { @@ -4702,14 +4769,21 @@

What You Built

`; // Fetch AI-generated questions, fall back to hardcoded - fetch('/api/trivia?topic=' + encodeURIComponent(topic)) - .then(r => r.json()) + fetch('/api/trivia?topic=' + encodeURIComponent(topic), { headers: authHeaders() }) + .then(async (r) => { + const data = await r.json().catch(() => ({})); + if (!r.ok) throw data; + return data; + }) .then(data => { const questions = (data.questions && data.questions.length >= 3) ? data.questions : fallbackQuestions; const displayTopic = data.topic || topic; startTriviaGame(questions, displayTopic); }) - .catch(() => { + .catch((err) => { + if (err && err.requiresUserApiKey) { + addSystemMessage('Parent Anthropic API key required for AI trivia. Using local trivia set for now.'); + } startTriviaGame(fallbackQuestions, 'Capybara Mix'); }); @@ -5246,6 +5320,10 @@

What You Built

await activateApp(data.id); enterAppMode(); switchTab('chat'); + if (!(await ensureParentApiKeyIfRequired())) { + addSystemMessage('Parent Anthropic API key is required before full AI mode can start.'); + return; + } // Greet addSystemMessage(quickStart ? 'Quick start enabled. Waking up Bart...' : 'Waking up Bart...'); showTyping(); @@ -5253,27 +5331,34 @@

What You Built

state.messages.push(firstMsg); const chatResp = await fetch('/api/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ messages: state.messages }) }); hideTyping(); - if (chatResp.ok) { - const chatData = await chatResp.json(); - if (chatData.mode) updateModeHint(chatData.mode, chatData.modeReason || ''); - if (chatData.fallbackUsed && !state.fallbackNotified) { - addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.'); - state.fallbackNotified = true; - } - refreshParentGuide(); - state.messages.push({ role: 'assistant', content: chatData.response }); - const sysMsg = document.querySelector('#chat .message.system'); - if (sysMsg) sysMsg.remove(); - addMessage('ai', chatData.response); - await speak(chatData.response); - if (state.recognition) { - state.micActive = true; - startListening(); + if (!chatResp.ok) { + const err = await chatResp.json().catch(() => ({ error: 'Server error' })); + if (err.requiresUserApiKey) { + setStoredApiKey(''); } + state.messages.pop(); + addSystemMessage(err.error || 'Could not start chat yet. Please try again.'); + return; + } + const chatData = await chatResp.json(); + if (chatData.mode) updateModeHint(chatData.mode, chatData.modeReason || ''); + if (chatData.fallbackUsed && !state.fallbackNotified) { + addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.'); + state.fallbackNotified = true; + } + refreshParentGuide(); + state.messages.push({ role: 'assistant', content: chatData.response }); + const sysMsg = document.querySelector('#chat .message.system'); + if (sysMsg) sysMsg.remove(); + addMessage('ai', chatData.response); + await speak(chatData.response); + if (state.recognition) { + state.micActive = true; + startListening(); } } catch(e) { console.error('Failed to create app:', e); @@ -5609,28 +5694,40 @@

What You Built

// Helper to send a message programmatically async function sendMessageAuto(text) { + if (!(await ensureParentApiKeyIfRequired())) { + addSystemMessage('Parent Anthropic API key is required before full AI mode can start.'); + return; + } state.isWaiting = true; showTyping(); setBartMood('thinking'); try { const resp = await fetch('/api/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ messages: state.messages }) }); hideTyping(); - if (resp.ok) { - const data = await resp.json(); - if (data.mode) updateModeHint(data.mode, data.modeReason || ''); - if (data.fallbackUsed && !state.fallbackNotified) { - addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.'); - state.fallbackNotified = true; + if (!resp.ok) { + const err = await resp.json().catch(() => ({ error: 'Server error' })); + if (err.requiresUserApiKey) { + setStoredApiKey(''); } - refreshParentGuide(); - state.messages.push({ role: 'assistant', content: data.response }); - addMessage('ai', data.response); - await speak(data.response); + addSystemMessage(err.error || 'Oops, something went wrong. Try again!'); + state.isWaiting = false; + setBartMood('idle'); + return; } + const data = await resp.json(); + if (data.mode) updateModeHint(data.mode, data.modeReason || ''); + if (data.fallbackUsed && !state.fallbackNotified) { + addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.'); + state.fallbackNotified = true; + } + refreshParentGuide(); + state.messages.push({ role: 'assistant', content: data.response }); + addMessage('ai', data.response); + await speak(data.response); } catch(e) { hideTyping(); } state.isWaiting = false; setBartMood('idle'); diff --git a/interview-app/run.sh b/interview-app/run.sh index e3186b4..7338f0a 100755 --- a/interview-app/run.sh +++ b/interview-app/run.sh @@ -5,8 +5,8 @@ if [ -z "${YAPPY_BILLING_MODE:-}" ]; then export YAPPY_BILLING_MODE=subscription fi -if [ "$YAPPY_BILLING_MODE" = "subscription" ] && [ -n "${ANTHROPIC_API_KEY:-}" ]; then - echo " Billing mode is subscription; ignoring ANTHROPIC_API_KEY to avoid API charges." +if { [ "$YAPPY_BILLING_MODE" = "subscription" ] || [ "$YAPPY_BILLING_MODE" = "user_api" ]; } && [ -n "${ANTHROPIC_API_KEY:-}" ]; then + echo " Billing mode is $YAPPY_BILLING_MODE; ignoring ANTHROPIC_API_KEY at startup." unset ANTHROPIC_API_KEY fi diff --git a/interview-app/server.js b/interview-app/server.js index 0569cad..22bf33c 100644 --- a/interview-app/server.js +++ b/interview-app/server.js @@ -34,9 +34,11 @@ const BUILD_NUDGE_MINUTES = 1; // After this many minutes, nudge Bart to build const REQUESTED_ASSIST_MODE = (process.env.YAPPY_MODE || 'auto').toLowerCase(); const VALID_ASSIST_MODES = new Set(['auto', 'claude', 'demo']); const REQUESTED_BILLING_MODE = (process.env.YAPPY_BILLING_MODE || 'subscription').toLowerCase(); -const VALID_BILLING_MODES = new Set(['subscription', 'api', 'auto']); +const VALID_BILLING_MODES = new Set(['subscription', 'api', 'auto', 'user_api']); const BILLING_MODE = VALID_BILLING_MODES.has(REQUESTED_BILLING_MODE) ? REQUESTED_BILLING_MODE : 'subscription'; const HAS_API_KEY = Boolean((process.env.ANTHROPIC_API_KEY || '').trim()); +const USER_API_HEADER = 'x-anthropic-api-key'; +const MIN_ANTHROPIC_KEY_LEN = 24; let assistMode = REQUESTED_ASSIST_MODE === 'demo' ? 'demo' : 'claude'; let assistModeReason = REQUESTED_ASSIST_MODE === 'demo' ? 'Forced demo mode (YAPPY_MODE=demo)' @@ -47,7 +49,9 @@ let billingModeReason = BILLING_MODE === 'subscription' ? 'Subscription-first: prefer Claude login and avoid API billing' : BILLING_MODE === 'api' ? 'API billing mode: usage-based billing via ANTHROPIC_API_KEY' - : 'Auto billing mode: API key if present, otherwise Claude login'; + : BILLING_MODE === 'user_api' + ? 'Parent BYOK mode: each family provides their own Anthropic API key' + : 'Auto billing mode: API key if present, otherwise Claude login'; let apiKeySuppressed = false; if (!VALID_ASSIST_MODES.has(REQUESTED_ASSIST_MODE)) { @@ -61,10 +65,52 @@ if (BILLING_MODE === 'subscription' && HAS_API_KEY) { delete process.env.ANTHROPIC_API_KEY; apiKeySuppressed = true; billingModeReason = 'Subscription-first: ignored ANTHROPIC_API_KEY to avoid API charges'; +} else if (BILLING_MODE === 'user_api' && HAS_API_KEY) { + // Never spend server-owned API keys in parent BYOK mode. + delete process.env.ANTHROPIC_API_KEY; + apiKeySuppressed = true; + billingModeReason = 'Parent BYOK mode: ignored server ANTHROPIC_API_KEY and requires per-user key'; +} else if (BILLING_MODE === 'user_api' && !HAS_API_KEY) { + billingModeReason = 'Parent BYOK mode: waiting for per-user Anthropic key from browser'; } else if (BILLING_MODE === 'api' && !HAS_API_KEY) { billingModeReason = 'API billing mode requested, but ANTHROPIC_API_KEY is missing'; } +function isUserApiBillingMode() { + return BILLING_MODE === 'user_api'; +} + +function sanitizeRuntimeApiKey(raw) { + const key = String(raw || '').trim(); + if (!key) return null; + if (key.length < MIN_ANTHROPIC_KEY_LEN) return null; + if (!key.startsWith('sk-ant-')) return null; + return key; +} + +function requestApiKey(req) { + if (!req) return null; + const headerKey = sanitizeRuntimeApiKey(req.get(USER_API_HEADER)); + if (headerKey) return headerKey; + const bodyKey = sanitizeRuntimeApiKey(req.body?.anthropicApiKey); + if (bodyKey) return bodyKey; + return null; +} + +function resolveRuntimeApiKey(req) { + return requestApiKey(req); +} + +function runtimeClaudeEnv(runtimeApiKey) { + const env = { ...process.env }; + if (runtimeApiKey) { + env.ANTHROPIC_API_KEY = runtimeApiKey; + } else if (isUserApiBillingMode()) { + delete env.ANTHROPIC_API_KEY; + } + return env; +} + // --- Apps.json CRUD + local persistence vault --- const APPS_FILE = path.join(STORAGE_ROOT, 'apps.json'); const DATA_DIR = path.join(STORAGE_ROOT, 'data'); @@ -550,7 +596,7 @@ This is URGENT. Do NOT ask another question. It's time to build. `; // Send a message to Claude via the Agent SDK -async function chatWithClaude(userMessage, sketchPath, activeApp, conversationContext) { +async function chatWithClaude(userMessage, sketchPath, activeApp, conversationContext, runtimeApiKey) { let prompt = PROMPT_GUARDRAIL + userMessage; // Build nudge: per-app tracking @@ -574,6 +620,7 @@ async function chatWithClaude(userMessage, sketchPath, activeApp, conversationCo allowedTools: sketchPath ? ['Read'] : [], disallowedTools: ['Write', 'Edit', 'Bash', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Task', 'NotebookEdit'], cwd: PROJECT_ROOT, + env: runtimeClaudeEnv(runtimeApiKey), }; if (activeApp.claudeSessionId) { @@ -817,7 +864,7 @@ function buildParentGuide(activeApp) { }; } -async function getBartResponse({ userText, sketchFile, activeApp, conversationContext, messages }) { +async function getBartResponse({ userText, sketchFile, activeApp, conversationContext, messages, runtimeApiKey }) { const mode = getAssistMode(); if (mode === 'demo') { const localText = sanitizeBartResponse(chatWithLocalBart(userText, activeApp, messages)); @@ -826,7 +873,7 @@ async function getBartResponse({ userText, sketchFile, activeApp, conversationCo } try { - const text = await chatWithClaude(userText, sketchFile || null, activeApp, conversationContext); + const text = await chatWithClaude(userText, sketchFile || null, activeApp, conversationContext, runtimeApiKey); return { text, mode: 'claude', fallbackUsed: false }; } catch (err) { if (REQUESTED_ASSIST_MODE === 'claude') { @@ -914,7 +961,7 @@ async function runLocalBuilder({ spec, appId }) { } // --- Build Phase --- -async function startBuild(spec, conversation, appId) { +async function startBuild(spec, conversation, appId, runtimeApiKey) { updateActiveApp({ buildStatus: 'building' }); logAction('build_start', { spec: spec.slice(0, 300), appId }); console.log(''); @@ -928,7 +975,7 @@ async function startBuild(spec, conversation, appId) { result = await runLocalBuilder({ spec, appId }); } else { try { - result = await runBuilder({ spec, conversation, appId }); + result = await runBuilder({ spec, conversation, appId, runtimeApiKey }); } catch (err) { if (REQUESTED_ASSIST_MODE === 'claude') { throw err; @@ -1097,6 +1144,13 @@ app.post('/api/chat', async (req, res) => { if (!activeApp) { return res.status(400).json({ error: 'No active app. Create or select one first.' }); } + const runtimeApiKey = resolveRuntimeApiKey(req); + if (getAssistMode() !== 'demo' && isUserApiBillingMode() && !runtimeApiKey) { + return res.status(402).json({ + error: 'Parent Anthropic API key required in this deployment. Add your own key in this browser first.', + requiresUserApiKey: true, + }); + } const lastMsg = messages[messages.length - 1]; let userText; @@ -1123,6 +1177,7 @@ app.post('/api/chat', async (req, res) => { activeApp, conversationContext, messages, + runtimeApiKey, }); const responseText = response.text; @@ -1142,7 +1197,7 @@ app.post('/api/chat', async (req, res) => { logAction('build_phase', { spec: spec.slice(0, 500), appId: activeApp.id }); updateActiveApp({ buildStatus: 'building', spec }); - startBuild(spec, messages, activeApp.id); + startBuild(spec, messages, activeApp.id, runtimeApiKey); res.json({ response: displayText, @@ -1385,6 +1440,14 @@ const triviaCache = []; // Cache generated questions to avoid re-generating app.get('/api/trivia', async (req, res) => { const topic = req.query.topic || 'random'; + const runtimeApiKey = resolveRuntimeApiKey(req); + if (getAssistMode() !== 'demo' && isUserApiBillingMode() && !runtimeApiKey) { + return res.status(402).json({ + questions: [], + error: 'Parent Anthropic API key required before generating trivia in this deployment.', + requiresUserApiKey: true, + }); + } // Return cached if we have enough if (triviaCache.length >= 10) { @@ -1404,6 +1467,7 @@ app.get('/api/trivia', async (req, res) => { maxTurns: 1, allowedTools: [], disallowedTools: ['Write', 'Edit', 'Bash', 'Read', 'Grep', 'Glob'], + env: runtimeClaudeEnv(runtimeApiKey), } }); @@ -1451,6 +1515,8 @@ app.get('/api/session-info', (req, res) => { billingMode: getBillingMode(), billingModeReason, apiKeySuppressed, + requiresUserApiKey: isUserApiBillingMode() && getAssistMode() !== 'demo', + apiKeyHeader: USER_API_HEADER, deployRootDomain: normalizeDomainRoot(DEFAULT_DEPLOY_ROOT_DOMAIN), deployPriceUsd: parseDeployPrice(), deployRequest: activeApp?.deployRequest || null, @@ -1474,6 +1540,7 @@ app.get('/api/qr', async (req, res) => { app.post('/api/panic', async (req, res) => { const { feedback } = req.body; logAction('panic', { feedback }); + const runtimeApiKey = requestApiKey(req); // Respond immediately so the browser can show the nap screen res.json({ ok: true }); @@ -1499,7 +1566,7 @@ app.post('/api/panic', async (req, res) => { }, 120_000); try { - const result = await runMechanic({ feedback, conversation, actionLog: actionLogTail }); + const result = await runMechanic({ feedback, conversation, actionLog: actionLogTail, runtimeApiKey }); logAction('mechanic_result', { summary: result.slice(0, 500) }); } catch (err) { logAction('error', { endpoint: 'mechanic', error: err.message }); @@ -1649,13 +1716,15 @@ function onServerReady(protocol) { console.log(''); if (getBillingMode() === 'api') { console.log(' Billing mode: API key (usage-based billing).'); + } else if (getBillingMode() === 'user_api') { + console.log(' Billing mode: parent BYOK (each session uses a parent API key).'); } else if (getBillingMode() === 'auto') { console.log(' Billing mode: auto (uses API key if present).'); } else { console.log(' Billing mode: subscription-first (Claude login).'); } if (apiKeySuppressed) { - console.log(' ANTHROPIC_API_KEY was ignored. Set YAPPY_BILLING_MODE=api to use API billing.'); + console.log(' ANTHROPIC_API_KEY was ignored for this mode.'); } console.log(` Assistant mode: ${getAssistMode()} (${assistModeReason})`); if (protocol === 'HTTPS') { diff --git a/interview-app/test.js b/interview-app/test.js index 2ff0dc7..452efa8 100644 --- a/interview-app/test.js +++ b/interview-app/test.js @@ -775,6 +775,8 @@ describe('API Endpoints (integration — requires server)', () => { assert.ok('turnCount' in data); assert.ok('mode' in data); assert.ok('billingMode' in data); + assert.ok('requiresUserApiKey' in data); + assert.ok('apiKeyHeader' in data); assert.ok('deployRootDomain' in data); assert.ok('deployPriceUsd' in data); }); @@ -908,6 +910,89 @@ describe('API Endpoints (integration — requires server)', () => { }); }); +// ========================================================= +// Hosted BYOK billing guard +// ========================================================= +describe('User API Billing Guard (integration)', () => { + let serverProcess; + let serverReady = false; + const PORT = 13457; + const BASE = `http://localhost:${PORT}`; + const testAppsFile = path.join(__dirname, 'apps.json'); + const testDataDir = path.join(__dirname, 'data'); + + before(async () => { + const { spawn } = await import('child_process'); + + try { fs.unlinkSync(testAppsFile); } catch (e) {} + try { fs.rmSync(testDataDir, { recursive: true, force: true }); } catch (e) {} + + serverProcess = spawn('node', ['server.js'], { + cwd: __dirname, + env: { + ...process.env, + PORT: String(PORT), + YAPPY_MODE: 'claude', + YAPPY_BILLING_MODE: 'user_api', + FORCE_HTTP: 'true', + AUTO_GENERATE_CERTS: 'false', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + for (let i = 0; i < 20; i++) { + try { + const r = await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(1000) }); + if (r.ok) { serverReady = true; break; } + } catch (e) {} + await new Promise(r => setTimeout(r, 500)); + } + }); + + after(() => { + if (serverProcess) { + try { serverProcess.kill('SIGTERM'); } catch (e) {} + } + try { fs.unlinkSync(path.join(__dirname, 'action-log.jsonl')); } catch (e) {} + try { fs.rmSync(testDataDir, { recursive: true, force: true }); } catch (e) {} + }); + + function skipIfNoServer() { + return !serverReady; + } + + it('GET /api/session-info reports BYOK requirement', async (t) => { + if (skipIfNoServer()) return t.skip('Server not available'); + const r = await fetch(`${BASE}/api/session-info`); + assert.equal(r.status, 200); + const data = await r.json(); + assert.equal(data.billingMode, 'user_api'); + assert.equal(data.requiresUserApiKey, true); + assert.equal(data.apiKeyHeader, 'x-anthropic-api-key'); + }); + + it('POST /api/chat rejects when parent API key is missing in user_api mode', async (t) => { + if (skipIfNoServer()) return t.skip('Server not available'); + const createResp = await fetch(`${BASE}/api/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'BYOK Guard Test' }), + }); + const { id } = await createResp.json(); + assert.ok(id); + + const r = await fetch(`${BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hello' }] }), + }); + assert.equal(r.status, 402); + const data = await r.json(); + assert.equal(data.requiresUserApiKey, true); + assert.ok(String(data.error || '').toLowerCase().includes('api key')); + }); +}); + // ========================================================= // run.sh validation // ========================================================= @@ -974,6 +1059,12 @@ describe('index.html UI Elements', () => { assert.ok(html.includes('id="parent-gate-checkbox"'), 'Should include supervision acknowledgment checkbox'); }); + it('has BYOK key helpers for hosted parent billing', () => { + assert.ok(html.includes('API_KEY_STORAGE_KEY'), 'Should persist per-device parent key'); + assert.ok(html.includes('ensureParentApiKeyIfRequired'), 'Should gate chat when BYOK is required'); + assert.ok(html.includes('x-anthropic-api-key'), 'Should send parent key via request header'); + }); + it('has dash build section', () => { assert.ok(html.includes('id="dash-build-section"'), 'Should have build section'); assert.ok(html.includes('id="dash-progress-bar"'), 'Should have progress bar');