fix(agent): stop sending every page view to a third-party avatar CDN (#1226)
* fix(agent): stop sending every page view to a third-party avatar CDN Eight avatar SVGs were loaded from api.dicebear.com on every render. In an accounting product that meant every authenticated page view told a third party who was looking at it, from a domain we do not control, on the path of a logged-in surface. A firewalled or self-hosted install showed no faces at all. The SVGs are now generated once and served from public/agent-avatars. Each entry records the seed it came from, so the set can be regenerated reproducibly, and the command to do it is in the file. The licence question that made this look like a founder decision resolved itself on inspection: Notionists is by Zoish under CC0 1.0, public domain, no attribution required. Confirmed on dicebear.com/licenses and, more usefully, in each downloaded file's own RDF metadata, so the terms travel with the asset rather than living in a commit message. Tests pin the properties that matter rather than the file list: no entry may be a remote URL, every entry must have a file behind it, and no shipped SVG may carry an <image href>, a url(https://…), an xlink:href or a <script>, since self-hosting a file that then phones home would reintroduce exactly the request this removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(agent): assert the property, not a list of elements, for avatar externals The external-reference check enumerated <image href>, url(https://…) and xlink:href, which left <use href>, <feImage href> and scheme-relative //host through: exactly the requests the guard claims to prevent, via elements it happened not to list. That is how this sort of allowlist rots. It now strips the parts that legitimately carry URLs and are never fetched (the RDF metadata block, xmlns declarations) and then asserts that NOTHING in what remains points off-origin. Verified by injecting each of the four bypasses into a real asset and confirming the test fails on all of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@@ -599,3 +599,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-27] Analytics is hosted-only and explicitly so: isAnalyticsEnabled() short-circuits on NEXT_PUBLIC_SELF_HOSTED and no __NEXT_PUBLIC_POSTHOG_*__ sentinel was added to Dockerfile/docker-entrypoint.sh. Recapt reached the same outcome only by accident (its env var was simply missing from the sentinel list), which meant a self-hosted operator could never have configured it and nobody had decided that on purpose. An AGPL operator's users should not be reported to our project.
|
||||
[2026-07-27] Removing Recapt made lib/support/submit-feedback.ts single-channel: a failing /api/support/contact now surfaces as a real error instead of being masked by Recapt reporting success on its own channel. That is the correct behaviour (silently "succeeding" while the message reached nobody was worse) and the Resend path is solid. A non-blocking posthog.capture('support_feedback_submitted') replaces the useful half of the Recapt channel by putting the submission on the user's timeline next to the session replay; it deliberately carries no message body, since free text is user content and would be PII in an event property.
|
||||
[2026-07-27] vitest.config.ts now aliases 'server-only' to tests/stubs/server-only.ts. Its real entry point throws unconditionally (Next.js swaps it out at bundle time; Vitest cannot), so the moment a server-only module entered the test graph it broke 48 test files at import. app/(dashboard)/request-context.ts was already carrying the same latent trap and had simply never been imported by a test.
|
||||
[2026-07-27] Self-hosted the dicebear Notionists avatars under public/agent-avatars instead of loading api.dicebear.com per render: the licence turned out to be CC0 1.0 (verified on dicebear.com/licenses AND in each file's own RDF metadata), so there was no licence decision to escalate, and the CDN was sending every authenticated page view's IP and referer to a third party while breaking firewalled/self-hosted installs entirely.
|
||||
|
||||
@@ -11,14 +11,13 @@ interface Props {
|
||||
alt?: string
|
||||
}
|
||||
|
||||
// Renders the agent's avatar: either the chosen dicebear SVG from the
|
||||
// AVATAR_OPTIONS registry, or a fallback MessageCircle glyph on a dark circle
|
||||
// when no avatar is set yet (free tier / older profiles).
|
||||
// Renders the agent's avatar: either the chosen SVG from the AVATAR_OPTIONS
|
||||
// registry, or a fallback MessageCircle glyph on a dark circle when no avatar
|
||||
// is set yet (free tier / older profiles).
|
||||
//
|
||||
// `next/image` is intentionally NOT used: avatars are tiny remote SVGs from
|
||||
// the dicebear CDN, and adding the domain to next.config just to render a
|
||||
// 28px image is overkill. Browser caches the SVG forever via the seed-keyed
|
||||
// URL.
|
||||
// `next/image` is intentionally NOT used: these are tiny static SVGs served
|
||||
// from our own /public, and the optimizer does not process SVG anyway, so it
|
||||
// would add a round trip through /_next/image for nothing.
|
||||
export default function AgentAvatar({ avatarId, size = 'sm', className, alt }: Props) {
|
||||
const url = getAvatarUrl(avatarId)
|
||||
const dim = SIZES[size]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { AVATAR_OPTIONS, getAvatarUrl } from '../avatars'
|
||||
|
||||
/**
|
||||
* These avatars used to be fetched from api.dicebear.com on every render, so
|
||||
* every authenticated page view of an accounting product told a third party
|
||||
* who was looking at it, and a firewalled or self-hosted install showed no
|
||||
* faces at all. The point of these tests is that the registry cannot quietly
|
||||
* drift back to a remote URL, and that every entry actually has a file.
|
||||
*/
|
||||
|
||||
const PUBLIC_DIR = join(process.cwd(), 'public')
|
||||
|
||||
describe('AVATAR_OPTIONS', () => {
|
||||
it('serves every avatar from our own origin', () => {
|
||||
for (const option of AVATAR_OPTIONS) {
|
||||
expect(option.url.startsWith('/'), `${option.id} must be a local path`).toBe(true)
|
||||
expect(option.url).not.toMatch(/^https?:/)
|
||||
expect(option.url).not.toContain('dicebear.com')
|
||||
}
|
||||
})
|
||||
|
||||
it('has a real file behind every entry', () => {
|
||||
// A registry entry with no file renders a broken image, which looks like a
|
||||
// bug in the agent rather than a missing asset.
|
||||
for (const option of AVATAR_OPTIONS) {
|
||||
expect(existsSync(join(PUBLIC_DIR, option.url)), `missing file for ${option.id}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('ships avatars that make no network requests of their own', () => {
|
||||
// Self-hosting the file is pointless if the file then phones home. This
|
||||
// asserts the PROPERTY (nothing points off-origin) rather than a list of
|
||||
// elements: an allowlist of <image> and xlink:href would still let a
|
||||
// future asset through via <use href>, <feImage href>, or a
|
||||
// scheme-relative //host, which is how this sort of guard rots.
|
||||
for (const option of AVATAR_OPTIONS) {
|
||||
const svg = readFileSync(join(PUBLIC_DIR, option.url), 'utf8')
|
||||
|
||||
// Namespace declarations and the licence metadata legitimately contain
|
||||
// URLs and are never fetched, so they are removed before the check
|
||||
// rather than special-cased inside it.
|
||||
const referencing = svg
|
||||
.replace(/<metadata[\s\S]*?<\/metadata>/gi, '')
|
||||
.replace(/xmlns(:[a-z0-9-]+)?\s*=\s*"[^"]*"/gi, '')
|
||||
.replace(/xsi:type\s*=\s*"[^"]*"/gi, '')
|
||||
|
||||
expect(referencing, `${option.id} references an absolute URL`).not.toMatch(/https?:\/\//i)
|
||||
// Scheme-relative: "//host/x" inherits the page's scheme and still
|
||||
// leaves the origin.
|
||||
expect(referencing, `${option.id} references a scheme-relative URL`).not.toMatch(
|
||||
/(href|src)\s*=\s*"\/\//i,
|
||||
)
|
||||
expect(referencing).not.toMatch(/url\(\s*['"]?\/\//i)
|
||||
expect(referencing, `${option.id} contains a script`).not.toMatch(/<script/i)
|
||||
expect(referencing, `${option.id} contains a foreignObject`).not.toMatch(/<foreignObject/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps ids and files one-to-one', () => {
|
||||
const ids = AVATAR_OPTIONS.map((a) => a.id)
|
||||
const urls = AVATAR_OPTIONS.map((a) => a.url)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(new Set(urls).size).toBe(urls.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvatarUrl', () => {
|
||||
it('resolves a known id and returns null otherwise', () => {
|
||||
expect(getAvatarUrl('notionists-1')).toBe('/agent-avatars/notionists-1.svg')
|
||||
// Null is what makes AgentAvatar fall back to its glyph, so an id from an
|
||||
// older profile degrades to a placeholder rather than a broken image.
|
||||
expect(getAvatarUrl('notionists-99')).toBeNull()
|
||||
expect(getAvatarUrl(null)).toBeNull()
|
||||
expect(getAvatarUrl(undefined)).toBeNull()
|
||||
expect(getAvatarUrl('')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not resolve inherited Object keys', () => {
|
||||
expect(getAvatarUrl('toString')).toBeNull()
|
||||
expect(getAvatarUrl('constructor')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,47 @@
|
||||
// Avatar registry for the specialized accountant agent.
|
||||
//
|
||||
// We use the dicebear "notionists" style: clean line illustrations that
|
||||
// match the editorial monochrome brand without the cartoony feel of most
|
||||
// avatar libraries. 8 hand-picked seeds give distinct faces without being
|
||||
// The dicebear "notionists" style: clean line illustrations that match the
|
||||
// editorial monochrome brand without the cartoony feel of most avatar
|
||||
// libraries. 8 hand-picked seeds give distinct faces without being
|
||||
// overwhelming. The user picks one during Phase B review; the choice is
|
||||
// persisted as agent_profiles.avatar_id.
|
||||
//
|
||||
// URLs are served by dicebear's free CDN. They're public SVGs derived from
|
||||
// the seed only: no user data leaves gnubok. If we ever need fully offline
|
||||
// generation, swap to @dicebear/core npm package and render server-side.
|
||||
// The SVGs are SELF-HOSTED under public/agent-avatars, generated once from the
|
||||
// dicebear API with the seeds recorded below. They used to be loaded from
|
||||
// api.dicebear.com on every render, which meant every authenticated page view
|
||||
// of an accounting product sent the user's IP, user-agent and referer to a
|
||||
// third party, and put a CDN this app does not control on the path of a
|
||||
// logged-in surface. A self-hosted or firewalled install simply showed no
|
||||
// faces at all.
|
||||
//
|
||||
// Licence: the Notionists set is by Zoish under CC0 1.0 (public domain, no
|
||||
// attribution required). Each file carries that statement in its own RDF
|
||||
// metadata, so the terms travel with the asset.
|
||||
//
|
||||
// To regenerate or add a seed:
|
||||
// curl -o public/agent-avatars/<id>.svg \
|
||||
// "https://api.dicebear.com/9.x/notionists/svg?seed=<seed>&radius=50&backgroundColor=f5f3ed"
|
||||
|
||||
export interface AvatarOption {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
/** The dicebear seed this file was generated from. Documentation, not runtime. */
|
||||
seed: string
|
||||
}
|
||||
|
||||
// Build URL from seed. Public dicebear CDN. ?radius=50 rounds the bounding
|
||||
// box; ?backgroundColor=transparent keeps the editorial paper-white feel.
|
||||
function dicebearNotionists(seed: string): string {
|
||||
return `https://api.dicebear.com/9.x/notionists/svg?seed=${encodeURIComponent(seed)}&radius=50&backgroundColor=f5f3ed`
|
||||
}
|
||||
|
||||
// Eight neutral seeds: names chosen to produce visibly different faces.
|
||||
// Labels are just for the picker tooltip; the user names the agent
|
||||
// themselves in the adjacent text field.
|
||||
// Labels are just for the picker tooltip; the user names the agent themselves
|
||||
// in the adjacent text field. `seed` is not read at runtime: it records what
|
||||
// each file was generated from, so the set can be regenerated reproducibly.
|
||||
export const AVATAR_OPTIONS: readonly AvatarOption[] = [
|
||||
{ id: 'notionists-1', label: 'Linn', url: dicebearNotionists('linn-revisor-1') },
|
||||
{ id: 'notionists-2', label: 'Erik', url: dicebearNotionists('erik-revisor-2') },
|
||||
{ id: 'notionists-3', label: 'Maja', url: dicebearNotionists('maja-revisor-3') },
|
||||
{ id: 'notionists-4', label: 'Anders', url: dicebearNotionists('anders-revisor-4') },
|
||||
{ id: 'notionists-5', label: 'Karin', url: dicebearNotionists('karin-revisor-5') },
|
||||
{ id: 'notionists-6', label: 'Johan', url: dicebearNotionists('johan-revisor-6') },
|
||||
{ id: 'notionists-7', label: 'Eva', url: dicebearNotionists('eva-revisor-7') },
|
||||
{ id: 'notionists-8', label: 'Per', url: dicebearNotionists('per-revisor-8') },
|
||||
{ id: 'notionists-1', label: 'Linn', url: '/agent-avatars/notionists-1.svg', seed: 'linn-revisor-1' },
|
||||
{ id: 'notionists-2', label: 'Erik', url: '/agent-avatars/notionists-2.svg', seed: 'erik-revisor-2' },
|
||||
{ id: 'notionists-3', label: 'Maja', url: '/agent-avatars/notionists-3.svg', seed: 'maja-revisor-3' },
|
||||
{ id: 'notionists-4', label: 'Anders', url: '/agent-avatars/notionists-4.svg', seed: 'anders-revisor-4' },
|
||||
{ id: 'notionists-5', label: 'Karin', url: '/agent-avatars/notionists-5.svg', seed: 'karin-revisor-5' },
|
||||
{ id: 'notionists-6', label: 'Johan', url: '/agent-avatars/notionists-6.svg', seed: 'johan-revisor-6' },
|
||||
{ id: 'notionists-7', label: 'Eva', url: '/agent-avatars/notionists-7.svg', seed: 'eva-revisor-7' },
|
||||
{ id: 'notionists-8', label: 'Per', url: '/agent-avatars/notionists-8.svg', seed: 'per-revisor-8' },
|
||||
]
|
||||
|
||||
export function getAvatarUrl(avatarId: string | null | undefined): string | null {
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |