Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Off-plan riders (pulled forward, rename-independent):
- 2026-07-11: S12 ruling — quiet-submit now (registry + directory submissions run this week; review/listing latency burns down while unannounced), the public announcement itself still holds for the late Aug–Sep window.
- 2026-07-11: Pregen + Blob portraits — both armed, pending owner secrets (~$2.50–3.80/mo pregen + ~cents/mo Blob), surfaced and approved.
- 2026-07-15: HCB application DENIED (HCB currently sponsors teen hacker builds only) — the citizen-donations rail is undecided again; About/footer copy scrubbed of every fiscal-sponsor/501(c)(3)/tax-deductible claim and `DonateSupport` retired in branch `feat/funding-transparency` (founder-funded framing is the single truthful surface); `DONATE_URL` stays null until a replacement rail is chosen.
- 2026-07-16: Embeds brand preview (planned session, plan `we-need-planning-mode`) — theme surface widened to the full spec-§3.4 set (surface/ink pair AA-gated 4.5:1, 4 system font stacks, forced light/dark/auto; delivered via one validated `:root` style tag, widgets lost the theme prop) + `POST /api/brand` (second Anthropic endpoint: SSRF-guarded stateless homepage fetch → regex signal extraction → `claude-sonnet-5` maps onto the closed knobs, ~$0.008/preview, per-IP 5/10min + global 250/day breaker ≈ $2/day worst case — surfaced and approved at plan review) + "Match your site" autofill UI on /embeds with mock-site strip. URL truncated to origin at parse, never stored/logged; cache is in-memory-only. Branch `feat/embeds-brand-preview`.

## Issues & learnings ledger (compound these)

Expand Down
141 changes: 141 additions & 0 deletions app/api/brand/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Anthropic from '@anthropic-ai/sdk';
import { after, NextRequest, NextResponse } from 'next/server';
import { createLruCache } from '@/lib/brand-cache';
import { extractFromHtml, mergeCssSignals, type BrandCandidates } from '@/lib/brand-extract';
import { fetchGuarded, normalizeBrandUrl } from '@/lib/brand-fetch';
import {
BRAND_MAX_TOKENS,
BRAND_MODEL,
buildBrandPrompt,
finalizeBrandTheme,
parseBrandResponse,
type BrandTheme,
} from '@/lib/brandprompt';
import { callerIp, createRateLimiter, createTenantRateLimiter } from '@/lib/ratelimit';
import { noteBrandPreview } from '@/lib/usage';

/*
* The brand-preview endpoint (brand-preview build): an org considering the
* white-label tier submits its site URL from /embeds and gets back a theme
* suggestion in the closed knob set. The SECOND Anthropic-calling endpoint
* in Oravan (after /api/script) — spend posture below.
*
* Stateless by the /api/district doctrine, strengthened: POST so the URL
* never lands in an access log; the URL is truncated to its ORIGIN inside
* normalizeBrandUrl (the path never exists past that call); the only cache
* is a per-instance in-memory LRU (lib/brand-cache — never a database, so
* the /embeds privacy copy "the address is never stored" is literally
* true); and NO catch below logs anything, not even the error object — a
* fetch error can embed the hostname.
*
* Spend posture (every $ decision surfaced — orchestrator rule):
* - per-IP limiter 5/10min ('brand') — tighter than district's 10,
* because every miss spends money (fetch + one BRAND_MODEL call);
* - a GLOBAL daily breaker 250/day ('brand-day' via the tenant limiter
* keyed by the constant 'brand-global') — unauthenticated spending
* endpoint, no cross-user cache to blunt a distributed farm. Worst-case
* day ≈ 250 × ~$0.008 ≈ $2 on claude-sonnet-5.
* - usage counter (noteBrandPreview) fires via after() on the actual-
* spend path only, mirroring /api/script's noteScriptGeneration.
*
* Error taxonomy (uniform bodies, nothing caller-specific ever echoed):
* 400 bad_request — malformed body/URL, or the SSRF guard refused
* 429 rate_limited — either limiter tripped (deliberately not
* distinguished, same rule as /api/script)
* 502 unavailable — the site couldn't be fetched (block/timeout/
* TLS/not-HTML) — soft dead-end, the UI says
* "set the colors manually"
* 502 generation_failed — Anthropic failed, or its output didn't survive
* the fail-closed validators
*/

const anthropic = new Anthropic();

const limiter = createRateLimiter({ route: 'brand', max: 5, windowSec: 600 });
const dayLimiter = createTenantRateLimiter({ route: 'brand-day', max: 250, windowSec: 86400 });
/** The global-breaker key: a constant, not caller/content material. */
const GLOBAL_BUCKET = 'brand-global';

interface BrandResponse {
theme: BrandTheme;
site: { name?: string; logoUrl?: string };
adjusted: boolean;
}

const cache = createLruCache<BrandResponse>({ max: 100, ttlMs: 15 * 60 * 1000 });

