diff --git a/interview-app/public/index.html b/interview-app/public/index.html
index 9b49dc9..5f6c6ca 100644
--- a/interview-app/public/index.html
+++ b/interview-app/public/index.html
@@ -3615,7 +3615,7 @@
Parent Supervision Required
/\b(?:cr[a@]p|crap+y)\b/gi,
/\b(?:p+[i1!]+ss+)\w*\b/gi,
/\b(?:d[i1!]+ck|c[o0]ck|pen[i1!]s|boner|dong)\b/gi,
- /\b(?:boob|tit|titty|tits|nipple|puss+y)\w*\b/gi,
+ /\b(?:boob(?:s|ies)?|titty|titties|tits|nipple(?:s)?|puss+y)\w*\b/gi,
/\b(?:bastard|whor+e|slut|skank)\w*\b/gi,
/\b(?:n[i1!]+gg+|f+[a@]+g+|retard|spaz)\w*\b/gi,
/\b(?:sex+(?:y|ual|ting)?|porn|xxx|nude|naked|horny)\b/gi,
@@ -3632,6 +3632,23 @@ Parent Supervision Required
return cleaned.replace(/\s{2,}/g, ' ').replace(/\.\s*\./g, '.').replace(/,\s*,/g, ',').replace(/\s+([.,!?])/g, '$1').trim();
}
+function sanitizeAiDisplayText(text) {
+ if (!text) return text;
+ // Never show build internals in chat bubbles.
+ let cleaned = text.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, '');
+ // Protect preview HTML from text sanitization so tags remain valid.
+ const previewBlocks = [];
+ cleaned = cleaned.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, (match) => {
+ previewBlocks.push(match);
+ return `__PREVIEW_${previewBlocks.length - 1}__`;
+ });
+ cleaned = sanitizeForKids(cleaned);
+ for (let i = 0; i < previewBlocks.length; i++) {
+ cleaned = cleaned.replace(`__PREVIEW_${i}__`, previewBlocks[i]);
+ }
+ return cleaned;
+}
+
// Sanitize HTML content for iframe display (strip external images)
function sanitizePreviewHtml(html) {
if (!html) return html;
@@ -3649,9 +3666,7 @@ Parent Supervision Required
function addMessage(role, text, imageUrl) {
// Client-side safety net: scrub AI text before display
if (role === 'ai') {
- // Strip build spec if it somehow leaked through to the frontend
- text = text.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, '');
- text = sanitizeForKids(text);
+ text = sanitizeAiDisplayText(text);
}
const div = document.createElement('div');
diff --git a/interview-app/safety.js b/interview-app/safety.js
index 1bd5a3f..d9622f4 100644
--- a/interview-app/safety.js
+++ b/interview-app/safety.js
@@ -15,7 +15,7 @@ export const UNSAFE_PATTERNS = [
[/\b(?:cr[a@]p|crap+y)\b/gi, ''],
[/\b(?:p+[i1!]+ss+)\w*\b/gi, ''],
[/\b(?:d[i1!]+ck|c[o0]ck|pen[i1!]s|ball?s(?:ack)?|boner|dong)\b/gi, ''],
- [/\b(?:boob|tit|titty|tits|nipple|va[gj]|puss+y)\w*\b/gi, ''],
+ [/\b(?:boob(?:s|ies)?|titty|titties|tits|nipple(?:s)?|va[gj]|puss+y)\w*\b/gi, ''],
[/\b(?:bastard|whor+e|slut|skank|ho+e)\w*\b/gi, ''],
// Slurs (abbreviated patterns to avoid reproducing them fully)
[/\b(?:n[i1!]+gg+|f+[a@]+g+|retard|spaz|tard)\w*\b/gi, ''],
@@ -66,6 +66,23 @@ export function scrubUnsafeWords(text) {
return cleaned;
}
+function scrubUnsafeInlineText(text) {
+ if (!text) return text;
+ let cleaned = text;
+ for (const [pattern, replacement] of UNSAFE_PATTERNS) {
+ cleaned = cleaned.replace(pattern, replacement);
+ pattern.lastIndex = 0;
+ }
+ return cleaned;
+}
+
+function scrubUnsafeTextNodes(html) {
+ if (!html) return html;
+ return html.replace(/>([^<]+) {
+ return `>${scrubUnsafeInlineText(textNode)}<`;
+ });
+}
+
// Sanitize HTML preview content for kid safety
export function sanitizePreviewHtml(html) {
if (!html) return html;
@@ -167,8 +184,8 @@ export function sanitizeBartResponse(text) {
// Sanitize and restore preview blocks (HTML content for iframes)
for (let i = 0; i < previewBlocks.length; i++) {
let block = previewBlocks[i];
- // Scrub unsafe words from preview HTML text content
- block = scrubUnsafeWords(block);
+ // Scrub only text nodes so HTML tags/attributes remain intact.
+ block = scrubUnsafeTextNodes(block);
// Strip external image loading to prevent inappropriate AI-generated images
block = sanitizePreviewHtml(block);
cleaned = cleaned.replace(`__PREVIEW_${i}__`, block);
diff --git a/interview-app/server.js b/interview-app/server.js
index 22bf33c..38fd309 100644
--- a/interview-app/server.js
+++ b/interview-app/server.js
@@ -663,20 +663,54 @@ function messageText(msg) {
return String(msg.content || '');
}
-function extractAppNameFromConversation(messages) {
- const texts = (messages || []).map(messageText);
+function userMessageTexts(messages) {
+ return (messages || [])
+ .filter((m) => m?.role === 'user')
+ .map(messageText);
+}
+
+function cleanIdeaText(raw) {
+ return String(raw || '')
+ // Remove preview/build payloads if they somehow appear in message history.
+ .replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, ' ')
+ .replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ' ')
+ // Remove html/xml tags to avoid parsing attribute values as app names.
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function normalizeAppNameCandidate(raw) {
+ const candidate = String(raw || '')
+ .replace(/\s+/g, ' ')
+ .replace(/[.,!?;:]+$/g, '')
+ .trim();
+ if (!candidate) return null;
+ if (candidate.length < 3) return null;
+ if (/content-security-policy|http-equiv|charset|viewport|doctype/i.test(candidate)) return null;
+ return candidate.slice(0, 40);
+}
+
+function extractAppNameFromConversation(messages, fallback = 'My Dream App') {
+ const texts = userMessageTexts(messages).map(cleanIdeaText);
for (let i = texts.length - 1; i >= 0; i--) {
const t = texts[i];
- const quoted = t.match(/"([^"]{3,40})"/);
- if (quoted) return quoted[1].trim();
- const named = t.match(/\b(?:called|named|name is)\s+([A-Za-z0-9][A-Za-z0-9 ]{2,40})/i);
- if (named) return named[1].trim();
+ const quoted = t.match(/["“]([^"”]{3,40})["”]/);
+ if (quoted) {
+ const name = normalizeAppNameCandidate(quoted[1]);
+ if (name) return name;
+ }
+ const named = t.match(/\b(?:called|named|name is)\s+([A-Za-z0-9][A-Za-z0-9 '&_-]{2,40})/i);
+ if (named) {
+ const name = normalizeAppNameCandidate(named[1]);
+ if (name) return name;
+ }
}
- return 'My Dream App';
+ return normalizeAppNameCandidate(fallback) || 'My Dream App';
}
function extractAudienceFromConversation(messages) {
- const text = (messages || []).map(messageText).join(' ').toLowerCase();
+ const text = userMessageTexts(messages).map(cleanIdeaText).join(' ').toLowerCase();
if (/\bkids?\b|\bchildren\b/.test(text)) return 'kids';
if (/\bstudents?\b|\bclass\b/.test(text)) return 'students';
if (/\bparents?\b|\bfamil(y|ies)\b/.test(text)) return 'families';
@@ -687,7 +721,7 @@ function extractAudienceFromConversation(messages) {
}
function extractVibeFromConversation(messages) {
- const text = (messages || []).map(messageText).join(' ').toLowerCase();
+ const text = userMessageTexts(messages).map(cleanIdeaText).join(' ').toLowerCase();
if (/\bcalm\b|\bcozy\b|\bsoft\b|\bgentle\b/.test(text)) return 'calm and cozy';
if (/\bfast\b|\benergy\b|\bneon\b|\bparty\b|\bbold\b/.test(text)) return 'energetic and bold';
if (/\badventure\b|\bquest\b|\bexplore\b|\bspace\b|\bocean\b|\bjungle\b/.test(text)) return 'adventurous and playful';
@@ -698,6 +732,7 @@ function extractFeaturesFromConversation(messages) {
const userText = (messages || [])
.filter(m => m.role === 'user')
.map(messageText)
+ .map(cleanIdeaText)
.join(' ')
.toLowerCase();
const ideas = [];
@@ -766,7 +801,7 @@ Special Details: Keep it safe, bright, and easy to use on a phone`;
}
function chatWithLocalBart(userMessage, activeApp, messages) {
- const appName = extractAppNameFromConversation(messages);
+ const appName = extractAppNameFromConversation(messages, activeApp?.name || 'My Dream App');
const audience = extractAudienceFromConversation(messages);
const vibe = extractVibeFromConversation(messages);
const features = extractFeaturesFromConversation(messages);
diff --git a/interview-app/test.js b/interview-app/test.js
index 452efa8..7706f5f 100644
--- a/interview-app/test.js
+++ b/interview-app/test.js
@@ -596,6 +596,15 @@ Build with APIs and HTML.
const cleaned = sanitizePreviewHtml('ok
');
assert.ok(cleaned.includes('Content-Security-Policy'));
});
+
+ it('does not break title tags while sanitizing preview content', () => {
+ const raw = `---PREVIEW---
+Garden Buildertits
+---END_PREVIEW---`;
+ const cleaned = sanitizeBartResponse(raw);
+ assert.ok(cleaned.includes('Garden Builder'));
+ assert.ok(!cleaned.includes('<>Garden Builder>'));
+ });
});
// =========================================================
@@ -908,6 +917,45 @@ describe('API Endpoints (integration — requires server)', () => {
assert.ok(startEntry, 'Should have a session_start entry');
assert.ok(startEntry.ts, 'session_start should have timestamp');
});
+
+ it('demo chat naming ignores CSP from assistant preview HTML', 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: 'Demo Name Test' }),
+ });
+ const { id } = await createResp.json();
+ await fetch(`${BASE}/api/apps/${id}/activate`, { method: 'PUT' });
+
+ const firstMessages = [{ role: 'user', content: 'I want to build an app called Garden Buddy for kids' }];
+ const firstChat = await fetch(`${BASE}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messages: firstMessages }),
+ });
+ assert.equal(firstChat.status, 200);
+ const firstData = await firstChat.json();
+ const firstAssistant = firstData.response;
+ assert.ok(firstAssistant.includes('---PREVIEW---'));
+ assert.ok(firstAssistant.includes('Content-Security-Policy'));
+
+ const secondMessages = [
+ ...firstMessages,
+ { role: 'assistant', content: firstAssistant },
+ { role: 'user', content: 'everyone' },
+ ];
+ const secondChat = await fetch(`${BASE}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messages: secondMessages }),
+ });
+ assert.equal(secondChat.status, 200);
+ const secondData = await secondChat.json();
+ assert.ok(secondData.response.includes('Garden Buddy'), 'Expected extracted app name from user input');
+ assert.ok(!secondData.response.includes('Content-Security-Policy!'), 'Should not treat CSP as app name');
+ });
});
// =========================================================