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');