const HTML_FETCH = {
maxBytes: 1_572_864, // 1.5 MB — truncation is success
timeoutMs: 8000,
maxRedirects: 3,
contentTypes: ['text/html', 'application/xhtml+xml'],
};

const CSS_FETCH = {
maxBytes: 262_144, // 256 KB per stylesheet, max 2 stylesheets
timeoutMs: 4000,
maxRedirects: 2,
contentTypes: ['text/css'],
};

export async function POST(req: NextRequest) {
const ip = callerIp(req.headers);
if ((await limiter.isLimited(ip)) || (await dayLimiter.isLimited(GLOBAL_BUCKET))) {
return NextResponse.json({ error: 'rate_limited' }, { status: 429 });
}

let body: { url?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
}

const normalized = normalizeBrandUrl(body.url);
if (!normalized.ok) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
}
const { origin } = normalized;

const hit = cache.get(origin);
if (hit) return NextResponse.json(hit);

const page = await fetchGuarded(`${origin}/`, HTML_FETCH);
if (!page.ok) {
return NextResponse.json({ error: 'unavailable' }, { status: 502 });
}

let candidates: BrandCandidates = extractFromHtml(page.text, page.finalUrl);
for (const stylesheetUrl of candidates.stylesheets) {
// Same-origin was enforced at extraction; each fetch re-guards anyway.
// A failed stylesheet is just a skipped signal, never an error.
const css = await fetchGuarded(stylesheetUrl, CSS_FETCH);
if (css.ok) candidates = mergeCssSignals(candidates, css.text);
}

try {
const msg = await anthropic.messages.create({
model: BRAND_MODEL,
max_tokens: BRAND_MAX_TOKENS,
thinking: { type: 'disabled' },
messages: [{ role: 'user', content: buildBrandPrompt(candidates) }],
});
const text = msg.content[0].type === 'text' ? msg.content[0].text : '';
const finalized = finalizeBrandTheme(parseBrandResponse(text));
if (!finalized) throw new Error('unusable');

const response: BrandResponse = {
theme: finalized.theme,
site: { name: candidates.siteName, logoUrl: candidates.logoUrl },
adjusted: finalized.adjusted,
};
cache.set(origin, response);
// Count only actual spend (this is the cache-miss path by construction);
// after() so a slow counter write never delays the response.
after(() => noteBrandPreview());
return NextResponse.json(response);
} catch {
// Log-nothing doctrine: the error object can embed the request context.
return NextResponse.json({ error: 'generation_failed' }, { status: 502 });
}
}
76 changes: 36 additions & 40 deletions app/embed/action-panel/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,14 @@ import { formatCitation } from '@/lib/format';
import { registrableDomain } from '@/lib/embed-referrer';
import { noteImpression } from '@/lib/impressions';
import {
FONT_VALUES,
RADIUS_VALUES,
safeAccent,
resolveEmbedTheme,
safeAttribution,
safeBrandless,
safeFontKey,
safeRadiusKey,
type FontKey,
type RadiusKey,
type ResolvedEmbedTheme,
} from '@/lib/embed-theme';
import { resolveTenantAccess } from '@/lib/tenancy';
import { ActionPanelWidget, type ActionPanelBillData } from '@/components/embed/ActionPanelWidget';
import { EmbedThemeStyle } from '@/components/embed/EmbedThemeStyle';

export async function generateMetadata({
searchParams,
Expand All @@ -40,12 +36,6 @@ function normalizeLocale(value: string | undefined): 'en' | 'es' {
type EmbedLocale = 'en' | 'es';
const DICTS: Record<EmbedLocale, typeof en> = { en, es };

interface EmbedThemeInput {
accent?: string;
radiusKey: RadiusKey;
fontKey: FontKey;
}

/*
* The action-panel embed (S19, paid tier only). Same theming/locale
* conventions as rep-lookup (S13) and bill-card (S14) - see those files'
Expand All @@ -71,21 +61,21 @@ export default async function ActionPanelEmbedPage({
slug?: string;
token?: string;
accent?: string;
surface?: string;
ink?: string;
mode?: string;
radius?: string;
font?: string;
brandless?: string;
attribution?: string;
}>;
}) {
const { locale: localeParam, slug, token, accent, radius, font, brandless, attribution } = await searchParams;
const { locale: localeParam, slug, token, accent, surface, ink, mode, radius, font, brandless, attribution } =
await searchParams;
const locale = normalizeLocale(localeParam);
const t = DICTS[locale];
const brandlessFlag = safeBrandless(brandless);
const theme: EmbedThemeInput = {
accent: safeAccent(accent),
radiusKey: safeRadiusKey(radius),
fontKey: safeFontKey(font),
};
const theme = resolveEmbedTheme({ accent, surface, ink, mode, radius, font });

const access = await resolveTenantAccess(token ?? null);
if (!access.ok) {
Expand Down Expand Up @@ -158,41 +148,47 @@ export default async function ActionPanelEmbedPage({
after(() => noteImpression(tenant.tenantId));

return (
<ActionPanelWidget
initialLocale={locale}
token={token!}
bill={billData}
theme={theme}
brandless={brandlessFlag}
attribution={safeAttribution(attribution)}
/>
<>
<EmbedThemeStyle theme={theme} />
<ActionPanelWidget
initialLocale={locale}
token={token!}
bill={billData}
brandless={brandlessFlag}
attribution={safeAttribution(attribution)}
/>
</>
);
}

/** Shared server-rendered chrome for every non-live (refusal) state. */
/**
* Shared server-rendered chrome for every non-live (refusal) state. Renders
* its own EmbedThemeStyle: refusal iframes never resize (no client widget
* mounts), so the :root-level style tag is the only thing keeping the whole
* fixed-height frame — including the band below this short content — on the
* tenant's palette.
*/
function ActionPanelMessage({
locale,
theme,
brandless,
children,
}: {
locale: EmbedLocale;
theme: EmbedThemeInput;
theme: ResolvedEmbedTheme;
brandless: boolean;
children: React.ReactNode;
}) {
const t = DICTS[locale];
const themeStyle: React.CSSProperties = {
...(theme.accent ? { ['--oravan-accent' as string]: theme.accent } : {}),
['--oravan-radius' as string]: RADIUS_VALUES[theme.radiusKey],
['--oravan-font' as string]: FONT_VALUES[theme.fontKey],
};
return (
<main className="re-root" lang={locale} style={themeStyle}>
<div className="re-header">
<p className="bc-citation">{brandless ? '' : t.common.appName}</p>
</div>
{children}
</main>
<>
<EmbedThemeStyle theme={theme} />
<main className="re-root" lang={locale}>
<div className="re-header">
<p className="bc-citation">{brandless ? '' : t.common.appName}</p>
</div>
{children}
</main>
</>
);
}
37 changes: 20 additions & 17 deletions app/embed/bill-card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { after } from 'next/server';
import { billSlug, getBill, localizeBill } from '@/lib/core';
import { formatCitation } from '@/lib/format';
import { getFreshness } from '@/lib/freshness';
import { safeAccent, safeAttribution, safeBrandless, safeFontKey, safeRadiusKey } from '@/lib/embed-theme';
import { resolveEmbedTheme, safeAttribution, safeBrandless } from '@/lib/embed-theme';
import { noteImpressionForToken } from '@/lib/impressions';
import { callerIp } from '@/lib/ratelimit';
import { BillCardWidget, type BillCardData } from '@/components/embed/BillCardWidget';
import { EmbedThemeStyle } from '@/components/embed/EmbedThemeStyle';

export async function generateMetadata({
searchParams,
Expand All @@ -28,9 +29,9 @@ function normalizeLocale(value: string | undefined): 'en' | 'es' {

/*
* The bill-card embed (S14). `locale` and `slug` are the content inputs a
* host page's iframe src (built by public/embed.js) supplies; `accent`,
* `radius`, and `font` are the theming inputs — every one of the three is
* validated through lib/embed-theme before it ever reaches a style prop
* host page's iframe src (built by public/embed.js) supplies; the theming
* inputs (accent/surface/ink/mode/radius/font) are each validated through
* lib/embed-theme's resolveEmbedTheme before they ever reach CSS
* (CSS-custom-properties-only theming, no exceptions). An unknown or
* missing slug renders the widget's own "bill not found" state rather than
* calling notFound() — this route has no not-found boundary of its own
Expand All @@ -51,13 +52,17 @@ export default async function BillCardEmbedPage({
slug?: string;
token?: string;
accent?: string;
surface?: string;
ink?: string;
mode?: string;
radius?: string;
font?: string;
brandless?: string;
attribution?: string;
}>;
}) {
const { locale: localeParam, slug, token, accent, radius, font, brandless, attribution } = await searchParams;
const { locale: localeParam, slug, token, accent, surface, ink, mode, radius, font, brandless, attribution } =
await searchParams;
const locale = normalizeLocale(localeParam);
const raw = typeof slug === 'string' && slug.length > 0 ? getBill(slug) : undefined;
const bill = raw ? localizeBill(raw, locale) : null;
Expand All @@ -78,17 +83,15 @@ export default async function BillCardEmbedPage({
: null;

return (
<BillCardWidget
initialLocale={locale}
bill={billData}
dataAsOf={getFreshness().checkedAt}
theme={{
accent: safeAccent(accent),
radiusKey: safeRadiusKey(radius),
fontKey: safeFontKey(font),
}}
brandless={safeBrandless(brandless)}
attribution={safeAttribution(attribution)}
/>
<>
<EmbedThemeStyle theme={resolveEmbedTheme({ accent, surface, ink, mode, radius, font })} />
<BillCardWidget
initialLocale={locale}
bill={billData}
dataAsOf={getFreshness().checkedAt}
brandless={safeBrandless(brandless)}
attribution={safeAttribution(attribution)}
/>
</>
);
}
Loading
Loading