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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3669-new-app-offers-a-few-starter-sentences-t → main 3 commits fork

  • Merged. scout/3669-new-app-offers-a-few-starter-sentences-t was merged into main as d7b00b4.

Review

Sign in to review this change.
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
897897 `http://<name>.<your forge's host>` from a small, real app (notes in a SQLite
898898 file), and if you named an agent, the sentence became issue #1, assigned.
899899
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
900905 - **Private egress is a site admin's yes.** An app reaches a private
901906 destination (a tailnet or LAN `host:port`) only when `forge.json` `egress`
902907 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 {
2727 import { listAllAgents } from "../../actions/crew.ts";
2828 import { planningAvailable } from "../../crew/plan.ts";
2929 import { appNameJs, deriveAppName } from "./apps-name.ts";
30 import { STARTERS, starterFromQuery, starterHref } from "./apps-starters.ts";
3031 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
3132 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
3233 import { firstPreviews, goldenPath, goldenPathLine } from "../../apps/golden_path.ts";
@@ -281,6 +282,13 @@ function newAppForm(
281282 const crew = agents.ok ? agents.value : [];
282283 const domain = appsRuntime(ctx.app)?.options.domain;
283284
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
284292 return html`<div class="narrow-wide">
285293 ${pageHead("New app", {
286294 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(
297305 autofocus: true,
298306 hint: "A sentence is enough — it becomes the first issue, and the agent you pick below starts on it.",
299307 })}
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>
300320 ${selectField({
301321 label: "Owner",
302322 name: "owner",
@@ -395,7 +415,7 @@ export function newAppPage(ctx: Ctx): Response {
395415 body: newAppForm(ctx, {
396416 owner: ctx.url.searchParams.get("owner") ?? ctx.auth.user.username,
397417 name: "",
398 description: "",
418 description: starterFromQuery(ctx.url.searchParams.get("description")),
399419 visibility: "private",
400420 agent: picks ? PICK_AGENT : "",
401421 }, picks),
test/web-apps.test.ts +59
@@ -12,6 +12,12 @@
1212 * only through a grant, and every new POST refuses a cross-origin form.
1313 */
1414
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";
1521 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1622 import { mkdtemp, rm } from "node:fs/promises";
1723 import { tmpdir } from "node:os";
@@ -185,6 +191,59 @@ describe("apps, runs and the crew on the web", () => {
185191 );
186192 });
187193
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("&lt;script&gt;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("&", "&amp;")}"`,
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
188247 test("New app creates the app and lands on its own page, URL in hand", async () => {
189248 const created = await web(alice, "/apps/new", {
190249 form: {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs