2da96c0be2
* 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>
51 lines
2.7 KiB
TypeScript
51 lines
2.7 KiB
TypeScript
// Avatar registry for the specialized accountant agent.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
// 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: '/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 {
|
|
if (!avatarId) return null
|
|
return AVATAR_OPTIONS.find((a) => a.id === avatarId)?.url ?? null
|
|
}
|