Skip to content

Commit 667838f

Browse files
committed
Fix 23 MEDIUM audit findings
Sidecar + launcher: - AdForge.command waits up to 10s on the restart_stale path for the old sidecar to release the port (nc -z probe). sleep 1 was too short on macOS with open file handles. (#41) - local-sync.cjs binds server with an error listener that explicitly surfaces EADDRINUSE — closes the resolve-ports.cjs TOCTOU window so the user gets a clear message instead of a silent fail. (#42) - extractMetadata now handles unquoted HTML5 attribute values via a per-attribute multi-arm regex — covers older CMS templates. (#59) UI/UX: - UndoToast now queues multiple events. Rapid double-delete no longer drops the first undo. (#43) - Dashboard (app/page.tsx) renders a tile-grid skeleton during load instead of returning null. (#54) - ServiceWorkerRegister listens for controllerchange and reloads once on update — fixes stale shell after deploy. (#55) - History delete adds confirm() to match the brand-delete safety pattern. (#56) Prompts + AI: - audience-targeting "IF AN IMAGE IS ATTACHED" block is now conditional on input.audience_screenshot — AI no longer hallucinates screenshot data when the image was stripped due to non-vision provider. (#45) - brand-extract dlog() wrapper + url-ingest dbg() gated to NODE_ENV !== "production" so raw page content + AI responses don't leak to production user DevTools. (#52) - Gap-fill pairs objections + objection_handling: if one is missing, both regenerate, and the merge trims the longer to match the shorter so indices line up. (#63) - Industry template path no longer setActiveBrainId on skeleton save — activation now happens on the user's explicit form save. (#62) LLM providers: - openai-compat + anthropic readError surfaces 429 Retry-After as a "Rate limit — retry in Ns" prefix so free-tier users on Groq/Cerebras/OpenRouter get a useful message. (#61) Security / safety: - Markdown escape() now also escapes " and ' (defense-in-depth for future tags with attributes). (#47) - url-ingest normalize() throws on non-http(s) schemes so javascript: URIs can't be saved into brain.website_url. (#53) - vercel.json adds X-Frame-Options: DENY + CSP frame-ancestors 'none' against clickjacking. (#51) - local-sync.ts SYNC_URL switched to 127.0.0.1 (IPv6 resolution parity with url-ingest.ts). (#49) SEO: - public/robots.txt + app/sitemap.ts added. Crawlers now get the public evergreen routes; gated app routes (generators/optimizers/settings) are disallowed. (#50) Verification: typecheck + 43/43 tests + next build all pass.
1 parent 4c4dd05 commit 667838f

16 files changed

Lines changed: 237 additions & 37 deletions

File tree

AdForge.command

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,16 @@ fi
9494
if [ "$ACTION" = "restart_stale" ]; then
9595
echo "Stale sidecar on :$SYNC_PORT — asking it to quit before starting fresh..."
9696
curl -fsS -X POST --max-time 3 "http://127.0.0.1:$SYNC_PORT/quit" >/dev/null 2>&1 || true
97-
sleep 1
97+
# Poll until the port frees. sleep 1 is not enough on macOS with open file
98+
# handles; the new sidecar would otherwise fail to bind. (Audit finding #41.)
99+
DEADLINE=$(( $(date +%s) + 10 ))
100+
while [ $(date +%s) -lt $DEADLINE ]; do
101+
# nc -z returns 0 if port is OPEN. We want it CLOSED (free).
102+
if ! nc -z 127.0.0.1 "$SYNC_PORT" >/dev/null 2>&1; then
103+
break
104+
fi
105+
sleep 0.3
106+
done
98107
fi
99108

100109
if [ "$ACTION" = "shifted" ]; then

app/brand/new/page.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ const GAP_FILL_FIELDS = [
3030
"objections", "objection_handling",
3131
] as const;
3232

33+
// Gated console.log — kept in development for the ingest debug story, but
34+
// stripped in production so raw AI responses don't end up in user DevTools.
35+
// (Audit finding #52.)
36+
function dlog(...args: unknown[]) {
37+
if (process.env.NODE_ENV !== "production") {
38+
// eslint-disable-next-line no-console
39+
console.log(...args);
40+
}
41+
}
42+
3343
function isEmptyField(v: unknown): boolean {
3444
if (v == null) return true;
3545
if (Array.isArray(v)) return v.length === 0;
@@ -184,8 +194,8 @@ function Inner() {
184194
}
185195
setQuickStatus("② Reading page metadata (title, OG tags, social links, schema)…");
186196
const deterministic = deterministicFillFromMetadata(r.metadata, r.url);
187-
console.log("[adforge:brand-extract] deterministic fill:", deterministic);
188-
console.log("[adforge:brand-extract] raw metadata:", r.metadata);
197+
dlog("[adforge:brand-extract] deterministic fill:", deterministic);
198+
dlog("[adforge:brand-extract] raw metadata:", r.metadata);
189199
const sourceLabel = `${r.url} (via ${
190200
r.source === "sidecar" ? "local sidecar" :
191201
r.source === "allorigins" ? "AllOrigins fallback" :
@@ -209,12 +219,12 @@ function Inner() {
209219
maxTokens: 3000,
210220
temperature: 0.7,
211221
});
212-
console.log("[adforge:brand-extract] raw AI response text:", res.text);
222+
dlog("[adforge:brand-extract] raw AI response text:", res.text);
213223
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
214224
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
215225
window.dispatchEvent(new Event("ados:usage"));
216226
const parsed = tryParseJson<any>(res.text) ?? {};
217-
console.log("[adforge:brand-extract] parsed AI JSON (pass 1):", parsed);
227+
dlog("[adforge:brand-extract] parsed AI JSON (pass 1):", parsed);
218228
const fallback = new URL(r.url).hostname.replace(/^www\./, "");
219229

220230
// Pass 2 — gap-fill. Any inference field still empty gets a focused
@@ -226,7 +236,11 @@ function Inner() {
226236
if (typeof v === "string" && !v.trim()) continue;
227237
merged1[k] = v;
228238
}
229-
const missing = GAP_FILL_FIELDS.filter((f) => isEmptyField(merged1[f]));
239+
const missing: string[] = GAP_FILL_FIELDS.filter((f) => isEmptyField(merged1[f]));
240+
// objections and objection_handling are paired arrays — if one is missing
241+
// we must regenerate both together so the indices line up. (Audit finding #63.)
242+
if (missing.includes("objections") && !missing.includes("objection_handling")) missing.push("objection_handling");
243+
if (missing.includes("objection_handling") && !missing.includes("objections")) missing.push("objections");
230244
if (missing.length) {
231245
setQuickStatus(`④ Filling gaps — ${missing.length} field${missing.length === 1 ? "" : "s"} still empty. Re-asking AI to infer from content…`);
232246
try {
@@ -242,19 +256,27 @@ function Inner() {
242256
maxTokens: 2000,
243257
temperature: 0.8,
244258
});
245-
console.log("[adforge:brand-extract] raw AI response text (gap-fill):", gapRes.text);
259+
dlog("[adforge:brand-extract] raw AI response text (gap-fill):", gapRes.text);
246260
const gapCost = estimateCostUsd(gapRes.providerId, gapRes.modelId, gapRes.usage);
247261
addUsage(gapCost, gapRes.usage?.input_tokens ?? 0, gapRes.usage?.output_tokens ?? 0);
248262
window.dispatchEvent(new Event("ados:usage"));
249263
const gapParsed = tryParseJson<any>(gapRes.text) ?? {};
250-
console.log("[adforge:brand-extract] parsed AI JSON (gap-fill):", gapParsed);
264+
dlog("[adforge:brand-extract] parsed AI JSON (gap-fill):", gapParsed);
251265
// Coerce each gap-fill value to the BrandBrain schema's expected type.
252266
// The model often returns "Instagram, LinkedIn" (string) for an array
253267
// field; without coercion, brain.platforms.join(...) downstream throws.
254268
for (const f of missing) {
255269
const coerced = coerceFieldValue(f, gapParsed[f]);
256270
if (!isEmptyField(coerced)) parsed[f] = coerced;
257271
}
272+
// Trim the longer of objections / objection_handling so indices match.
273+
const objs: unknown = parsed.objections;
274+
const handles: unknown = parsed.objection_handling;
275+
if (Array.isArray(objs) && Array.isArray(handles)) {
276+
const len = Math.min(objs.length, handles.length);
277+
parsed.objections = objs.slice(0, len);
278+
parsed.objection_handling = handles.slice(0, len);
279+
}
258280
} catch (gapErr) {
259281
console.warn("[adforge:brand-extract] gap-fill pass failed:", gapErr);
260282
}
@@ -287,12 +309,12 @@ function Inner() {
287309
maxTokens: 3000,
288310
temperature: 0.4,
289311
});
290-
console.log("[adforge:brand-extract] raw AI response text (paste):", res.text);
312+
dlog("[adforge:brand-extract] raw AI response text (paste):", res.text);
291313
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
292314
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
293315
window.dispatchEvent(new Event("ados:usage"));
294316
const parsed = tryParseJson<any>(res.text) ?? {};
295-
console.log("[adforge:brand-extract] parsed AI JSON (paste):", parsed);
317+
dlog("[adforge:brand-extract] parsed AI JSON (paste):", parsed);
296318
if (!parsed.business_name && !Object.keys(parsed).length) {
297319
setQuickStatus("AI returned no usable JSON. Check DevTools [adforge:brand-extract] logs and try again, or fall back to Method 3 / 4.");
298320
return;
@@ -326,12 +348,12 @@ function Inner() {
326348
maxTokens: 3000,
327349
temperature: 0.4,
328350
});
329-
console.log("[adforge:brand-extract] raw AI response text (google):", res.text);
351+
dlog("[adforge:brand-extract] raw AI response text (google):", res.text);
330352
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
331353
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
332354
window.dispatchEvent(new Event("ados:usage"));
333355
const parsed = tryParseJson<any>(res.text) ?? {};
334-
console.log("[adforge:brand-extract] parsed AI JSON (google):", parsed);
356+
dlog("[adforge:brand-extract] parsed AI JSON (google):", parsed);
335357
if (!parsed.business_name && !Object.keys(parsed).length) {
336358
setQuickStatus("AI returned no usable JSON from search results. Try a more specific query or fall back to Method 3 / 4.");
337359
return;
@@ -505,8 +527,11 @@ function Inner() {
505527
key={t.slug}
506528
onClick={async () => {
507529
const b = t.apply({ business_name: "" });
530+
// Save the skeleton but DON'T activate it yet — if the user
531+
// abandons the form their other tools would all run against
532+
// an empty brain. Activation happens when BrandBrainForm's
533+
// own save action fires. (Audit finding #62.)
508534
await saveBrain(b);
509-
setActiveBrainId(b.id);
510535
window.dispatchEvent(new Event("ados:brains-changed"));
511536
setEditing(b);
512537
}}

app/history/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ function HistoryInner() {
348348
<CopyButton text={a.output_text} />
349349
<button
350350
onClick={async () => {
351+
// Confirm dialog matches the brand-delete pattern in /brand —
352+
// single mis-click no longer triggers a delete. The 7-second
353+
// undo toast is the second safety net. (Audit finding #56.)
354+
if (!confirm(`Delete "${a.title.slice(0, 60)}"? You can undo from the toast for 7 seconds.`)) return;
351355
await softDeleteAd(a.id);
352356
refresh();
353357
showUndoToast({

app/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,20 @@ export default function Dashboard() {
7070
};
7171
}, [router]);
7272

73-
if (loading) return null;
73+
// Skeleton instead of `return null` to avoid the blank-flash + layout shift
74+
// when the user returns to the dashboard. (Audit finding #54.)
75+
if (loading) {
76+
return (
77+
<div className="animate-pulse">
78+
<div className="h-16 mb-6 border-b border-base-700/40" />
79+
<div className="grid md:grid-cols-3 gap-3">
80+
{[1, 2, 3, 4, 5, 6].map((i) => (
81+
<div key={i} className="h-32 border border-base-700 bg-base-900/40" />
82+
))}
83+
</div>
84+
</div>
85+
);
86+
}
7487

7588
return (
7689
<div>

app/sitemap.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { MetadataRoute } from "next";
2+
import { PLATFORM_HUBS } from "@/lib/platform-hubs";
3+
import { CONCEPTS } from "@/lib/learn-content";
4+
import { COURSES } from "@/lib/courses";
5+
6+
const BASE = "https://adforge.dicecodes.com";
7+
8+
// Surface only the public, evergreen routes to search engines. Gated app
9+
// routes (generators, optimizers, settings) sit behind ApiKeyGate and produce
10+
// near-empty pages for crawlers. (Audit finding #50.)
11+
export default function sitemap(): MetadataRoute.Sitemap {
12+
const lastModified = new Date();
13+
const fixed: MetadataRoute.Sitemap = [
14+
{ url: `${BASE}/`, lastModified, changeFrequency: "weekly", priority: 1.0 },
15+
{ url: `${BASE}/about`, lastModified, changeFrequency: "monthly", priority: 0.8 },
16+
{ url: `${BASE}/benchmarks`, lastModified, changeFrequency: "weekly", priority: 0.7 },
17+
{ url: `${BASE}/platforms`, lastModified, changeFrequency: "monthly", priority: 0.8 },
18+
{ url: `${BASE}/learn`, lastModified, changeFrequency: "monthly", priority: 0.7 },
19+
{ url: `${BASE}/learn/courses`, lastModified, changeFrequency: "monthly", priority: 0.7 },
20+
{ url: `${BASE}/learn/frameworks`, lastModified, changeFrequency: "monthly", priority: 0.6 },
21+
{ url: `${BASE}/launch-guide`, lastModified, changeFrequency: "monthly", priority: 0.7 },
22+
];
23+
const platformPages: MetadataRoute.Sitemap = Object.keys(PLATFORM_HUBS).map((slug) => ({
24+
url: `${BASE}/platforms/${slug}`,
25+
lastModified,
26+
changeFrequency: "monthly",
27+
priority: 0.7,
28+
}));
29+
const conceptPages: MetadataRoute.Sitemap = CONCEPTS.map((c) => ({
30+
url: `${BASE}/learn/${c.slug}`,
31+
lastModified,
32+
changeFrequency: "monthly",
33+
priority: 0.5,
34+
}));
35+
const coursePages: MetadataRoute.Sitemap = COURSES.flatMap((c) => [
36+
{ url: `${BASE}/learn/courses/${c.slug}`, lastModified, changeFrequency: "monthly" as const, priority: 0.5 },
37+
...c.lessons.map((l) => ({
38+
url: `${BASE}/learn/courses/${c.slug}/${l.slug}`,
39+
lastModified,
40+
changeFrequency: "monthly" as const,
41+
priority: 0.4,
42+
})),
43+
]);
44+
return [...fixed, ...platformPages, ...conceptPages, ...coursePages];
45+
}

components/Markdown.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@ import { memo } from "react";
77
* Handles: # headers, **bold**, lists, code blocks. No HTML passthrough.
88
*/
99
function escape(s: string): string {
10+
// Quote escape included for defense-in-depth — currently renderInline only
11+
// emits <strong>/<em>/<code> with no attributes, but any future tag with
12+
// attributes (e.g. <a href>) would otherwise be vulnerable to attribute
13+
// injection via `**foo" onmouseover="alert(1)**`. (Audit finding #47.)
1014
return s
1115
.replace(/&/g, "&amp;")
1216
.replace(/</g, "&lt;")
13-
.replace(/>/g, "&gt;");
17+
.replace(/>/g, "&gt;")
18+
.replace(/"/g, "&quot;")
19+
.replace(/'/g, "&#39;");
1420
}
1521

1622
function renderInline(s: string): string {

components/ServiceWorkerRegister.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,24 @@ export function ServiceWorkerRegister() {
77
if (typeof window === "undefined") return;
88
if (!("serviceWorker" in navigator)) return;
99
if (process.env.NODE_ENV !== "production") return;
10+
1011
navigator.serviceWorker.register("/sw.js").catch(() => {});
12+
13+
// When a new SW takes over (skipWaiting + claim fires after a deploy),
14+
// reload the page so the shell HTML matches the new chunk URLs. Without
15+
// this, users on the stale cached shell may see broken UI references for
16+
// a session. (Audit finding #55.) Guarded against the first install on a
17+
// fresh visit, where there was no previous controller.
18+
let reloaded = false;
19+
const onControllerChange = () => {
20+
if (reloaded) return;
21+
reloaded = true;
22+
window.location.reload();
23+
};
24+
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
25+
return () => {
26+
navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
27+
};
1128
}, []);
1229
return null;
1330
}

components/UndoToast.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,33 @@ export function showUndoToast(detail: UndoEvent) {
2020
}
2121

2222
export function UndoToast() {
23-
const [evt, setEvt] = useState<UndoEvent | null>(null);
23+
// Queue of pending undo events. A rapid double-delete used to silently drop
24+
// the first one because we only held a single slot. Now the second event
25+
// joins the queue and surfaces when the first dismisses / times out.
26+
// (Audit finding #43.)
27+
const [queue, setQueue] = useState<UndoEvent[]>([]);
2428
const [remaining, setRemaining] = useState(0);
2529
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
30+
const evt = queue[0] ?? null;
2631

2732
useEffect(() => {
2833
const onUndo = (e: CustomEvent<UndoEvent>) => {
29-
setEvt(e.detail);
30-
setRemaining(e.detail.timeoutMs ?? 7000);
34+
setQueue((q) => [...q, e.detail]);
3135
};
3236
window.addEventListener("ados:undo", onUndo);
3337
return () => window.removeEventListener("ados:undo", onUndo);
3438
}, []);
3539

3640
useEffect(() => {
37-
if (!evt) return;
41+
if (!evt) { setRemaining(0); return; }
42+
setRemaining(evt.timeoutMs ?? 7000);
3843
if (tickRef.current) clearInterval(tickRef.current);
3944
tickRef.current = setInterval(() => {
4045
setRemaining((r) => {
4146
const next = r - 100;
4247
if (next <= 0) {
4348
if (tickRef.current) clearInterval(tickRef.current);
44-
setEvt(null);
49+
setQueue((q) => q.slice(1));
4550
return 0;
4651
}
4752
return next;
@@ -64,13 +69,13 @@ export function UndoToast() {
6469
<button
6570
onClick={async () => {
6671
await evt.undo();
67-
setEvt(null);
72+
setQueue((q) => q.slice(1));
6873
}}
6974
className="flex items-center gap-1.5 text-[12px] font-semibold uppercase tracking-wide text-live hover:bg-live/10 px-2 py-1"
7075
>
7176
<Undo2 size={12} /> Undo
7277
</button>
73-
<button onClick={() => setEvt(null)} className="text-ink-faint hover:text-ink" aria-label="Dismiss">
78+
<button onClick={() => setQueue((q) => q.slice(1))} className="text-ink-faint hover:text-ink" aria-label="Dismiss">
7479
<X size={14} />
7580
</button>
7681
</div>

lib/local-sync.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
* API keys are NOT synced by default (security). Toggle in Settings to include.
1212
*/
1313

14-
const SYNC_URL = "http://localhost:3006";
14+
// 127.0.0.1 instead of "localhost" so IPv6-preferring systems (some Windows
15+
// configs) don't resolve to ::1 while the sidecar listens on 127.0.0.1 only.
16+
// Matches url-ingest.ts. (Audit finding #49.)
17+
const SYNC_URL = "http://127.0.0.1:3006";
1518
const SYNC_DEBOUNCE_MS = 1500;
1619
const LS_INCLUDE_KEYS = "ados.sync_include_keys";
1720
const LS_LAST_SYNC = "ados.sync_last_at";

lib/prompts/audience-targeting.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export interface AudienceTargetingInput {
1010
current_aov_or_ltv?: string;
1111
existing_audiences?: string;
1212
best_creator_audience?: string;
13+
/** Screenshot of the Audiences tab — when present, vision-capable providers
14+
* read it. When absent, the "IF AN IMAGE IS ATTACHED" block is stripped so
15+
* the AI doesn't hallucinate audience data. (Audit finding #45.) */
16+
audience_screenshot?: unknown;
1317
}
1418

1519
import { RETARGETING_MATRIX, MANDATORY_EXCLUSIONS } from "./common-rules";
@@ -43,12 +47,12 @@ INPUT — audiences currently running (name · monthly spend · CPA, one per lin
4347
${input.existing_audiences || "(none provided — treat the plan as a fresh launch)"}
4448
"""
4549
46-
IF AN IMAGE IS ATTACHED:
47-
The user has dropped a screenshot of their Audiences tab (Meta Ads Manager / Google Audiences /
50+
${input.audience_screenshot ? `IMAGE ATTACHED:
51+
The user dropped a screenshot of their Audiences tab (Meta Ads Manager / Google Audiences /
4852
LinkedIn). Extract for each visible row: audience name, audience size, spend, impressions, CPA,
4953
CVR. When typed fields and image conflict, trust the image. When metrics only appear in the image,
5054
USE the image values. Cite "(from screenshot)" in audience_diagnosis when image-derived data drives
51-
a tier or budget recommendation.
55+
a tier or budget recommendation.` : ``}
5256
5357
PHASE 1 — ANALYZE THE EXISTING SETUP:
5458
For each audience already running:

0 commit comments

Comments
 (0)