Public: anyone can read this; the crew can change it.
A tell sent from the forge pill carries its context: app, page, build and the last console errors #10840
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10827-a-tell-sent-from-the-forge-pill-carries → main 6 commits fork 3 threads · 3 unresolved
11 files
changed +308
−4
docs/features/forge-toolbar.md +11 −1
| @@ -9,6 +9,16 @@ preview), so you always know where you are and can get back to the forge. | ||
| 9 | 9 | byte. A page that already carries the tag is not given a second. |
| 10 | 10 | - **What it says:** the app, the environment and, on a preview, a link |
| 11 | 11 | "Preview of pull #N" to the pull's page on the forge; Home goes to the forge. |
| 12 | - **What a note carries:** a note sent from the pill also carries where it came | |
| 13 | from, folded at the foot of the issue under "Where this came from": the | |
| 14 | build the app is serving (from the forge's own record of the running | |
| 15 | environment, never the client's word) and up to five recent console errors | |
| 16 | (`console.error`, uncaught errors and unhandled rejections, message text | |
| 17 | only). Each error is cut to 200 characters, control characters, URL queries | |
| 18 | and anything shaped like a token, password or key are taken out on the | |
| 19 | server, and the whole is bounded by the request cap. The page is the issue's | |
| 20 | existing `Page:` line and is not repeated. The sender sees nothing new; the | |
| 21 | fold is closed until opened. | |
| 12 | 22 | - **No layout shift:** it is `position: fixed` inside a closed shadow root and |
| 13 | 23 | adds nothing to the page's flow or style sheets. |
| 14 | 24 | - **Escaping:** app and pull names travel as escaped `data-*` attributes and are |
| @@ -19,4 +29,4 @@ preview), so you always know where you are and can get back to the forge. | ||
| 19 | 29 | pill out; the app still works. |
| 20 | 30 | - **Off:** `"toolbar": false` in `forge.json`. |
| 21 | 31 | |
| 22 | Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`. | |
| 32 | Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`, `test/note-context.test.ts`, `test/apps-feedback.test.ts`. |
docs/features/notes.md +2
| @@ -29,6 +29,8 @@ The terminal has the same door: the palette's "Notes" (`src/tui/screens/thoughtn | ||
| 29 | 29 | |
| 30 | 30 | A note sent from an app's forge button (or the terminal's "Tell the builders") files a from-app issue, and the sender is told its number at once: "Filed as #N". Anyone the repository would show the issue to (`canRead`, so a signed-in reader of a private repository too, not only a public app on a public repository) also gets the issue's link and the repository's; everyone else gets the number and no link. The list shows `#N` beside each note. The browser button links only to the forge's own origin. In the terminal (`src/tui/screens/notes.ts`) the sender is the SSH user: the confirmation reads "Sent as #N, owner/repo/issues/N" and the list and moderation screens carry `#N`; the terminal has no clickable link. |
| 31 | 31 | |
| 32 | The issue also carries a closed fold, "Where this came from" (`actions/note_context.ts`): the build the app was serving and up to five recent console errors from the sender's page, each cut to 200 characters and scrubbed of anything shaped like a secret or a URL query. The page itself is the issue's `Page:` line, said once. Markdown accepts one flat fold (`<details><summary>Title</summary>` … `</details>`, each on its own line, a title of plain characters); an unclosed one is closed, a nested one is text. | |
| 33 | ||
| 32 | 34 | The crew's answers come back to the sender: under each note the button's list (and the terminal's "Notes from the app", where picking a note shows them) carries the comments on its issue by the repository's crew and writers, with author and time, oldest first. Anyone else's comments stay on the forge. The button shows the latest five and says how many are earlier. |
| 33 | 35 | |
| 34 | 36 | When a pull that closes a note's issue merges, the forge leaves one plain-words comment on it (`crew/note_closed.ts`): what was wrong and what changed, from the pull's description, with no hashes or links, saying "the next release" until one is cut, then rewritten to name it ("release 57 (10 Nov 2026)"). Both ways a merge closes an issue (the merge itself, and a reviewer's box pass on an already-landed pull) go through `noteShipped`. The forge marks its comment in `note_shipped`, so a person's comment with the same opening never stands in for it. The button shows it as a reply and the note reads shipped. |
src/actions/app_notes.ts +4 −1
| @@ -20,6 +20,7 @@ import { getUserById, isActive, type User } from "../core/users.ts"; | ||
| 20 | 20 | import { crewWithRole, isCrew } from "../core/agents.ts"; |
| 21 | 21 | import { fence } from "../crew/fence.ts"; |
| 22 | 22 | import { unadoptedAmong } from "../crew/direct.ts"; |
| 23 | import { contextFold, type NoteContext } from "./note_context.ts"; | |
| 23 | 24 | import { NOTE_EMPTY } from "../apps/note_words.ts"; |
| 24 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 25 | 26 | import { assignIssue, openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "./issues.ts"; |
| @@ -196,6 +197,8 @@ export interface NoteInput { | ||
| 196 | 197 | /** The page, already reduced to this app's own origin and path. */ |
| 197 | 198 | readonly page: string; |
| 198 | 199 | readonly viewport: string; |
| 200 | /** Path, build and console errors, already checked (`parseNoteContext`). */ | |
| 201 | readonly context?: NoteContext; | |
| 199 | 202 | } |
| 200 | 203 | |
| 201 | 204 | /** |
| @@ -249,7 +252,7 @@ export async function sendNote( | ||
| 249 | 252 | `- Page: ${input.page === "" ? "unknown" : `\`${input.page.replace(/`/g, "%60")}\``}`, |
| 250 | 253 | `- Viewport: ${input.viewport}`, |
| 251 | 254 | `- Time: ${new Date().toISOString()}`, |
| 252 | ].join("\n"); | |
| 255 | ].join("\n") + (input.context === undefined ? "" : contextFold(input.context)); | |
| 253 | 256 | // The label exists before the issue does, so a mirror's origin can apply it too. |
| 254 | 257 | if (getLabel(app.db, repo.id, FROM_APP_LABEL) === undefined) createLabel(app.db, repo.id, FROM_APP_LABEL, LABEL_COLOR); |
| 255 | 258 | const unavailable = err({ error: "unavailable", message: "The builders cannot take notes right now." } as const); |
src/actions/note_context.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * What a note from the forge pill says about where it came from (#10827): | |
| 3 | * the build the app was serving and up to five recent console errors, each | |
| 4 | * cut short. (The page is already the issue's `- Page:` line, so it is not | |
| 5 | * said twice.) Every field | |
| 6 | * is checked at the boundary and bounded; what passes is a fold at the foot | |
| 7 | * of the issue, never its headline. Console text can hold secrets, so only | |
| 8 | * the message is kept, and anything shaped like a credential or a URL query | |
| 9 | * is cut out of it. Plain data in, plain data out. | |
| 10 | */ | |
| 11 | ||
| 12 | export interface NoteContext { | |
| 13 | readonly build: string; | |
| 14 | readonly errors: readonly string[]; | |
| 15 | } | |
| 16 | ||
| 17 | export const ERRORS_MAX = 5; | |
| 18 | export const ERROR_CHARS = 200; | |
| 19 | const BUILD = /^[0-9a-f]{7,64}$/; | |
| 20 | ||
| 21 | /** A message with what looks like a secret or a query string taken out. */ | |
| 22 | export function scrubMessage(raw: string): string { | |
| 23 | return raw | |
| 24 | // eslint-disable-next-line no-control-regex | |
| 25 | .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ") | |
| 26 | .replace(/\b(https?:\/\/[^\s?#]*)[?#]\S*/gi, "$1") | |
| 27 | .replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]{8,}/gi, "$1 [removed]") | |
| 28 | .replace(/\b([A-Za-z_-]*(?:token|secret|key|password|passwd|auth|session|cookie)[A-Za-z_-]*)\s*[:=]\s*\S+/gi, "$1=[removed]") | |
| 29 | .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.?[A-Za-z0-9_-]*/g, "[removed]") | |
| 30 | .replace(/\b[A-Fa-f0-9]{32,}\b/g, "[removed]") | |
| 31 | .replace(/\s+/g, " ") | |
| 32 | .trim(); | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * The context of a note: `build` is what the forge knows is serving (never | |
| 37 | * the client's word), `errors` what the client sent. Anything malformed is dropped, | |
| 38 | * never an error: the note itself still goes. | |
| 39 | */ | |
| 40 | export function parseNoteContext(errors: unknown, build: string | undefined): NoteContext { | |
| 41 | const list: string[] = []; | |
| 42 | if (Array.isArray(errors)) { | |
| 43 | for (const e of errors.slice(0, ERRORS_MAX)) { | |
| 44 | if (typeof e !== "string") continue; | |
| 45 | const m = scrubMessage(e.slice(0, 2000)).slice(0, ERROR_CHARS); | |
| 46 | if (m !== "") list.push(m); | |
| 47 | } | |
| 48 | } | |
| 49 | return { build: build !== undefined && BUILD.test(build) ? build : "", errors: list }; | |
| 50 | } | |
| 51 | ||
| 52 | /** The fold appended to a note's issue; empty when there is nothing to say. */ | |
| 53 | export function contextFold(c: NoteContext): string { | |
| 54 | if (c.build === "" && c.errors.length === 0) return ""; | |
| 55 | const lines = [ | |
| 56 | c.build === "" ? "" : `- Build: \`${c.build}\``, | |
| 57 | // The sender's browser's words, unchecked: data, not instructions. | |
| 58 | ...c.errors.map((e) => `- Console error: \`${e.replace(/`/g, "'")}\``), | |
| 59 | ].filter((l) => l !== ""); | |
| 60 | return ["", "<details><summary>Where this came from</summary>", ...lines, "</details>"].join("\n"); | |
| 61 | } |
src/apps/feedback.ts +9 −1
| @@ -24,6 +24,7 @@ import { getNote } from "../core/app_notes.ts"; | ||
| 24 | 24 | import { PICTURE_MAX, savePicture, sniffPicture } from "../core/note_pictures.ts"; |
| 25 | 25 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 26 | 26 | import { sha256 } from "../kernel/hash.ts"; |
| 27 | import { parseNoteContext } from "../actions/note_context.ts"; | |
| 27 | 28 | import { NOTE_FAILED } from "./note_words.ts"; |
| 28 | 29 | |
| 29 | 30 | export const REQUEST_MAX = 16 * 1024; |
| @@ -42,6 +43,8 @@ export interface FeedbackAsk { | ||
| 42 | 43 | readonly viewerId: string | undefined; |
| 43 | 44 | /** The grant cookie's value, which the panel's proof is bound to. */ |
| 44 | 45 | readonly grantCookie?: string | undefined; |
| 46 | /** The commit the env is serving, when it is up; the client never says it. */ | |
| 47 | readonly build?: string | undefined; | |
| 45 | 48 | readonly secure: boolean; |
| 46 | 49 | /** The client's limit key, from the socket (`clientBucket`). */ |
| 47 | 50 | readonly source: string; |
| @@ -181,7 +184,12 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> { | ||
| 181 | 184 | const viewport = typeof body.viewport === "string" && /^\d{2,5}x\d{2,5}$/.test(body.viewport) ? body.viewport : "unknown"; |
| 182 | 185 | const sent = await sendNote( |
| 183 | 186 | ask.app, ask.record, door.repo, ask.env, ask.forgeUrl, sender, |
| 184 | { text: typeof body.text === "string" ? body.text : "", page, viewport }, | |
| 187 | { | |
| 188 | text: typeof body.text === "string" ? body.text : "", | |
| 189 | page, | |
| 190 | viewport, | |
| 191 | context: parseNoteContext(body.errors, ask.build), | |
| 192 | }, | |
| 185 | 193 | ask.source, |
| 186 | 194 | door.viewer, |
| 187 | 195 | ); |
src/apps/proxy.ts +1
| @@ -1098,6 +1098,7 @@ export async function proxyApp( | ||
| 1098 | 1098 | sealed, |
| 1099 | 1099 | viewerId, |
| 1100 | 1100 | grantCookie: cookie, |
| 1101 | build: runtime.liveFor(target.app, target.env)?.commitOid, | |
| 1101 | 1102 | secure: proxySecure(request, url, runtime.options.forgeUrl), |
| 1102 | 1103 | source: clientBucket(request), |
| 1103 | 1104 | }); |
src/apps/toolbar.ts +13 −1
| @@ -275,6 +275,18 @@ export const toolbarJs = `(function () { | ||
| 275 | 275 | } catch (e) { end(); } |
| 276 | 276 | }); |
| 277 | 277 | } |
| 278 | // The last few console errors: message text only, cut short here and again on the server. | |
| 279 | var errs = []; | |
| 280 | function noted(m) { errs.push(String(m).slice(0, 200)); if (errs.length > 5) errs.shift(); } | |
| 281 | if (window.addEventListener) { | |
| 282 | window.addEventListener("error", function (e) { noted(e && e.message); }); | |
| 283 | window.addEventListener("unhandledrejection", function (e) { var r = e && e.reason; noted(r && r.message ? r.message : r); }); | |
| 284 | } | |
| 285 | var logged = typeof console === "object" && console ? console.error : null; | |
| 286 | if (logged) console.error = function () { | |
| 287 | try { noted(Array.prototype.map.call(arguments, function (a) { return a && a.message ? a.message : typeof a === "string" ? a : ""; }).join(" ")); } catch (x) {} | |
| 288 | return logged.apply(console, arguments); | |
| 289 | }; | |
| 278 | 290 | tellBox.addEventListener("submit", function (e) { |
| 279 | 291 | e.preventDefault(); |
| 280 | 292 | var text = tellText.value.trim(); |
| @@ -291,7 +303,7 @@ export const toolbarJs = `(function () { | ||
| 291 | 303 | method: "POST", |
| 292 | 304 | credentials: "same-origin", |
| 293 | 305 | headers: { "content-type": "application/json" }, |
| 294 | body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight }) | |
| 306 | body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight, errors: errs.slice(-5) }) | |
| 295 | 307 | }) |
| 296 | 308 | .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) |
| 297 | 309 | .then(function (a) { |
src/web/markdown.ts +21
| @@ -4,6 +4,9 @@ | ||
| 4 | 4 | * transformation, so the renderer cannot be talked into emitting markup. |
| 5 | 5 | * CommonMark completeness is a non-goal — READMEs and issue prose are. |
| 6 | 6 | * |
| 7 | * A fold (`<details><summary>Title</summary>` … `</details>`, each on a line | |
| 8 | * of its own) is the one other tag it emits, with a title it checks. | |
| 9 | * | |
| 7 | 10 | * A fence with an info string is handed to the tokenizer, so the code samples |
| 8 | 11 | * in a README read as code. That is the one place this renderer emits markup |
| 9 | 12 | * it did not escape itself, and it is safe for the same reason the file |
| @@ -157,6 +160,7 @@ function renderUncached(source: string): Html { | ||
| 157 | 160 | let body: string[] = []; |
| 158 | 161 | let align: string[] | null = null; |
| 159 | 162 | let skip = -1; |
| 163 | let folded = false; | |
| 160 | 164 | |
| 161 | 165 | const closeParagraph = () => { |
| 162 | 166 | if (paragraph.length > 0) { |
| @@ -215,6 +219,22 @@ function renderUncached(source: string): Html { | ||
| 215 | 219 | continue; |
| 216 | 220 | } |
| 217 | 221 | |
| 222 | // A fold: `<details><summary>Title</summary>` on a line of its own, then | |
| 223 | // `</details>`. The title is escaped text; nothing else opens a tag. | |
| 224 | const fold = /^<details><summary>([^<>&"'`]{1,80})<\/summary>$/.exec(raws[index] ?? ""); | |
| 225 | if (fold !== null && !folded) { | |
| 226 | closeBlocks(); | |
| 227 | out.push(`<details><summary>${fold[1]}</summary>`); | |
| 228 | folded = true; | |
| 229 | continue; | |
| 230 | } | |
| 231 | if (folded && raws[index] === "</details>") { | |
| 232 | closeBlocks(); | |
| 233 | out.push("</details>"); | |
| 234 | folded = false; | |
| 235 | continue; | |
| 236 | } | |
| 237 | ||
| 218 | 238 | // A table: a row of pipes over a row of dashes. Both halves have to be |
| 219 | 239 | // there — a lone line with a pipe in it is a sentence, not a table. |
| 220 | 240 | if (align) { |
| @@ -278,5 +298,6 @@ function renderUncached(source: string): Html { | ||
| 278 | 298 | |
| 279 | 299 | if (fence) out.push(closeFence(body, fenceLang)); |
| 280 | 300 | closeBlocks(); |
| 301 | if (folded) out.push("</details>"); | |
| 281 | 302 | return raw(out.join("\n")); |
| 282 | 303 | } |
src/web/style/content.ts +7
| @@ -536,6 +536,13 @@ form.inline { display: inline; margin-left: auto; } | ||
| 536 | 536 | font-family: var(--mono); color: var(--muted-fg); |
| 537 | 537 | white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible; |
| 538 | 538 | } |
| 539 | /* A fold in prose (an issue's "Where this came from"): the global reset hides the marker, so the chevron is drawn here; | |
| 540 | the summary is a native, focusable, Enter/Space control with a row-high target. */ | |
| 541 | .prose details { margin: 12px 0; } | |
| 542 | .prose summary { cursor: pointer; padding: 10px 0; min-height: 44px; color: var(--muted-fg); font-size: 13px; } | |
| 543 | .prose summary::before { content: "\\25B8"; display: inline-block; width: 1.2em; } | |
| 544 | .prose details[open] > summary::before { content: "\\25BE"; } | |
| 545 | .prose details > ul { margin-top: 0; font-size: 13px; } | |
| 539 | 546 | .prose code { background: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; } |
| 540 | 547 | .prose pre code { background: none; border: none; padding: 0; } |
| 541 | 548 | .prose blockquote { |
test/apps-feedback.test.ts +36
| @@ -11,6 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { newApp } from "../src/actions/apps.ts"; |
| 13 | 13 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; |
| 14 | import { renderMarkdown } from "../src/web/markdown.ts"; | |
| 14 | 15 | import { toolbarJs } from "../src/apps/toolbar.ts"; |
| 15 | 16 | import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts"; |
| 16 | 17 | import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts"; |
| @@ -57,6 +58,7 @@ describe("the forge button takes notes from anyone", () => { | ||
| 57 | 58 | alice = a.value; |
| 58 | 59 | for (const [name, visibility] of [ |
| 59 | 60 | ["notes", "public"], |
| 61 | ["context", "public"], | |
| 60 | 62 | ["diary", "private"], |
| 61 | 63 | ] as const) { |
| 62 | 64 | const made = await newApp(app, a.value, { name, description: "Words", visibility }); |
| @@ -128,6 +130,40 @@ describe("the forge button takes notes from anyone", () => { | ||
| 128 | 130 | expect(j.note.url).toBe(`${servers.url}/alice/notes/issues/${issue.number}`); |
| 129 | 131 | }); |
| 130 | 132 | |
| 133 | test("a note's context is stored under a fold: build from the forge, errors scrubbed, capped and escaped", async () => { | |
| 134 | const spy = spyOn(AppsRuntime.prototype, "liveFor").mockImplementation(() => ({ commitOid: "c0ffee1234567890abcdef" }) as never); | |
| 135 | try { | |
| 136 | const send = (extra: Record<string, unknown>, source: string) => | |
| 137 | at("context", "/__forge/note", { method: "POST", body: JSON.stringify({ text: "It broke", page: `http://${host("context")}/cart?x=1`, viewport: "390x844", ...extra }) }, "", source); | |
| 138 | const bodyOf = async (res: Response) => { | |
| 139 | expect(res.status).toBe(201); | |
| 140 | const j = (await res.json()) as { note: { url: string } }; | |
| 141 | return getIssue(app.db, getRepo(app.db, "alice", "context")!.id, Number(j.note.url.split("/").pop()))!.body; | |
| 142 | }; | |
| 143 | const hostile = ["<img src=x onerror=alert(1)> `tick`", "x".repeat(900), "GET http://a.test/p?api_key=sekret failed", "t1", "t2", "t3 beyond five", 7, { a: 1 }]; | |
| 144 | const body = await bodyOf(await send({ errors: hostile, build: "deadbeef" }, "10.7.0.1")); | |
| 145 | expect(body).toContain("<details><summary>Where this came from</summary>"); | |
| 146 | expect(body).toContain("- Build: `c0ffee1234567890abcdef`"); // the forge's, never the client's `build` | |
| 147 | expect(body).not.toContain("deadbeef"); | |
| 148 | expect(body).not.toContain("- Path:"); | |
| 149 | expect(body).not.toMatch(/sekret|beyond five/); | |
| 150 | const lines = body.split("\n").filter((l) => l.startsWith("- Console error:")); | |
| 151 | expect(lines.length).toBe(5); | |
| 152 | expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(200 + 20); | |
| 153 | expect(lines[0]).not.toContain("`tick`"); | |
| 154 | const rendered = String(renderMarkdown(body)); | |
| 155 | expect(rendered).not.toContain("<img"); | |
| 156 | expect(rendered).toContain("<img"); | |
| 157 | // No context at all: the note still goes, the fold only says the build. | |
| 158 | expect(await bodyOf(await send({ errors: "nope" }, "10.7.0.2"))).not.toContain("Console error"); | |
| 159 | // An oversized request is refused before any of it is read. | |
| 160 | const big = await send({ errors: ["e".repeat(REQUEST_MAX)] }, "10.7.0.3"); | |
| 161 | expect(big.status).toBe(400); | |
| 162 | } finally { | |
| 163 | spy.mockRestore(); | |
| 164 | } | |
| 165 | }); | |
| 166 | ||
| 131 | 167 | test("the sender sees their notes, and the status follows the issue", async () => { |
| 132 | 168 | const first = await post("Add dark mode", "", "10.0.0.2"); |
| 133 | 169 | const cookie = cookieOf(first); |
test/note-context.test.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * A note from the forge pill carries where it came from (#10827): path, build | |
| 3 | * and up to five console errors, checked, cut short and folded at the foot of | |
| 4 | * the issue, never in its title. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { newApp } from "../src/actions/apps.ts"; | |
| 12 | import { sendNote } from "../src/actions/app_notes.ts"; | |
| 13 | import { contextFold, parseNoteContext } from "../src/actions/note_context.ts"; | |
| 14 | import { getAppBySlug } from "../src/core/apps.ts"; | |
| 15 | import { getIssue } from "../src/core/issues.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { toolbarJs } from "../src/apps/toolbar.ts"; | |
| 19 | import { renderMarkdown } from "../src/web/markdown.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const BUILD = "a1b2c3d4e5f6"; | |
| 25 | ||
| 26 | describe("note context, parsed", () => { | |
| 27 | test("keeps the server's build and says nothing of the page", () => { | |
| 28 | const c = parseNoteContext([], BUILD); | |
| 29 | expect(c).toEqual({ build: BUILD, errors: [] }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("keeps five errors, each cut to 200 characters, without secrets or queries", () => { | |
| 33 | const errs = [ | |
| 34 | "x".repeat(500), | |
| 35 | "GET https://api.test/v1?api_key=abc123 failed", | |
| 36 | "Authorization: Bearer abcdefghijklmnop", | |
| 37 | "token=supersecret", | |
| 38 | "four", | |
| 39 | "six is dropped", | |
| 40 | 42, | |
| 41 | ]; | |
| 42 | const c = parseNoteContext(errs, BUILD); | |
| 43 | expect(c.errors.length).toBe(5); | |
| 44 | expect(c.errors[0]!.length).toBe(200); | |
| 45 | expect(c.errors[1]).toBe("GET https://api.test/v1 failed"); | |
| 46 | expect(c.errors.join(" ")).not.toMatch(/abcdefghijklmnop|supersecret|abc123/); | |
| 47 | expect(c.errors).not.toContain("six is dropped"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("hostile fields are dropped, not trusted", () => { | |
| 51 | const c = parseNoteContext("not a list", "../../etc"); | |
| 52 | expect(c).toEqual({ build: "", errors: [] }); | |
| 53 | expect(contextFold(c)).toBe(""); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("a note's issue", () => { | |
| 58 | let dataDir = ""; | |
| 59 | let app: App; | |
| 60 | let owner: User; | |
| 61 | ||
| 62 | beforeAll(async () => { | |
| 63 | dataDir = await mkdtemp(join(tmpdir(), "forge-note-context-")); | |
| 64 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 65 | const made = await createUser(app.db, { | |
| 66 | username: "alice", email: "[email protected]", password: "a-good-passphrase", | |
| 67 | origin: "seed", trusted: true, adminIfFirst: true, | |
| 68 | }); | |
| 69 | if (!made.ok) throw new Error(String(made.error)); | |
| 70 | owner = made.value; | |
| 71 | const a = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" }); | |
| 72 | if (!a.ok) throw new Error(String(a.error)); | |
| 73 | }); | |
| 74 | afterAll(async () => { | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("stores the context under a fold, escaped, and not in the title", async () => { | |
| 80 | const repo = getRepo(app.db, "alice", "shop")!; | |
| 81 | const record = getAppBySlug(app.db, "shop")!; | |
| 82 | const context = parseNoteContext(["<img src=x onerror=alert(1)> `tick` \n</details>", "y".repeat(900)], BUILD); | |
| 83 | const sent = await sendNote( | |
| 84 | app, record, repo, "production", undefined, | |
| 85 | { key: `u:${owner.id}`, name: "alice", person: true }, | |
| 86 | { text: "too small", page: "http://shop.test/cart", viewport: "390x844", context }, | |
| 87 | "10.9.0.1", | |
| 88 | ); | |
| 89 | if (!sent.ok) throw new Error(sent.error.message); | |
| 90 | const issue = getIssue(app.db, repo.id, sent.value.number)!; | |
| 91 | expect(issue.title).toBe("From the app: too small"); | |
| 92 | expect(issue.body).toContain("<details><summary>Where this came from</summary>"); | |
| 93 | expect(issue.body).toContain(`- Build: \`${BUILD}\``); | |
| 94 | expect(issue.body).not.toContain("- Path:"); | |
| 95 | expect(issue.body.match(/\/cart/g)!.length).toBe(1); // the `- Page:` line says it once | |
| 96 | const errors = issue.body.split("\n").filter((l) => l.startsWith("- Console error:")); | |
| 97 | expect(errors.length).toBe(2); | |
| 98 | expect(errors[1]!.length).toBeLessThanOrEqual(200 + 20); | |
| 99 | const html = String(renderMarkdown(issue.body)); | |
| 100 | expect(html).toContain("<details><summary>Where this came from</summary>"); | |
| 101 | expect(html).not.toContain("<img"); | |
| 102 | expect(html).toContain("<img"); | |
| 103 | expect(html.match(/<\/details>/g)!.length).toBe(1); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | test("the pill sends the last console errors with a note", () => { | |
| 108 | expect(toolbarJs).toContain("errors: errs.slice(-5)"); | |
| 109 | expect(toolbarJs).toContain('"unhandledrejection"'); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("the fold in user text", () => { | |
| 113 | const html = (md: string) => String(renderMarkdown(md)); | |
| 114 | const opens = (h: string) => (h.match(/<details>/g) ?? []).length; | |
| 115 | const closes = (h: string) => (h.match(/<\/details>/g) ?? []).length; | |
| 116 | ||
| 117 | test("an unclosed fold is closed by the renderer", () => { | |
| 118 | const h = html("<details><summary>More</summary>\n- one"); | |
| 119 | expect(opens(h)).toBe(1); | |
| 120 | expect(closes(h)).toBe(1); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a nested fold is not a second fold: the flat rule holds", () => { | |
| 124 | const h = html("<details><summary>A</summary>\n<details><summary>B</summary>\nx\n</details>\n</details>"); | |
| 125 | expect(opens(h)).toBe(1); | |
| 126 | expect(closes(h)).toBe(1); | |
| 127 | expect(h).toContain("<details><summary>B"); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a stray close is text, not a tag", () => { | |
| 131 | const h = html("hello\n</details>"); | |
| 132 | expect(closes(h)).toBe(0); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a title with disallowed characters is text", () => { | |
| 136 | for (const title of ['x"onclick="1', "a<b", "a&b", "it's", "`t`", "", "z".repeat(81)]) { | |
| 137 | const h = html(`<details><summary>${title}</summary>\nbody\n</details>`); | |
| 138 | expect(opens(h)).toBe(0); | |
| 139 | expect(h).not.toContain("<summary>"); | |
| 140 | } | |
| 141 | expect(html("<details><summary>Fine title</summary>\nbody\n</details>")).toContain("<summary>Fine title</summary>"); | |
| 142 | }); | |
| 143 | }); |
A string-contains check on the script does not show the pill sends errors or the server stores them. Post through the feedback route with errors and hostile context.