From 61736ba3c98b2c54216dfb250a6bc1e82a5673c0 Mon Sep 17 00:00:00 2001 From: FireTable Date: Fri, 10 Jul 2026 20:36:31 +0800 Subject: [PATCH 1/5] fix(avatar): upload to R2 instead of base64 in user.image (#28) Base64 avatars written to user.image flowed through the memory auth-overlay into every system block uncapped, blowing the context window on small models (372K tokens on a one-line query). - new POST /api/avatar/presign: presigns a PUT to u//avatar/*, returns the public URL, no DB row; 503 when R2 unset - ChangeAvatar uploads via presign and stores only the URL; upload is disabled unless ATTACHMENTS_ENABLED (reuses the existing R2 gate) - rename the memory auth-overlay key image -> avatar - socials now carry the provider email decoded from the OIDC idToken (Google); GitHub has no idToken so it stays email-less (#10) Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 9 +- app/api/avatar/presign/route.ts | 86 ++++++++++++++ app/api/memory/profile/route.ts | 2 +- backend/memory/recall.ts | 2 +- backend/memory/template.ts | 2 +- backend/tool/memory/save-memory-tool.ts | 2 +- .../auth/settings/account/change-avatar.tsx | 40 ++++++- components/settings/memory-view.tsx | 2 +- docs/APIS.md | 10 ++ lib/memory/constants.ts | 2 +- lib/memory/queries.ts | 40 +++++-- lib/memory/validators.ts | 2 +- tests/api/avatar/presign.test.ts | 106 ++++++++++++++++++ tests/lib/memory/queries.test.ts | 28 ++++- 14 files changed, 305 insertions(+), 28 deletions(-) create mode 100644 app/api/avatar/presign/route.ts create mode 100644 tests/api/avatar/presign.test.ts diff --git a/.env.example b/.env.example index 491fdd6..9cf822b 100644 --- a/.env.example +++ b/.env.example @@ -171,10 +171,11 @@ R2_MAX_BYTES=10485760 # window.__CONFIG__ (see app/layout.tsx). R2_ALLOWED_CONTENT_TYPES=image/png,image/jpeg,image/webp # Attachments feature toggle — gates the assistant-ui `` -# attachment UI. Default false; flip to "true" once every required -# R2_* above is set. Frontend and backend must be in sync: if this -# stays "false" the composer hides the attachment widget, but routes -# still answer when called. Read in browser via window.__CONFIG__. +# attachment UI AND the Settings → Account avatar upload (both back onto +# R2). Default false; flip to "true" once every required R2_* above is +# set. Frontend and backend must be in sync: if this stays "false" the +# composer hides the attachment widget + the avatar upload is disabled, +# but routes still answer when called. Read in browser via window.__CONFIG__. ATTACHMENTS_ENABLED=false diff --git a/app/api/avatar/presign/route.ts b/app/api/avatar/presign/route.ts new file mode 100644 index 0000000..adecf4f --- /dev/null +++ b/app/api/avatar/presign/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { randomBytes } from "node:crypto"; + +import { R2NotConfiguredError, buildPublicUrl, presignPut } from "@/lib/r2/client"; +import { safeFilename } from "@/lib/attachments/keys"; +import { PresignBody } from "@/lib/attachments/validators"; +import { withAuth } from "@/lib/auth/with-auth"; + +// ponytail: avatars go to R2 under the user's own path, NOT base64 into +// user.image. A base64 data URL there flows through the memory auth-overlay +// (mergeMemory) into every system block uncapped — issue #28's +// 372K-token blow-up. This route hands back a presigned PUT + the public +// URL; the client stores only the URL. No DB row (avatars aren't chat +// attachments — no dedup / retention sweep needed). + +const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; +const ID_LEN = 12; + +function generateId(): string { + const bytes = randomBytes(ID_LEN); + let out = ""; + for (let i = 0; i < ID_LEN; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length]; + return out; +} + +function maxBytes(): number { + const parsed = process.env.R2_MAX_BYTES ? Number(process.env.R2_MAX_BYTES) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 1024 * 1024; +} + +function notConfiguredResponse(): NextResponse { + return NextResponse.json( + { + code: "AVATAR_UPLOADS_NOT_CONFIGURED", + message: "Set the R2_* env vars to enable avatar uploads (see docs/ATTACHMENTS.md).", + }, + { status: 503 }, + ); +} + +export const POST = withAuth(async (req, { user }) => { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ code: "BAD_REQUEST", error: "invalid JSON" }, { status: 400 }); + } + + const parsed = PresignBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ code: "BAD_REQUEST", error: parsed.error.issues }, { status: 400 }); + } + + const contentType = parsed.data.contentType.toLowerCase(); + if (!contentType.startsWith("image/")) { + return NextResponse.json({ code: "CONTENT_TYPE_NOT_ALLOWED", contentType }, { status: 400 }); + } + + const cap = maxBytes(); + if (parsed.data.sizeBytes > cap) { + return NextResponse.json( + { code: "FILE_TOO_LARGE", maxBytes: cap, sizeBytes: parsed.data.sizeBytes }, + { status: 400 }, + ); + } + + const key = `u/${user.id}/avatar/${generateId()}-${safeFilename(parsed.data.name)}`; + + let uploadUrl: string; + try { + uploadUrl = await presignPut({ key, contentLength: parsed.data.sizeBytes }); + } catch (e) { + if (e instanceof R2NotConfiguredError) return notConfiguredResponse(); + throw e; + } + + return NextResponse.json( + { + key, + uploadUrl, + publicUrl: buildPublicUrl(key), + uploadHeaders: { "Content-Type": contentType, "Content-Disposition": "inline" }, + }, + { status: 201 }, + ); +}); diff --git a/app/api/memory/profile/route.ts b/app/api/memory/profile/route.ts index 96211b1..596e8c8 100644 --- a/app/api/memory/profile/route.ts +++ b/app/api/memory/profile/route.ts @@ -22,7 +22,7 @@ export const GET = withAuth(async (_req, { user }) => { getAuthInfo(user.id).catch(() => ({ name: null, email: null, - image: null, + avatar: null, socials: [] as Array<{ provider: string }>, })), getRecentThreadSummaries(user.id).catch(() => []), diff --git a/backend/memory/recall.ts b/backend/memory/recall.ts index 2c28fb4..720bb0c 100644 --- a/backend/memory/recall.ts +++ b/backend/memory/recall.ts @@ -53,7 +53,7 @@ export async function loadMemory(userId: string): Promise { getAuthInfo(userId).catch(() => ({ name: null, email: null, - image: null, + avatar: null, socials: [] as Array<{ provider: string }>, })), ]); diff --git a/backend/memory/template.ts b/backend/memory/template.ts index b49450c..f23ab2b 100644 --- a/backend/memory/template.ts +++ b/backend/memory/template.ts @@ -14,7 +14,7 @@ import { formatSummaryText } from "@/lib/langgraph/format-summary"; // ponytail: the system prompt carries TWO dynamic blocks: // = the user's saved profile (keys + values), plus OAuth -// overlay (name / email / image / socials) when set. +// overlay (name / email / avatar / socials) when set. // = compressed Q&A history for THIS thread, pulled from // the store at invoke time. Mounts the historical // content the model would otherwise lose when older diff --git a/backend/tool/memory/save-memory-tool.ts b/backend/tool/memory/save-memory-tool.ts index 803b7e6..7d26094 100644 --- a/backend/tool/memory/save-memory-tool.ts +++ b/backend/tool/memory/save-memory-tool.ts @@ -33,7 +33,7 @@ function extractUserId(config?: { configurable?: { userId?: unknown } }): string return typeof raw === "string" && raw.length > 0 ? raw : null; } -const EMPTY_AUTH: AuthInfo = { name: null, email: null, image: null, socials: [] }; +const EMPTY_AUTH: AuthInfo = { name: null, email: null, avatar: null, socials: [] }; async function impl( input: SaveMemoryInput, diff --git a/components/auth/settings/account/change-avatar.tsx b/components/auth/settings/account/change-avatar.tsx index 92e619e..11edb73 100644 --- a/components/auth/settings/account/change-avatar.tsx +++ b/components/auth/settings/account/change-avatar.tsx @@ -1,6 +1,5 @@ "use client"; -import { fileToBase64 } from "@better-auth-ui/core"; import { useAuth, useSession, useUpdateUser } from "@better-auth-ui/react"; import { Trash2, Upload } from "lucide-react"; import { type ChangeEvent, useRef, useState } from "react"; @@ -22,6 +21,36 @@ export type ChangeAvatarProps = { className?: string; }; +// ponytail: avatars upload to R2 and we store ONLY the public URL. The +// old base64 fallback wrote a data URL into user.image, which the memory +// auth-overlay injected into every system block uncapped — +// issue #28's 372K-token blow-up. Gated on ATTACHMENTS_ENABLED (same R2 +// backing as chat attachments — no separate flag). Off → uploads disabled. +// Read at render (matches app/assistant.tsx) — window.__CONFIG__ is +// injected beforeInteractive. +function avatarUploadsEnabled(): boolean { + return typeof window !== "undefined" && window.__CONFIG__?.ATTACHMENTS_ENABLED === "true"; +} + +// ponytail: presign → PUT → return the public URL. Errors bubble to the +// caller's try/catch, which toasts them. +async function uploadAvatarToR2(file: File): Promise { + const presignRes = await fetch("/api/avatar/presign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: file.name, contentType: file.type, sizeBytes: file.size }), + }); + if (!presignRes.ok) { + throw new Error("Avatar upload is not available right now."); + } + const { uploadUrl, publicUrl, uploadHeaders } = await presignRes.json(); + const putRes = await fetch(uploadUrl, { method: "PUT", headers: uploadHeaders, body: file }); + if (!putRes.ok) { + throw new Error("Failed to upload avatar."); + } + return publicUrl; +} + export function ChangeAvatar({ className }: ChangeAvatarProps) { const { authClient, localization, avatar } = useAuth(); const { data: session } = useSession(authClient); @@ -33,6 +62,7 @@ export function ChangeAvatar({ className }: ChangeAvatarProps) { const [isDeleting, setIsDeleting] = useState(false); const isPending = updatePending || isUploading || isDeleting; + const canUpload = avatarUploadsEnabled(); async function handleFileChange(e: ChangeEvent) { const file = e.target.files?.[0]; @@ -40,12 +70,14 @@ export function ChangeAvatar({ className }: ChangeAvatarProps) { e.target.value = ""; + if (!canUpload) return; + setIsUploading(true); try { const resized = (await avatar.resize?.(file, avatar.size, avatar.extension)) || file; - const image = (await avatar.upload?.(resized)) || (await fileToBase64(resized)); + const image = await uploadAvatarToR2(resized); updateUser( { image }, @@ -101,7 +133,7 @@ export function ChangeAvatar({ className }: ChangeAvatarProps) { type="button" variant="ghost" className="p-0 h-auto w-auto rounded-full" - disabled={isPending} + disabled={isPending || !canUpload} onClick={() => fileInputRef.current?.click()} > @@ -118,7 +150,7 @@ export function ChangeAvatar({ className }: ChangeAvatarProps) { - fileInputRef.current?.click()}> + fileInputRef.current?.click()}> {localization.settings.uploadAvatar} diff --git a/components/settings/memory-view.tsx b/components/settings/memory-view.tsx index f3075a9..2ecfdfd 100644 --- a/components/settings/memory-view.tsx +++ b/components/settings/memory-view.tsx @@ -49,7 +49,7 @@ function buildRows(store: MemoryDoc, auth: AuthInfo): Row[] { const storeKeys = new Set(Object.keys(store)); const storeKeyList = [...storeKeys].sort(); // ponytail: account rows first in AUTH_OVERLAY_KEYS order (stable: - // name, email, image, socials), then store rows alphabetically. + // name, email, avatar, socials), then store rows alphabetically. const accountOrder = new Map(AUTH_OVERLAY_KEYS.map((k, i) => [k, i])); const rows: Row[] = Object.entries(merged).map(([key, value]) => ({ key, diff --git a/docs/APIS.md b/docs/APIS.md index ce91537..4896a03 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -94,6 +94,16 @@ Best-effort cleanup of a composer chip. Removes the row + the R2 object. Idempot | Response | `204 No Content` | | Failure codes | 401 `UNAUTHORIZED`. 404 `NOT_FOUND`. 503 `ATTACHMENTS_NOT_CONFIGURED`. | +### `POST /api/avatar/presign` + +Presign a 5-minute PUT for a user avatar (issue #28). Same R2 backing as attachments (`withAuth`, 503 when `R2_*` unset), but **no DB row** — avatars aren't chat attachments. Key is `u//avatar/-`. The browser PUTs the file, then calls Better Auth `updateUser({ image: publicUrl })` — we store only the URL, never base64 (a base64 data URL in `user.image` flowed through the memory auth-overlay into every `` block uncapped; that was the 372K-token blow-up). Client gate: the Settings avatar upload is disabled unless `window.__CONFIG__.ATTACHMENTS_ENABLED === "true"`. + +| | | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request body | `{ name: string (1..256), contentType: string (1..127), sizeBytes: number (>0) }`. `contentType` must start with `image/`; `sizeBytes` ≤ `R2_MAX_BYTES`. | +| 201 response | `{ key, uploadUrl, publicUrl, uploadHeaders: { "Content-Type", "Content-Disposition": "inline" } }`. Presigned URL expires in 300s. | +| Failure codes | 400 `BAD_REQUEST`. 400 `CONTENT_TYPE_NOT_ALLOWED` (non-image). 400 `FILE_TOO_LARGE` (`{ maxBytes, sizeBytes }`). 401 `UNAUTHORIZED`. 503 `AVATAR_UPLOADS_NOT_CONFIGURED`. | + ## Observability Per-thread captured LLM / Tool / Chain / Node / Human spans — written at every End hook by the callback handler attached to the compiled graph in `backend/agent.ts` via `compile({ checkpointer }).withConfig({ callbacks: [capturingHandler] })`. Attaching at the compile layer (not per-model) ensures ToolNode spans are captured too. Design doc: [`docs/OBSERVABILITY.md`](./OBSERVABILITY.md) (storage, retention, FORBIDDEN regex, trade-offs). diff --git a/lib/memory/constants.ts b/lib/memory/constants.ts index bdc23fc..7f27d66 100644 --- a/lib/memory/constants.ts +++ b/lib/memory/constants.ts @@ -52,5 +52,5 @@ export const MEMORY_PROFILE_MAX_BYTES = positiveInt( // Better Auth) when the user-saved doc doesn't have them. Listed // once so mergeMemory iterates them — the UI's "(from account)" // hint and the LLM's system-prompt overlay use the same set. -export const AUTH_OVERLAY_KEYS = ["name", "email", "image", "socials"] as const; +export const AUTH_OVERLAY_KEYS = ["name", "email", "avatar", "socials"] as const; export type AuthOverlayKey = (typeof AUTH_OVERLAY_KEYS)[number]; diff --git a/lib/memory/queries.ts b/lib/memory/queries.ts index bf5610d..defa8ed 100644 --- a/lib/memory/queries.ts +++ b/lib/memory/queries.ts @@ -148,18 +148,35 @@ export async function writeSummary( return full; } -// ponytail: explicit `.select({ provider: account.providerId })` keeps -// `accountId` / tokens out of the recall payload (FR-020). Filter out -// better-auth's `"credential"` provider — that's the email+password -// account, not a social login; showing it in the Memory view as a -// "linked account" is misleading. +// ponytail: we read `idToken` only to extract its `email` claim — the +// token itself never leaves this function (FR-020 keeps accountId / raw +// tokens out of the recall payload). Filter out better-auth's +// `"credential"` provider — that's the email+password account, not a +// social login; showing it in the Memory view as a "linked account" is +// misleading. export type AuthInfo = { name: string | null; email: string | null; - image: string | null; - socials: Array<{ provider: string }>; + avatar: string | null; + socials: Array<{ provider: string; email?: string }>; }; +// ponytail: read the `email` claim out of an OIDC idToken. Google is OIDC +// so its idToken is a JWT (header.payload.signature) with an email claim. +// GitHub is plain OAuth2 — no idToken — so github rows stay email-less. +// We only READ our own stored token, so no signature verify: base64url- +// decode the middle segment. Malformed / missing → undefined, never throws. +function emailFromIdToken(idToken: string | null): string | undefined { + const payload = idToken?.split(".")[1]; + if (!payload) return undefined; + try { + const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + return typeof claims?.email === "string" ? claims.email : undefined; + } catch { + return undefined; + } +} + export async function getAuthInfo(userId: string): Promise { const [u] = await db .select({ name: user.name, email: user.email, image: user.image }) @@ -167,15 +184,18 @@ export async function getAuthInfo(userId: string): Promise { .where(eq(user.id, userId)) .limit(1); const accounts = await db - .select({ provider: account.providerId }) + .select({ provider: account.providerId, idToken: account.idToken }) .from(account) .where(eq(account.userId, userId)); return { name: u?.name ?? null, email: u?.email ?? null, - image: u?.image ?? null, + avatar: u?.image ?? null, socials: accounts .filter((r) => r.provider !== "credential") - .map((r) => ({ provider: r.provider })), + .map((r) => { + const email = emailFromIdToken(r.idToken); + return email ? { provider: r.provider, email } : { provider: r.provider }; + }), }; } diff --git a/lib/memory/validators.ts b/lib/memory/validators.ts index 50742ad..1d84a2a 100644 --- a/lib/memory/validators.ts +++ b/lib/memory/validators.ts @@ -100,7 +100,7 @@ export const SummaryEntrySchema = z export const MemoryResponseSchema = z.object({ // ponytail: `memory` is the user-saved doc overlaid with live auth - // (name/email/image/socials from drizzle user+account tables). Model + // (name/email/avatar/socials from drizzle user+account tables). Model // and UI both see the same merged shape — one function (`loadMemory`) // is the single source of truth. memory: z.record(z.string(), z.unknown()), diff --git a/tests/api/avatar/presign.test.ts b/tests/api/avatar/presign.test.ts new file mode 100644 index 0000000..1ecde02 --- /dev/null +++ b/tests/api/avatar/presign.test.ts @@ -0,0 +1,106 @@ +import "@/tests/helpers/session"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + presignPut: vi.fn(async () => "https://r2.example/presigned"), + buildPublicUrl: vi.fn((key: string) => `https://file.example/${key}`), +})); + +vi.mock("@/lib/r2/client", async () => { + class R2NotConfiguredError extends Error { + readonly missing: readonly string[]; + constructor(missing: readonly string[]) { + super(`R2 not configured — missing env vars: ${missing.join(", ")}`); + this.name = "R2NotConfiguredError"; + this.missing = missing; + } + } + return { + R2NotConfiguredError, + presignPut: mockState.presignPut, + buildPublicUrl: mockState.buildPublicUrl, + }; +}); + +import { setCurrentUser } from "@/tests/helpers/session"; +import { TEST_USER } from "@/tests/helpers/auth"; +import { R2NotConfiguredError } from "@/lib/r2/client"; +import { POST } from "@/app/api/avatar/presign/route"; + +const ctx = { params: Promise.resolve(undefined as never) }; + +function jsonRequest(body: unknown): Request { + return new Request("http://localhost", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function body(overrides: Partial<{ name: string; contentType: string; sizeBytes: number }> = {}) { + return { name: "me.png", contentType: "image/png", sizeBytes: 1024, ...overrides }; +} + +beforeEach(() => { + mockState.presignPut.mockReset(); + mockState.buildPublicUrl.mockReset(); + mockState.presignPut.mockResolvedValue("https://r2.example/presigned"); + mockState.buildPublicUrl.mockImplementation((key: string) => `https://file.example/${key}`); + process.env.R2_MAX_BYTES = "10485760"; + setCurrentUser({ id: TEST_USER.id, email: TEST_USER.email }); +}); + +afterAll(() => setCurrentUser(null)); + +describe("POST /api/avatar/presign", () => { + it("returns 401 when unauthenticated", async () => { + setCurrentUser(null); + expect((await POST(jsonRequest(body()), ctx)).status).toBe(401); + }); + + it("returns 400 on invalid JSON", async () => { + const req = new Request("http://localhost", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + expect((await POST(req, ctx)).status).toBe(400); + }); + + it("returns 400 on schema violation", async () => { + const res = await POST(jsonRequest({ name: "", contentType: "image/png", sizeBytes: 0 }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 400 for a non-image content type", async () => { + const res = await POST(jsonRequest(body({ contentType: "application/pdf" })), ctx); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("CONTENT_TYPE_NOT_ALLOWED"); + }); + + it("returns 400 when the file is over the cap", async () => { + const res = await POST(jsonRequest(body({ sizeBytes: 20_000_000 })), ctx); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("FILE_TOO_LARGE"); + }); + + it("returns 201 with a presigned PUT + public URL under the user avatar path", async () => { + const res = await POST(jsonRequest(body()), ctx); + expect(res.status).toBe(201); + const json = await res.json(); + expect(json.uploadUrl).toBe("https://r2.example/presigned"); + expect(json.key).toMatch(new RegExp(`^u/${TEST_USER.id}/avatar/[0-9a-z]{12}-me\\.png$`)); + expect(json.publicUrl).toBe(`https://file.example/${json.key}`); + expect(json.uploadHeaders).toEqual({ + "Content-Type": "image/png", + "Content-Disposition": "inline", + }); + }); + + it("returns 503 when R2 is not configured", async () => { + mockState.presignPut.mockRejectedValueOnce(new R2NotConfiguredError(["R2_BUCKET"])); + const res = await POST(jsonRequest(body()), ctx); + expect(res.status).toBe(503); + expect((await res.json()).code).toBe("AVATAR_UPLOADS_NOT_CONFIGURED"); + }); +}); diff --git a/tests/lib/memory/queries.test.ts b/tests/lib/memory/queries.test.ts index 1c619fb..6999d61 100644 --- a/tests/lib/memory/queries.test.ts +++ b/tests/lib/memory/queries.test.ts @@ -150,14 +150,14 @@ describe("lib/memory/queries", () => { }); describe("getAuthInfo", () => { - it("returns name/email/image from user + providers from account", async () => { + it("returns name/email/avatar from user + providers from account", async () => { mockSelectLimit.mockResolvedValueOnce([{ name: "Lin", email: "lin@x.com", image: null }]); mockSelectAll.mockResolvedValueOnce([{ provider: "github" }, { provider: "google" }]); const info = await getAuthInfo(USER); expect(info).toEqual({ name: "Lin", email: "lin@x.com", - image: null, + avatar: null, socials: [{ provider: "github" }, { provider: "google" }], }); }); @@ -166,7 +166,7 @@ describe("lib/memory/queries", () => { mockSelectLimit.mockResolvedValueOnce([]); mockSelectAll.mockResolvedValueOnce([]); const info = await getAuthInfo(USER); - expect(info).toEqual({ name: null, email: null, image: null, socials: [] }); + expect(info).toEqual({ name: null, email: null, avatar: null, socials: [] }); }); it("filters out the credential provider (email+password account)", async () => { @@ -175,6 +175,28 @@ describe("lib/memory/queries", () => { const info = await getAuthInfo(USER); expect(info.socials).toEqual([{ provider: "github" }]); }); + + it("decodes the email claim from an OIDC idToken (issue #10)", async () => { + const payload = Buffer.from(JSON.stringify({ email: "lin@gmail.com" })).toString("base64url"); + const idToken = `h.${payload}.sig`; + mockSelectLimit.mockResolvedValueOnce([{ name: "Lin", email: "lin@x.com", image: null }]); + mockSelectAll.mockResolvedValueOnce([ + { provider: "google", idToken }, + { provider: "github", idToken: null }, + ]); + const info = await getAuthInfo(USER); + expect(info.socials).toEqual([ + { provider: "google", email: "lin@gmail.com" }, + { provider: "github" }, + ]); + }); + + it("ignores a malformed idToken instead of throwing", async () => { + mockSelectLimit.mockResolvedValueOnce([{ name: "Lin", email: "lin@x.com", image: null }]); + mockSelectAll.mockResolvedValueOnce([{ provider: "google", idToken: "not-a-jwt" }]); + const info = await getAuthInfo(USER); + expect(info.socials).toEqual([{ provider: "google" }]); + }); }); describe("writeSummary", () => { From 7e3765054b873a1fbcf7aba3f4365494f9c270e7 Mon Sep 17 00:00:00 2001 From: FireTable Date: Fri, 10 Jul 2026 20:45:26 +0800 Subject: [PATCH 2/5] fix(memory): reject base64 / data-URL values in save_memory (#28) Another prompt-bloat vector: the model could persist a base64 image / data URL into the profile doc, which then rides in every block. Guard rejects any string value (walked through arrays/objects) that is a data URL or a long unbroken base64 run; the tool description forbids it too. Threshold 512 avoids flagging wallet addresses / hashes / IDs. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/tool/memory/save-memory-tool.ts | 21 +++++++++++ .../tool/memory/save-memory-tool.test.ts | 37 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/tool/memory/save-memory-tool.ts b/backend/tool/memory/save-memory-tool.ts index 7d26094..b702cbc 100644 --- a/backend/tool/memory/save-memory-tool.ts +++ b/backend/tool/memory/save-memory-tool.ts @@ -33,6 +33,21 @@ function extractUserId(config?: { configurable?: { userId?: unknown } }): string return typeof raw === "string" && raw.length > 0 ? raw : null; } +// ponytail: base64 blobs (esp. `data:...;base64,` avatars/images) are the +// exact payload that blew the context window in issue #28 — a single one +// dwarfs the whole profile. Reject any string value that's a data URL or a +// long uninterrupted base64 run. Walks arrays/objects too so it can't slip +// in nested. Threshold 512 avoids flagging wallet addresses / hashes / IDs. +const DATA_URL_RE = /data:[^,]*;base64,/i; +const LONG_BASE64_RE = /[A-Za-z0-9+/]{512,}={0,2}/; + +function containsBase64(value: unknown): boolean { + if (typeof value === "string") return DATA_URL_RE.test(value) || LONG_BASE64_RE.test(value); + if (Array.isArray(value)) return value.some(containsBase64); + if (value && typeof value === "object") return Object.values(value).some(containsBase64); + return false; +} + const EMPTY_AUTH: AuthInfo = { name: null, email: null, avatar: null, socials: [] }; async function impl( @@ -56,6 +71,11 @@ async function impl( // model. const effective = mergeMemory(storeDoc, auth); for (const patch of patches) { + if (patch.op !== "remove" && containsBase64(patch.value)) { + throw new MemoryPatchError( + `path ${patch.path} value looks like base64 / data-URL content; save_memory does not store binary blobs`, + ); + } if ((patch.op === "replace" || patch.op === "remove") && !(patch.path.slice(1) in effective)) { throw new MemoryPatchError(`path ${patch.path} not found in memory`); } @@ -159,6 +179,7 @@ CONSTRAINTS (DO NOT): - DO NOT save model inferences or questions about the user. - DO NOT save sensitive content the user wouldn't want surfaced later (passwords, financial details, medical info, intimate relationships) — use judgment. - DO NOT save external data returned by tools (weather, prices, balances, fetched URLs) — only the user's input portion. +- DO NOT save base64 / data-URL blobs, images, or any large binary-encoded string (avatars, file contents) — they blow up the context window; store a URL or short reference instead. - DO NOT call more than once per turn (group multiple updates into one patch set). - DO NOT save the same or similar content under two keys (e.g. use "ask_location_cache" instead of both "ask_location_cache" and "ask_location_result"). diff --git a/tests/backend/tool/memory/save-memory-tool.test.ts b/tests/backend/tool/memory/save-memory-tool.test.ts index 5fe4c6b..c15aa5e 100644 --- a/tests/backend/tool/memory/save-memory-tool.test.ts +++ b/tests/backend/tool/memory/save-memory-tool.test.ts @@ -49,6 +49,39 @@ describe("saveMemoryTool — patch matrix (FR-001..003)", () => { expect(result.patches).toEqual([{ op: "add", path: "role", value: "frontend" }]); }); + it("rejects a data-URL / base64 blob value (issue #28) without writing", async () => { + mockGetMemoryDoc.mockResolvedValueOnce({}); + const dataUrl = `data:image/png;base64,${"A".repeat(600)}`; + await expect( + saveMemoryTool.invoke( + { patches: [{ op: "add", path: "/avatar", value: dataUrl }] }, + cfg("u1"), + ), + ).rejects.toThrow(/base64/i); + expect(mockPutMemoryDoc).not.toHaveBeenCalled(); + }); + + it("rejects a base64 blob nested inside an object value", async () => { + mockGetMemoryDoc.mockResolvedValueOnce({}); + await expect( + saveMemoryTool.invoke( + { patches: [{ op: "add", path: "/pic", value: { raw: "B".repeat(700) } }] }, + cfg("u1"), + ), + ).rejects.toThrow(/base64/i); + expect(mockPutMemoryDoc).not.toHaveBeenCalled(); + }); + + it("does not flag short normal values (wallet address / hash)", async () => { + mockGetMemoryDoc.mockResolvedValueOnce({}); + mockPutMemoryDoc.mockResolvedValueOnce(undefined); + await saveMemoryTool.invoke( + { patches: [{ op: "add", path: "/wallet", value: "0xAbC123def456" }] }, + cfg("u1"), + ); + expect(mockPutMemoryDoc).toHaveBeenCalledWith("u1", { wallet: "0xAbC123def456" }); + }); + it("merges multiple adds with existing keys (non-destructive)", async () => { mockGetMemoryDoc.mockResolvedValueOnce({ language: "zh" }); mockPutMemoryDoc.mockResolvedValueOnce(undefined); @@ -106,7 +139,9 @@ describe("saveMemoryTool — patch matrix (FR-001..003)", () => { it("rejects a patch set that would exceed profile size (FR-003)", async () => { mockGetMemoryDoc.mockResolvedValueOnce({}); - const padding = "x".repeat(9000); + // ponytail: spaced text so it trips the SIZE guard, not the base64 + // guard (a long unbroken A-Za-z0-9 run would look like a blob). + const padding = "long note ".repeat(900); await expect( saveMemoryTool.invoke({ patches: [{ op: "add", path: "/note", value: padding }] }, cfg("u1")), ).rejects.toThrow(/exceeds|MemorySize|profile size/i); From 44d01f474b98b2ae8f2bd5c04160966411f3e64d Mon Sep 17 00:00:00 2001 From: FireTable Date: Fri, 10 Jul 2026 21:11:01 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(avatar,memory):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20GitHub=20email,=20R2=20cleanup,=20SVG,=20EMPTY?= =?UTF-8?q?=5FAUTH=5FINFO=20(#28,=20#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAuthInfo now fetches the GitHub per-provider email via the stored OAuth access token (default scope already grants user:email); Google keeps using the OIDC idToken. Best-effort, behind the recall LRU. - new DELETE /api/avatar deletes the R2 object on avatar delete AND on replace — avatars have no DB row, so nothing else swept the old object. - avatar presign rejects image/svg+xml (inline SVG XSS on a public bucket). - toast instead of silently dropping an upload when R2 is unconfigured. - extract shared EMPTY_AUTH_INFO; drop the stale per-site fallback casts. - docs: APIS.md (svg + DELETE route), MEMORY.md (image -> avatar overlay). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/avatar/presign/route.ts | 7 +- app/api/avatar/route.ts | 55 +++++++++++++++ app/api/memory/profile/route.ts | 14 ++-- backend/memory/recall.ts | 9 +-- backend/tool/memory/save-memory-tool.ts | 8 +-- .../auth/settings/account/change-avatar.tsx | 27 ++++++- docs/APIS.md | 22 ++++-- docs/MEMORY.md | 8 +-- lib/memory/queries.ts | 70 +++++++++++++++---- tests/api/avatar/presign.test.ts | 63 +++++++++++++++++ tests/api/memory/profile.get.test.ts | 1 + tests/backend/memory/recall.test.ts | 1 + .../tool/memory/save-memory-tool.test.ts | 1 + tests/lib/memory/queries.test.ts | 35 ++++++++++ 14 files changed, 275 insertions(+), 46 deletions(-) create mode 100644 app/api/avatar/route.ts diff --git a/app/api/avatar/presign/route.ts b/app/api/avatar/presign/route.ts index adecf4f..63320e4 100644 --- a/app/api/avatar/presign/route.ts +++ b/app/api/avatar/presign/route.ts @@ -51,8 +51,13 @@ export const POST = withAuth(async (req, { user }) => { return NextResponse.json({ code: "BAD_REQUEST", error: parsed.error.issues }, { status: 400 }); } + // ponytail: image/* only, and explicitly NOT svg — an SVG can carry + // inline