Public: anyone can read this; the crew can change it.
New app offers a few starter sentences that fill the box in one tap, server-rendered, with no script #3731
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3669-new-app-offers-a-few-starter-sentences-t → main 3 commits fork
4 files
changed +123
−1
README.md +5
| @@ -897,6 +897,11 @@ a sentence about what it should do. Seconds later it is serving at | ||
| 897 | 897 | `http://<name>.<your forge's host>` from a small, real app (notes in a SQLite |
| 898 | 898 | file), and if you named an agent, the sentence became issue #1, assigned. |
| 899 | 899 | |
| 900 | Under the box, four starter sentences are plain links (no script): | |
| 901 | `/apps/new?description=…` fills the box with that text (plain text up to | |
| 902 | 200 characters, anything else is ignored; always escaped), and | |
| 903 | `&owner=` keeps the chosen owner. The list is `src/web/pages/apps-starters.ts`. | |
| 904 | ||
| 900 | 905 | - **Private egress is a site admin's yes.** An app reaches a private |
| 901 | 906 | destination (a tailnet or LAN `host:port`) only when `forge.json` `egress` |
| 902 | 907 | names it exactly and a site admin has granted it: on the |
src/web/pages/apps-starters.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * New app's starter sentences: a few ways to begin for someone who does not | |
| 3 | * know what to type. Each is a plain link to `/apps/new?description=…`, so | |
| 4 | * it works with no script; the page reads the value back through | |
| 5 | * `starterFromQuery`, the one door a `description` query comes in by. | |
| 6 | */ | |
| 7 | ||
| 8 | export const STARTERS: readonly string[] = [ | |
| 9 | "Notes my team can share", | |
| 10 | "A form that collects answers and lists them", | |
| 11 | "A list with a page for each item", | |
| 12 | "A counter that everyone can bump", | |
| 13 | ]; | |
| 14 | ||
| 15 | /** The longest sentence the query may prefill; the longest starter is far under. */ | |
| 16 | export const MAX_STARTER_LENGTH = 200; | |
| 17 | ||
| 18 | /** | |
| 19 | * The prefill a `?description=` carries, or "" when it is not plain text of a | |
| 20 | * sensible length (control characters, too long, blank). Never an error: a | |
| 21 | * bad value is simply ignored. Escaping is the template's job on output. | |
| 22 | */ | |
| 23 | export function starterFromQuery(raw: string | null): string { | |
| 24 | if (raw === null) return ""; | |
| 25 | const text = raw.trim(); | |
| 26 | if (text === "" || text.length > MAX_STARTER_LENGTH) return ""; | |
| 27 | for (let i = 0; i < text.length; i++) { | |
| 28 | const code = text.charCodeAt(i); | |
| 29 | if (code < 0x20 || code === 0x7f) return ""; | |
| 30 | } | |
| 31 | return text; | |
| 32 | } | |
| 33 | ||
| 34 | /** The link for one starter; `owner` rides along so an org's form stays the org's. */ | |
| 35 | export function starterHref(sentence: string, owner?: string): string { | |
| 36 | const own = owner === undefined ? "" : `&owner=${encodeURIComponent(owner)}`; | |
| 37 | return `/apps/new?description=${encodeURIComponent(sentence)}${own}`; | |
| 38 | } |
src/web/pages/apps.ts +21 −1
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | import { listAllAgents } from "../../actions/crew.ts"; |
| 28 | 28 | import { planningAvailable } from "../../crew/plan.ts"; |
| 29 | 29 | import { appNameJs, deriveAppName } from "./apps-name.ts"; |
| 30 | import { STARTERS, starterFromQuery, starterHref } from "./apps-starters.ts"; | |
| 30 | 31 | import { envPull, previewEnv, type EnvName } from "../../apps/data.ts"; |
| 31 | 32 | import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts"; |
| 32 | 33 | import { firstPreviews, goldenPath, goldenPathLine } from "../../apps/golden_path.ts"; |
| @@ -281,6 +282,13 @@ function newAppForm( | ||
| 281 | 282 | const crew = agents.ok ? agents.value : []; |
| 282 | 283 | const domain = appsRuntime(ctx.app)?.options.domain; |
| 283 | 284 | |
| 285 | // An org's form stays the org's: carry the owner only when it is one the | |
| 286 | // select offers, and only when it is not the viewer's own default. | |
| 287 | const carried = | |
| 288 | values.owner !== user.username && owners.includes(values.owner) | |
| 289 | ? values.owner | |
| 290 | : undefined; | |
| 291 | ||
| 284 | 292 | return html`<div class="narrow-wide"> |
| 285 | 293 | ${pageHead("New app", { |
| 286 | 294 | sub: "A repository with a small working app in it: a first issue from your sentence, a crew member on it, production at its hostname, and a preview for every change.", |
| @@ -297,6 +305,18 @@ function newAppForm( | ||
| 297 | 305 | autofocus: true, |
| 298 | 306 | hint: "A sentence is enough — it becomes the first issue, and the agent you pick below starts on it.", |
| 299 | 307 | })} |
| 308 | <div class="stack mb" role="group" aria-labelledby="starters-label"> | |
| 309 | <span id="starters-label" class="muted small" | |
| 310 | >No idea where to start? Tap a starter to fill the box:</span | |
| 311 | > | |
| 312 | ${STARTERS.map((s) => | |
| 313 | btn(s, { | |
| 314 | href: starterHref(s, carried), | |
| 315 | variant: "outline", | |
| 316 | size: "lg", | |
| 317 | }), | |
| 318 | )} | |
| 319 | </div> | |
| 300 | 320 | ${selectField({ |
| 301 | 321 | label: "Owner", |
| 302 | 322 | name: "owner", |
| @@ -395,7 +415,7 @@ export function newAppPage(ctx: Ctx): Response { | ||
| 395 | 415 | body: newAppForm(ctx, { |
| 396 | 416 | owner: ctx.url.searchParams.get("owner") ?? ctx.auth.user.username, |
| 397 | 417 | name: "", |
| 398 | description: "", | |
| 418 | description: starterFromQuery(ctx.url.searchParams.get("description")), | |
| 399 | 419 | visibility: "private", |
| 400 | 420 | agent: picks ? PICK_AGENT : "", |
| 401 | 421 | }, picks), |
test/web-apps.test.ts +59
| @@ -12,6 +12,12 @@ | ||
| 12 | 12 | * only through a grant, and every new POST refuses a cross-origin form. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { createOrg } from "../src/core/orgs.ts"; | |
| 16 | import { | |
| 17 | MAX_STARTER_LENGTH, | |
| 18 | STARTERS, | |
| 19 | starterHref, | |
| 20 | } from "../src/web/pages/apps-starters.ts"; | |
| 15 | 21 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 16 | 22 | import { mkdtemp, rm } from "node:fs/promises"; |
| 17 | 23 | import { tmpdir } from "node:os"; |
| @@ -185,6 +191,59 @@ describe("apps, runs and the crew on the web", () => { | ||
| 185 | 191 | ); |
| 186 | 192 | }); |
| 187 | 193 | |
| 194 | test("New app: starters are plain links that prefill the box; a hostile description is escaped or ignored", async () => { | |
| 195 | const plain = await (await web(alice, "/apps/new")).text(); | |
| 196 | for (const starter of STARTERS) | |
| 197 | expect(plain).toContain(`href="${starterHref(starter)}"`); | |
| 198 | ||
| 199 | const starter = STARTERS[0]!; | |
| 200 | const filled = await ( | |
| 201 | await web(alice, starterHref(starter)) | |
| 202 | ).text(); | |
| 203 | expect(filled).toMatch( | |
| 204 | new RegExp(`data-grow>\\s*${starter}</textarea\\s*>`), | |
| 205 | ); | |
| 206 | ||
| 207 | const hostile = `</textarea><script>alert(1)</script>"><img src=x onerror=1>`; | |
| 208 | const escaped = await ( | |
| 209 | await web(alice, `/apps/new?description=${encodeURIComponent(hostile)}`) | |
| 210 | ).text(); | |
| 211 | expect(escaped).not.toContain("<script>alert(1)"); | |
| 212 | expect(escaped).not.toContain("<img src=x"); | |
| 213 | expect(escaped).toContain("<script>alert(1)"); | |
| 214 | ||
| 215 | for (const bad of ["x".repeat(MAX_STARTER_LENGTH + 1), "a\u0000b", " "]) { | |
| 216 | const ignored = await ( | |
| 217 | await web(alice, `/apps/new?description=${encodeURIComponent(bad)}`) | |
| 218 | ).text(); | |
| 219 | expect(ignored).toMatch(/data-grow>\s*<\/textarea\s*>/); | |
| 220 | } | |
| 221 | }); | |
| 222 | ||
| 223 | test("New app: starters keep a valid ?owner= (an org the viewer writes to) and drop an unknown one", async () => { | |
| 224 | const org = createOrg(app.db, "starterorg", user("alice").id); | |
| 225 | if (!org.ok) throw new Error(org.error); | |
| 226 | const starter = STARTERS[1]!; | |
| 227 | const withOrg = await ( | |
| 228 | await web(alice, "/apps/new?owner=starterorg") | |
| 229 | ).text(); | |
| 230 | for (const s of STARTERS) | |
| 231 | expect(withOrg).toContain( | |
| 232 | `href="${starterHref(s, "starterorg").replace("&", "&")}"`, | |
| 233 | ); | |
| 234 | expect(withOrg).toMatch(/<option\s+value="starterorg"\s+selected/); | |
| 235 | const filled = await ( | |
| 236 | await web(alice, starterHref(starter, "starterorg")) | |
| 237 | ).text(); | |
| 238 | expect(filled).toMatch(/<option\s+value="starterorg"\s+selected/); | |
| 239 | ||
| 240 | const stranger = await ( | |
| 241 | await web(alice, "/apps/new?owner=nobody%22%3E%3Cb%3E") | |
| 242 | ).text(); | |
| 243 | expect(stranger).not.toContain("&owner="); | |
| 244 | expect(stranger).not.toContain("<b>"); | |
| 245 | }); | |
| 246 | ||
| 188 | 247 | test("New app creates the app and lands on its own page, URL in hand", async () => { |
| 189 | 248 | const created = await web(alice, "/apps/new", { |
| 190 | 249 | form: { |
new above
offline