Public: anyone can read this; the crew can change it.
The command palette's last row is "Tell the crew: <what you typed>", and choosing it sends the tell from wherever you are #6820
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5885-the-command-palette-s-last-row-is-tell-t → main 11 commits fork 5 threads · 5 unresolved
docs/design/areas/palette.md +2
| @@ -9,3 +9,5 @@ The command palette is the keyboard-first fuzzy-find overlay (`⌘K` / `Ctrl K`, | ||
| 9 | 9 | - `.palette-input` — the search field across the top of the dialog: full width, borderless but for a rule beneath it, 48px tall, with no focus ring of its own (the system outline under forced colours). |
| 10 | 10 | - `.palette-list` — the scrolling results area under the input: a fixed height (not a ceiling) so the dialog does not jump as rows come and go. |
| 11 | 11 | - `.palette-foot` — the faint one-line strip of key hints under the list, set off by a rule above it. |
| 12 | ||
| 13 | The palette's tell row (`data-tell`, last in the list) sends the typed sentence to `/tell`. Its answer is one `.toast[data-palette-tell]` at the top of `#main`, drawn by `tellFromHere()` in `src/web/client.ts` from existing parts only: `.toast` and `.toast-body`, a link-variant small `.btn` as the dismiss ×, and focus moved to the toast (`tabindex="-1"`, `role="status"`) because the palette that held it has just closed. It clears itself after 12 seconds. No new component class. |
src/web/client.ts +87
| @@ -1681,6 +1681,56 @@ export const shellJs = /* js */ ` | ||
| 1681 | 1681 | }).catch(function () { location.href = href; }); |
| 1682 | 1682 | } |
| 1683 | 1683 | |
| 1684 | // The palette's tell: the feed composer's own POST (same action, same | |
| 1685 | // same-origin check), with this page's path as where it was said. The | |
| 1686 | // answer is one line at the top of the page, in the flash's own classes. | |
| 1687 | function tellFromHere(app, text) { | |
| 1688 | var main = doc.getElementById('main'); | |
| 1689 | function say(tone, message, href) { | |
| 1690 | if (!main) return; | |
| 1691 | var old = main.querySelector('.toast[data-palette-tell]'); | |
| 1692 | if (old) old.remove(); | |
| 1693 | var t = doc.createElement('div'); | |
| 1694 | t.className = 'toast'; | |
| 1695 | t.setAttribute('data-tone', tone); | |
| 1696 | t.setAttribute('data-palette-tell', ''); | |
| 1697 | t.setAttribute('role', 'status'); | |
| 1698 | var b = doc.createElement('div'); | |
| 1699 | b.className = 'toast-body'; | |
| 1700 | b.appendChild(doc.createTextNode(message)); | |
| 1701 | if (href && href.charAt(0) === '/' && href.charAt(1) !== '/') { | |
| 1702 | b.appendChild(doc.createTextNode(' \u00b7 ')); | |
| 1703 | var a = doc.createElement('a'); | |
| 1704 | a.setAttribute('href', href); | |
| 1705 | a.textContent = 'See it'; | |
| 1706 | b.appendChild(a); | |
| 1707 | } | |
| 1708 | t.appendChild(b); | |
| 1709 | var x = doc.createElement('button'); | |
| 1710 | x.type = 'button'; | |
| 1711 | x.className = 'btn'; | |
| 1712 | x.setAttribute('data-variant', 'link'); | |
| 1713 | x.setAttribute('data-size', 'sm'); | |
| 1714 | x.setAttribute('aria-label', 'Dismiss'); | |
| 1715 | x.textContent = '\u00d7'; | |
| 1716 | x.onclick = function () { t.remove(); }; | |
| 1717 | t.appendChild(x); | |
| 1718 | t.tabIndex = -1; | |
| 1719 | main.insertBefore(t, main.firstChild); | |
| 1720 | // The palette has just closed: the answer takes the focus, then clears itself. | |
| 1721 | t.focus({ preventScroll: false }); | |
| 1722 | window.setTimeout(function () { if (t.parentNode) t.remove(); }, 12000); | |
| 1723 | } | |
| 1724 | fetch('/tell', { | |
| 1725 | method: 'POST', credentials: 'same-origin', | |
| 1726 | headers: { accept: 'application/json' }, | |
| 1727 | body: new URLSearchParams({ app: app, text: text, from: location.pathname }) | |
| 1728 | }).then(function (r) { return r.json(); }).then(function (d) { | |
| 1729 | if (d.ok) say('success', d.flash, d.href); | |
| 1730 | else say('danger', d.error || '${NOT_TOLD_WORDS}'); | |
| 1731 | }).catch(function () { say('danger', '${NOT_TOLD_WORDS}'); }); | |
| 1732 | } | |
| 1733 | ||
| 1684 | 1734 | var closePalette = null; |
| 1685 | 1735 | function openPalette() { |
| 1686 | 1736 | var dlg = doc.getElementById('palette'); |
| @@ -1691,6 +1741,7 @@ export const shellJs = /* js */ ` | ||
| 1691 | 1741 | var items = (loadIndex().items || []).map(function (i) { |
| 1692 | 1742 | return { t: String(i.t), h: String(i.h), g: i.c ? String(i.c) : 'Go to' }; |
| 1693 | 1743 | }); |
| 1744 | var tellApp = (loadIndex().tell || {}).app; | |
| 1694 | 1745 | var recent = readRecent().map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; }); |
| 1695 | 1746 | var recentHrefs = recent.map(function (r) { return r.h; }); |
| 1696 | 1747 | var served = []; // rows the server found, for the query servedFor |
| @@ -1773,6 +1824,36 @@ export const shellJs = /* js */ ` | ||
| 1773 | 1824 | }); |
| 1774 | 1825 | frag.appendChild(box); |
| 1775 | 1826 | }); |
| 1827 | // Tell from anywhere: once there are words, the last row says them to the crew. | |
| 1828 | if (tellApp && q.length >= 3) { | |
| 1829 | var tbox = doc.createElement('div'); | |
| 1830 | tbox.className = 'palette-group'; | |
| 1831 | tbox.setAttribute('role', 'group'); | |
| 1832 | // A mutation is a button in a POST form, as /tell takes it without | |
| 1833 | // script too; the click is caught below and sent in place. | |
| 1834 | var tf = doc.createElement('form'); | |
| 1835 | tf.method = 'post'; tf.action = '/tell'; | |
| 1836 | [['app', tellApp], ['text', q], ['from', location.pathname]].forEach(function (kv) { | |
| 1837 | var hid = doc.createElement('input'); | |
| 1838 | hid.type = 'hidden'; hid.name = kv[0]; hid.value = kv[1]; | |
| 1839 | tf.appendChild(hid); | |
| 1840 | }); | |
| 1841 | var ta = doc.createElement('button'); | |
| 1842 | ta.type = 'submit'; | |
| 1843 | ta.className = 'palette-item'; | |
| 1844 | ta.id = 'palette-o' + nodes.length; | |
| 1845 | ta.setAttribute('role', 'option'); | |
| 1846 | ta.setAttribute('aria-selected', 'false'); | |
| 1847 | ta.setAttribute('data-tell', q); | |
| 1848 | var tt = doc.createElement('span'); | |
| 1849 | tt.className = 'grow'; | |
| 1850 | tt.textContent = 'Tell the crew: ' + q; | |
| 1851 | ta.appendChild(tt); | |
| 1852 | tf.appendChild(ta); | |
| 1853 | nodes.push(ta); shown.push({ t: tt.textContent, h: '#tell', g: 'Tell' }); | |
| 1854 | tbox.appendChild(tf); | |
| 1855 | frag.appendChild(tbox); | |
| 1856 | } | |
| 1776 | 1857 | if (nodes.length === 0) frag.appendChild(emptyLine(q)); |
| 1777 | 1858 | list.textContent = ''; |
| 1778 | 1859 | list.appendChild(frag); |
| @@ -1857,6 +1938,12 @@ export const shellJs = /* js */ ` | ||
| 1857 | 1938 | var a = e.target && e.target.closest ? e.target.closest('.palette-item') : null; |
| 1858 | 1939 | var i = a ? nodes.indexOf(a) : -1; |
| 1859 | 1940 | if (i === -1) return; |
| 1941 | if (a.hasAttribute('data-tell')) { | |
| 1942 | e.preventDefault(); | |
| 1943 | close(); | |
| 1944 | tellFromHere(tellApp, a.getAttribute('data-tell')); | |
| 1945 | return; | |
| 1946 | } | |
| 1860 | 1947 | remember(shown[i]); |
| 1861 | 1948 | var h = a.getAttribute('href') || ''; |
| 1862 | 1949 | if (/^\\/[^\\/?#]+\\/[^\\/?#]+\\/tell\\?from=/.test(h) && !e.metaKey && !e.ctrlKey && !e.shiftKey) { |
src/web/layout.ts +32 −2
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts"; |
| 11 | 11 | import { protectionNotice } from "../kernel/protection.ts"; |
| 12 | 12 | import { appsForRepos } from "../core/apps.ts"; |
| 13 | import { listReposVisibleTo } from "../core/repos.ts"; | |
| 13 | import { getRepo, listReposVisibleTo } from "../core/repos.ts"; | |
| 14 | import { mayDirectCrew } from "../crew/direct.ts"; | |
| 14 | 15 | import type { User } from "../core/users.ts"; |
| 15 | 16 | import type { Ctx } from "../http/context.ts"; |
| 16 | 17 | import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl, edgeStrangerLogin } from "../http/edge.ts"; |
| @@ -292,6 +293,32 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 292 | 293 | { t: "Origins", h: "/settings/origins", c: "Settings" }, |
| 293 | 294 | ]; |
| 294 | 295 | |
| 296 | /** First path segments that are the forge's own pages, never an owner: no repository read for them. */ | |
| 297 | const NOT_AN_OWNER = new Set([ | |
| 298 | ...STATIC_PALETTE.map((p) => p.h.split("/")[1] ?? ""), | |
| 299 | "api", "releases", "audit", "admin", "tell", "login", "logout", "signup", "feed", "boards", "organizations", "static", "assets", | |
| 300 | ]); | |
| 301 | ||
| 302 | /** | |
| 303 | * Where the palette's "Tell the crew" row sends a sentence, as the feed | |
| 304 | * composer's `app` field names it: the repository the page is on when the | |
| 305 | * viewer may direct its crew, else the forge itself, which costs no | |
| 306 | * repository read. Absent for anyone who may not tell (signed out, or an | |
| 307 | * organization), and then the row is not drawn. | |
outdated
getRepo runs on every two-segment path. Skip reserved prefixes (settings, releases, audit...) before touching the db, so the read-budget tests stay unchanged. |
||
| 308 | */ | |
| 309 | function tellTarget(ctx: Ctx): { app: string } | undefined { | |
| 310 | const user = ctx.auth.user; | |
| 311 | if (user === undefined || user.kind === "org") return undefined; | |
| 312 | const [owner, name] = ctx.url.pathname.split("/").filter((s) => s !== ""); | |
| 313 | const here = | |
| 314 | owner !== undefined && name !== undefined && !NOT_AN_OWNER.has(owner) | |
| 315 | ? getRepo(ctx.app.db, owner, name) | |
| 316 | : undefined; | |
| 317 | if (here !== undefined && mayDirectCrew(ctx.app.db, here, user)) | |
| 318 | return { app: `${here.ownerName}/${here.name}` }; | |
The reserved-prefix list is a hand-kept denylist; any new top-level route silently costs a repository read. Add a test that counts getRepo calls so the gate is proven. |
||
| 319 | return { app: "forge" }; | |
Medium (UX, #7124): on this fallback |
||
| 320 | } | |
| 321 | ||
| 295 | 322 | /** |
| 296 | 323 | * The palette index rides as a `<script type="application/json">` data block. |
| 297 | 324 | * The HTML spec stops parsing such a block as script, so the CSP never |
| @@ -338,7 +365,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 338 | 365 | // API found, folded in the browser — build the same addresses the server |
| 339 | 366 | // just rendered (`tellHref`/`changeHref`, `src/web/studio/nouns.ts`). The |
| 340 | 367 | // palette can never hand out a noun the page behind it would not. |
| 341 | const json = JSON.stringify({ items, studio }).replaceAll("<", "\\u003c"); | |
| 368 | const json = JSON.stringify({ items, studio, tell: tellTarget(ctx) }).replaceAll("<", "\\u003c"); | |
| 342 | 369 | return raw( |
| 343 | 370 | `<script type="application/json" id="palette-index">${json}</script>`, |
| 344 | 371 | ); |
| @@ -491,6 +518,9 @@ function footer(ctx: Ctx): Html { | ||
| 491 | 518 | >${reduced}</span |
| 492 | 519 | >`} |
| 493 | 520 | <span class="spacer"></span> |
| 521 | ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org" | |
| 522 | ? html`<a class="btn" data-variant="link" data-size="sm" href="/#composer">Tell the crew</a>` | |
| 523 | : ""} | |
| 494 | 524 | <button |
| 495 | 525 | class="btn js-only" |
| 496 | 526 | data-variant="link" |
src/web/studio/actions.ts +22 −7
| @@ -81,14 +81,26 @@ async function tellFrom( | ||
| 81 | 81 | // A client's "Ask for something" (named by the form, not guessed from `/`): |
| 82 | 82 | // they asked for something and want nothing more than to know it went. |
| 83 | 83 | if (from === "/" && field(form, "ask") === "1") return flashOrJson(ctx, back, { message: SENT_WORDS }); |
| 84 | // The tell's own page, for a caller that asked for JSON (the palette's row). | |
| 85 | const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` }; | |
| 84 | 86 | if (routed === undefined) |
| 85 | return flashOrJson(ctx, back, { | |
| 86 | message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, | |
| 87 | }); | |
| 87 | return flashOrJson( | |
| 88 | ctx, | |
| 89 | back, | |
| 90 | { | |
| 91 | message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, | |
| 92 | }, | |
| 93 | at, | |
| 94 | ); | |
| 88 | 95 | const builder = routed.builder; |
| 89 | return flashOrJson(ctx, back, { | |
| 90 | message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}.${from === null ? " Watch it work below." : ""}`, | |
| 91 | }); | |
| 96 | return flashOrJson( | |
| 97 | ctx, | |
| 98 | back, | |
| 99 | { | |
| 100 | message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}.${from === null ? " Watch it work below." : ""}`, | |
| 101 | }, | |
| 102 | at, | |
| 103 | ); | |
| 92 | 104 | } |
| 93 | 105 | |
| 94 | 106 | /** POST /:owner/:name/tell { text } — a sentence becomes work. */ |
| @@ -160,8 +172,10 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 160 | 172 | const target = field(form, "app"); |
| 161 | 173 | // "the forge" is no app: the words are kept as a thought, exactly as typed. |
| 162 | 174 | if (target === FORGE_TARGET) { |
| 175 | if (field(form, "text").trim() === "") | |
| 176 | return flashOrJson(ctx, "/", { tone: "danger", message: SAY_SOMETHING_WORDS }); | |
| 163 | 177 | const kept = keepThought(ctx.app, ctx.auth.user, field(form, "text"), "composer"); |
| 164 | return flashTo( | |
| 178 | return flashOrJson( | |
| 165 | 179 | ctx, |
| 166 | 180 | "/", |
| 167 | 181 | kept.ok |
| @@ -171,6 +185,7 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 171 | 185 | message: |
| 172 | 186 | kept.error === "too_long" ? "That is too long to keep." : "That could not be kept.", |
| 173 | 187 | }, |
| 188 | { href: "/" }, | |
| 174 | 189 | ); |
| 175 | 190 | } |
| 176 | 191 | const [owner = "", name = "", ...extra] = target.split("/"); |
test/helpers/chrome.ts +2 −1
| @@ -107,7 +107,7 @@ async function readDebuggerUrl( | ||
| 107 | 107 | * cookies and the given colour scheme, and return what `expression` (a JS |
| 108 | 108 | * expression that yields a JSON string) says once the page has loaded. |
| 109 | 109 | */ |
| 110 | async function evaluateLoaded( | |
| 110 | export async function evaluateLoaded( | |
| 111 | 111 | binary: string, |
| 112 | 112 | url: string, |
| 113 | 113 | viewport: { width: number; height: number }, |
| @@ -187,6 +187,7 @@ async function evaluateLoaded( | ||
| 187 | 187 | const { result } = await cdp.send("Runtime.evaluate", { |
| 188 | 188 | expression, |
| 189 | 189 | returnByValue: true, |
| 190 | awaitPromise: true, | |
| 190 | 191 | }); |
| 191 | 192 | return result.value as string; |
| 192 | 193 | } finally { |
test/palette-tell-gate.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * The palette's tell target costs a repository read only where the first path | |
| 3 | * segment can be an owner (#5885): the forge's own pages never pay it, so a | |
| 4 | * new top-level route that is left off the list shows up here as a read. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("the tell target's repository read", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let cookie = ""; | |
| 23 | let reads = 0; | |
| 24 | let http: ForgeHttp; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-palette-gate-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | const made = await createUser(app.db, { | |
| 30 | origin: "seed", | |
| 31 | trusted: true, | |
| 32 | username: "keeper", | |
| 33 | email: "[email protected]", | |
| 34 | password: "a-good-passphrase", | |
| 35 | adminIfFirst: true, | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | http = forgeHttp(app); | |
| 39 | cookie = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 40 | // Count the by-name repository lookup, the one `getRepo` issues. | |
| 41 | const query = app.db.query.bind(app.db) as (sql: string) => unknown; | |
| 42 | (app.db as { query: unknown }).query = (sql: string) => { | |
| 43 | if (sql.includes("user.username = ?1 COLLATE NOCASE AND repo.name = ?2")) reads++; | |
| 44 | return query(sql); | |
| 45 | }; | |
| 46 | }); | |
| 47 | ||
| 48 | afterAll(async () => { | |
| 49 | app.close(); | |
| 50 | await rm(dataDir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | const get = async (path: string): Promise<string> => { | |
| 54 | reads = 0; | |
| 55 | const res = await http.handle(new Request(`http://localhost${path}`, { headers: { cookie } })); | |
| 56 | return res.text(); | |
| 57 | }; | |
| 58 | ||
| 59 | test("the forge's own two-segment pages read no repository for the target", async () => { | |
| 60 | for (const path of ["/settings/origins", "/settings/tokens", "/settings/keys"]) { | |
| 61 | const html = await get(path); | |
| 62 | expect(html).toContain(`"tell":{"app":"forge"}`); | |
| 63 | expect(reads).toBe(0); | |
| 64 | } | |
| 65 | }); | |
| 66 | ||
| 67 | test("a path that can be an owner and a name costs the target one read, which signed out it does not", async () => { | |
| 68 | await get("/nobody/nothing"); | |
| 69 | const signedIn = reads; | |
| 70 | reads = 0; | |
| 71 | await http.handle(new Request("http://localhost/nobody/nothing")); | |
| 72 | expect(signedIn).toBe(reads + 1); | |
| 73 | }); | |
| 74 | }); |
test/palette-tell.test.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * The palette's last row, "Tell the crew: <what you typed>" (#5885): in a | |
| 3 | * real headless Chromium on a repository page, type a sentence, pick the | |
| 4 | * row, and the tell is in the forge with that page as where it was said. | |
| 5 | * Signed out, the row is not drawn. The refusals are the action's own words. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { findChromium } from "../src/looks/capture.ts"; | |
| 18 | import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const found = findChromium(); | |
| 25 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 26 | if (chromium === undefined) | |
| 27 | console.log("SKIPPING: no chromium that starts on this machine, so the palette tell browser tests cannot run"); | |
| 28 | ||
| 29 | /** Open the palette, type `text`, and report the rows; with `pick`, choose the tell row. */ | |
| 30 | const drive = (text: string, pick: boolean): string => `(async () => { try { | |
| 31 | const wait = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 32 | const opener = document.querySelector("[data-palette]"); | |
| 33 | if (!opener) return JSON.stringify({ error: "no palette button on " + location.href + ": " + document.title }); | |
| 34 | opener.click(); | |
| 35 | await wait(100); | |
| 36 | const dlg = document.getElementById("palette"); | |
| 37 | const input = dlg.querySelector(".palette-input"); | |
| 38 | input.value = ${JSON.stringify(text)}; | |
| 39 | input.dispatchEvent(new Event("input")); | |
| 40 | await wait(100); | |
| 41 | const rows = [...dlg.querySelectorAll(".palette-item")]; | |
| 42 | const last = rows[rows.length - 1]; | |
| 43 | const tellRow = last && last.hasAttribute("data-tell") ? last.textContent : null; | |
| 44 | let toast = null; | |
| 45 | if (${pick} && last) { | |
| 46 | last.click(); | |
| 47 | await wait(800); | |
| 48 | const t = document.querySelector(".toast[data-palette-tell]"); | |
| 49 | toast = t ? { text: t.querySelector(".toast-body").textContent, link: t.querySelector("a")?.getAttribute("href") ?? null } : null; | |
| 50 | } | |
| 51 | return JSON.stringify({ tellRow, rows: rows.length, open: dlg.open, toast }); | |
| 52 | } catch (e) { return JSON.stringify({ error: String(e && e.stack || e) }); } | |
| 53 | })()`; | |
| 54 | ||
| 55 | describe.skipIf(chromium === undefined)("the palette's tell row", () => { | |
| 56 | let dataDir: string; | |
| 57 | let app: App; | |
| 58 | let servers: Servers; | |
| 59 | let cookies: { name: string; value: string }[] = []; | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-palette-tell-")); | |
| 63 | app = createApp({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | const made = await createUser(app.db, { | |
| 66 | origin: "seed", | |
| 67 | trusted: true, | |
| 68 | username: "keeper", | |
| 69 | email: "[email protected]", | |
| 70 | password: "a-good-passphrase", | |
| 71 | adminIfFirst: true, | |
| 72 | }); | |
| 73 | if (!made.ok) throw new Error(made.error); | |
| 74 | promote(app.db, made.value); | |
| 75 | cookies = [{ name: "forge_session", value: createSession(app.db, made.value.id).token }]; | |
| 76 | const repo = await createRepo(app, made.value, { name: "garden", visibility: "public" }); | |
| 77 | if (!repo.ok) throw new Error("repo"); | |
| 78 | const seeded = await commitToBranch(app, made.value, repo.value, { | |
| 79 | message: "seed", | |
| 80 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 81 | }); | |
| 82 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 83 | }, 30_000); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | servers.stop(); | |
| 87 | app.close(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("on a repository page, typing a sentence and picking the last row tells the crew from that page", async () => { | |
| 92 | const said = "make the garden gate green"; | |
| 93 | const out = JSON.parse( | |
| 94 | await evaluateLoaded( | |
| 95 | chromium!, | |
| 96 | `${servers.url}/keeper/garden`, | |
| 97 | { width: 1280, height: 800 }, | |
| 98 | cookies, | |
| 99 | drive(said, true), | |
| 100 | ), | |
| 101 | ); | |
| 102 | expect(out.error).toBeUndefined(); | |
| 103 | expect(out.tellRow).toBe(`Tell the crew: ${said}`); | |
| 104 | expect(out.open).toBe(false); | |
| 105 | expect(out.toast.link).toMatch(/^\/keeper\/garden\/issues\/\d+$/); | |
| 106 | const issue = app.db | |
| 107 | .query("SELECT title, body FROM issue WHERE title LIKE ?1") | |
| 108 | .get(`%${said}%`) as { title: string; body: string } | null; | |
| 109 | expect(issue).not.toBeNull(); | |
| 110 | expect(issue!.body).toContain("Told from [/keeper/garden](/keeper/garden)"); | |
| 111 | }, 30_000); | |
| 112 | ||
| 113 | test("under three characters there is no tell row", async () => { | |
| 114 | const out = JSON.parse( | |
| 115 | await evaluateLoaded(chromium!, `${servers.url}/keeper/garden`, { width: 1280, height: 800 }, cookies, drive("ga", false)), | |
| 116 | ); | |
| 117 | expect(out.error).toBeUndefined(); | |
| 118 | expect(out.tellRow).toBeNull(); | |
| 119 | }, 30_000); | |
| 120 | ||
| 121 | test("signed out, the row is not drawn and the page carries no tell target", async () => { | |
| 122 | const out = JSON.parse( | |
| 123 | await evaluateLoaded(chromium!, `${servers.url}/keeper/garden`, { width: 1280, height: 800 }, [], drive("make the garden gate green", false)), | |
| 124 | ); | |
| 125 | expect(out.error).toBeUndefined(); | |
| 126 | expect(out.tellRow).toBeNull(); | |
| 127 | const html = await (await fetch(`${servers.url}/keeper/garden`)).text(); | |
| 128 | expect(html).not.toContain('"tell":'); | |
| 129 | }, 30_000); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("the tell the palette posts", () => { | |
| 133 | test("an empty tell answers in the action's own words, as JSON", async () => { | |
| 134 | const dataDir = await mkdtemp(join(tmpdir(), "forge-palette-tell-")); | |
| 135 | const app = createApp({ dataDir, port: 0 }); | |
| 136 | const servers = await serve(app); | |
| 137 | try { | |
| 138 | const made = await createUser(app.db, { | |
| 139 | origin: "seed", | |
| 140 | trusted: true, | |
| 141 | username: "keeper", | |
| 142 | email: "[email protected]", | |
| 143 | password: "a-good-passphrase", | |
| 144 | adminIfFirst: true, | |
| 145 | }); | |
| 146 | if (!made.ok) throw new Error(made.error); | |
| 147 | promote(app.db, made.value); | |
| 148 | const cookie = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 149 | const res = await fetch(`${servers.url}/tell`, { | |
| 150 | method: "POST", | |
| 151 | headers: { cookie, accept: "application/json" }, | |
| 152 | body: new URLSearchParams({ app: "forge", text: " ", from: "/" }), | |
| 153 | }); | |
| 154 | expect(res.status).toBe(422); | |
| 155 | const body = (await res.json()) as { ok: boolean; error: string }; | |
| 156 | expect(body.ok).toBe(false); | |
| 157 | expect(body.error.length).toBeGreaterThan(0); | |
| 158 | const index = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 159 | expect(index).toContain('"tell":{"app":"forge"}'); | |
| 160 | // Without script there is still a door to the composer, signed in only. | |
| 161 | expect(index).toContain('href="/#composer"'); | |
| 162 | expect(await (await fetch(`${servers.url}/explore`)).text()).not.toContain('href="/#composer"'); | |
| 163 | // A forge page is not an owner's: it still answers with the forge as the target. | |
| 164 | const settings = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text(); | |
| 165 | expect(settings).toContain('"tell":{"app":"forge"}'); | |
| 166 | } finally { | |
| 167 | servers.stop(); | |
| 168 | app.close(); | |
| 169 | await rm(dataDir, { recursive: true, force: true }); | |
| 170 | } | |
| 171 | }, 30_000); | |
| 172 | }); |
Low: browsers treat
/\hostlike//host.hrefcomes from the server today, so this can't be exploited, but/^\/(?![\/\\])/.test(href)would make the guard say what it means.