Public: anyone can read this; the crew can change it.

The home page is the feed: server-rendered, live, on phone and desk #368

Merged opened this

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

Merged

scout/forge:scout/361-the-home-page-is-the-feed-server-rendere → main 1 commit fork

  • Merged. scout/361-the-home-page-is-the-feed-server-rendere was merged into main as b7a4fe8.

Review

Sign in to review this change.
8 files changed +323 −3
docs/FEED.md +12 −1
@@ -1,7 +1,7 @@
11 # The feed
22
33 A per-viewer read model of what happened on the forge, ranked, each item with
4 one sentence saying why it is there. Data only: there is no page yet.
4 one sentence saying why it is there.
55
66 `GET /api/v1/feed?limit=&cursor=` → `{ items, next_cursor }`, for the signed-in
77 viewer only. An item is `{ id, kind, app, actor, at, link, reason, score }`;
@@ -41,3 +41,14 @@ Muting hides an app's items except asks: what waits on a person is never
4141 muted away. Dismissing hides one item. The cursor is `score|id` of the last
4242 item; the feed is recomputed per request and resumes by id (or by score if
4343 that item has gone).
44
45 ## The page (`src/web/studio/feed.ts`)
46
47 `/` for a signed-in viewer renders the first 20 items as cards above the shelf,
48 server-side, and `/live/feed` answers the same region for the live-regions
49 client (etag, 304). A card: the reason as a link into the thing, Steer (run
50 cards, posts to the run's `steer`) or Tell the crew (everything else, posts to
51 the app's `tell`), Dismiss and Mute app (the interactions API, same-origin).
52 Keys: `j`/`k`/Enter are the shell's, `x` dismisses the focused card.
53 Not yet: the `choose` deck card (nothing in the feed produces one), stop from
54 a card, and paging past the first 20.
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/http/server.ts +2
@@ -106,6 +106,7 @@ import {
106106 tellSubmit,
107107 } from "../web/studio/actions.ts";
108108 import { asksPage } from "../web/studio/asks_page.ts";
109 import { liveFeedFragment } from "../web/studio/feed.ts";
109110 import { liveAppsFragment, liveOnYouFragment } from "../web/studio/home.ts";
110111 import { appNowFragment } from "../web/studio/now.ts";
111112 import { appStateFragment } from "../web/studio/previews.ts";
@@ -446,6 +447,7 @@ export function buildRouter(): Router<Handler> {
446447 // fetches a region and not the page. Literal, above /:owner.
447448 router.add("GET", "/live/apps", liveAppsFragment);
448449 router.add("GET", "/live/on-you", liveOnYouFragment);
450 router.add("GET", "/live/feed", liveFeedFragment);
449451 // Whether a hostname is an app's verified domain — what Caddy's
450452 // `on_demand_tls { ask … }` asks before it fetches a certificate for a
451453 // name it has never seen (docs/EXTRACT.md). 200 says yes, 404 says no,
src/web/studio/feed.ts added +132
@@ -0,0 +1,132 @@
1 /**
2 * The home's feed: the ranked read model (`feed/feed.ts`) as cards. Each card
3 * is one sentence of why, a link straight into the thing, a way to say
4 * something to the crew from where you are, and dismiss / mute. The first
5 * page is server-rendered; the same region answers at `/live/feed` so a
6 * forge event refreshes it through the live-regions client, like the shelf.
7 * Every string on a card is plain text and is escaped here, once.
8 */
9
10 import type { User } from "../../core/users.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { readFeed } from "../../feed/feed.ts";
13 import type { FeedItem } from "../../feed/rank.ts";
14 import { html, type Html } from "../html.ts";
15 import { fragment } from "../layout.ts";
16 import { btn, empty } from "../ui.ts";
17
18 /** Events that can change what the feed says. */
19 export const FEED_LIVE =
20 "run.finished,pull.opened,pull.merged,pull.reviewed,pull.closed,look.taken,app.serving,issue.blocked,issue.unblocked,decision.*";
21
22 export const FEED_FIRST_PAGE = 20;
23
24 const RUN_LINK = /^\/[^/]+\/[^/]+\/runs\/[A-Za-z0-9_-]+$/;
25
26 const KIND_LABEL: Record<FeedItem["kind"], string> = {
27 run: "Run",
28 ask: "Needs you",
29 pull: "Change",
30 decision: "Decision",
31 map: "Map",
32 cost: "Cost",
33 };
34
35 /** One card. A run's card steers the run; every card can tell its app. */
36 export function feedCard(item: FeedItem): Html {
37 const steer = item.kind === "run" || item.kind === "cost";
38 const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null;
39 return html`<article
40 class="fcard"
41 data-nav
42 tabindex="0"
43 data-item="${item.id}"
44 data-app="${item.app}"
45 data-kind="${item.kind}"
46 >
47 <div class="fcard-head">
48 <span class="badge">${KIND_LABEL[item.kind]}</span>
49 <span class="fcard-app muted small">${item.app}</span>
50 <span class="spacer"></span>
51 <button type="button" class="btn btn-sm btn-ghost" data-feed="dismissed" title="Dismiss (x)">Dismiss</button>
52 <button type="button" class="btn btn-sm btn-ghost" data-feed="muted" title="Mute ${item.app}">Mute app</button>
53 </div>
54 <p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.reason}</a></p>
55 <details class="fcard-say">
56 <summary class="small">${target ? "Steer" : "Tell the crew"}</summary>
57 <form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form">
58 <textarea name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea>
59 ${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })}
60 </form>
61 </details>
62 </article>`;
63 }
64
65 /** The region: cards, or one line saying there is nothing to do. */
66 export function feedRegion(user: User, ctx: Ctx): Html {
67 const { items } = readFeed(ctx.app.db, user, { limit: FEED_FIRST_PAGE });
68 return html`<section
69 id="live-feed"
70 class="feed"
71 aria-label="Feed"
72 data-live="${FEED_LIVE}"
73 data-live-src="/live/feed"
74 >
75 ${items.length === 0
76 ? empty({
77 icon: "activity",
78 title: "Nothing new",
79 note: "When something happens in your apps, it lands here.",
80 })
81 : items.map(feedCard)}
82 </section>`;
83 }
84
85 // GET /live/feed — the feed alone, for the region that shows it.
86 export function liveFeedFragment(ctx: Ctx): Response {
87 const user = ctx.auth.user;
88 if (!user) return fragment(html``, { status: 404 });
89 return fragment(feedRegion(user, ctx), { request: ctx.request });
90 }
91
92 /**
93 * j/k and enter are the shell's (`data-nav`). Here: `x` dismisses the card
94 * in focus, the buttons dismiss or mute, and opening a card is recorded.
95 * Interactions go to the feed API, same-origin with the session cookie.
96 */
97 export const feedJs = /* js */ `
98 (function () {
99 var feed = document.getElementById('live-feed');
100 if (!feed || typeof fetch === 'undefined') return;
101 function send(card, kind, keepalive) {
102 var body = { item: card.getAttribute('data-item'), kind: kind, app: card.getAttribute('data-app') };
103 return fetch('/api/v1/feed/interactions', {
104 method: 'POST', credentials: 'same-origin', keepalive: !!keepalive,
105 headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
106 });
107 }
108 function gone(card) {
109 var cards = Array.prototype.slice.call(feed.querySelectorAll('.fcard'));
110 var next = cards[cards.indexOf(card) + 1] || cards[cards.indexOf(card) - 1];
111 card.hidden = true;
112 if (next) next.focus();
113 }
114 function act(card, kind) {
115 send(card, kind).then(function (r) { if (r.ok) gone(card); }, function () {});
116 }
117 feed.addEventListener('click', function (e) {
118 var t = e.target && e.target.closest ? e.target.closest('[data-feed],[data-feed-open]') : null;
119 var card = t && t.closest('.fcard');
120 if (!card) return;
121 if (t.hasAttribute('data-feed-open')) { send(card, 'opened', true).catch(function () {}); return; }
122 act(card, t.getAttribute('data-feed'));
123 });
124 document.addEventListener('keydown', function (e) {
125 if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return;
126 var a = document.activeElement;
127 if (!a || !a.matches || !a.matches('.fcard')) return;
128 e.preventDefault();
129 act(a, 'dismissed');
130 });
131 })();
132 `;
src/web/studio/feed_style.ts added +18
@@ -0,0 +1,18 @@
1 /** The feed cards' slice of the served stylesheet (no imports: `style.ts` reads this). */
2
3 export const feedCss = /* css */ `
4 .feed { display: grid; gap: 10px; }
5 .fcard { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--card, var(--bg)); }
6 .fcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
7 .fcard[hidden] { display: none; }
8 .fcard-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
9 .fcard-reason { margin: 8px 0 4px; font-size: 1.05em; overflow-wrap: anywhere; }
10 .fcard-say summary { cursor: pointer; color: var(--muted-fg, inherit); padding: 4px 0; }
11 .fcard-form { display: grid; gap: 8px; margin-top: 6px; }
12 .fcard-form textarea { width: 100%; min-height: 56px; resize: vertical; }
13 @media (max-width: 600px) {
14 .fcard { padding: 12px; }
15 .fcard-head .btn { padding: 8px 10px; }
16 .fcard-say summary { padding: 10px 0; }
17 }
18 `;
src/web/studio/home.ts +3 −1
@@ -21,6 +21,7 @@ import { onYouCard } from "../pages/site.ts";
2121 import { writableAppsOf } from "./asks.ts";
2222 import { productionFactsForRepos, shelfRows } from "./shelf.ts";
2323 import { servingHistory } from "./words.ts";
24 import { feedJs, feedRegion } from "./feed.ts";
2425
2526 /**
2627 * The home in the studio: your apps as a shelf of
@@ -147,6 +148,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
147148 ${costLine}
148149 <div class="dash">
149150 <div class="dash-main stack">
151 ${feedRegion(user, ctx)}
150152 ${appsRegion(apps)}
151153 </div>
152154 <aside class="dash-rail stack">
@@ -157,6 +159,6 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
157159 ctx,
158160 title: "Studio",
159161 body,
160 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
162 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}`,
161163 });
162164 }
src/web/studio/style.ts +3
@@ -89,6 +89,8 @@ const rail = part(/* css */ `
8989 .studio-crew li span { opacity: .85; }
9090 `);
9191
92 import { feedCss } from "./feed_style.ts";
93
9294 export const studioCss = `@layer components {\n${[
9395 shell,
9496 tellBox,
@@ -98,4 +100,5 @@ export const studioCss = `@layer components {\n${[
98100 rows,
99101 rowActions,
100102 rail,
103 part(feedCss),
101104 ].join("\n\n")}\n}`;
test/feed-page.test.ts added +152
@@ -0,0 +1,152 @@
1 /**
2 * The home page is the feed: server-rendered cards for the signed-in viewer,
3 * every string escaped, card actions pointing at the routes that already
4 * enforce them, and a fragment address the live client refreshes.
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 { createRepo } from "../src/actions/repos.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { readFeed } from "../src/feed/feed.ts";
17 import { feedCard } from "../src/web/studio/feed.ts";
18
19 describe("the feed on the home page", () => {
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let alice: User;
24 let bob: User;
25 let aliceCookie: string;
26 let bobCookie: string;
27 let secret: Repo;
28 let open: Repo;
29 const emit = (repo: Repo, kind: string, payload = {}) =>
30 app.bus.append({ kind, repoId: repo.id, actorId: alice.id, payload });
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-feed-page-"));
34 app = createApp({ dataDir, port: 0 });
35 servers = await serve(app);
36 const mk = async (username: string, first = false) => {
37 const r = await createUser(app.db, {
38 username,
39 email: `${username}@forge.local`,
40 password: "a-good-passphrase",
41 adminIfFirst: first,
42 });
43 if (!r.ok) throw new Error(r.error);
44 return r.value;
45 };
46 await mk("root", true);
47 alice = await mk("alice");
48 bob = await mk("bob");
49 aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`;
50 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
51 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
52 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
53 if (!s.ok || !o.ok) throw new Error("repos");
54 secret = s.value;
55 open = o.value;
56 emit(secret, "run.finished", { kind: "agent", state: "ok", summary: "hidden work", runId: "rs" });
57 emit(open, "run.finished", {
58 kind: "agent",
59 state: "failed",
60 summary: `<script>alert(1)</script> "x"`,
61 runId: "r1",
62 });
63 });
64
65 afterAll(async () => {
66 servers.stop();
67 app.close();
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 const get = (path: string, cookie?: string, headers: Record<string, string> = {}) =>
72 fetch(`${servers.url}${path}`, {
73 headers: { ...(cookie ? { cookie } : {}), ...headers },
74 });
75
76 test("/ renders the ranked feed on first paint, escaped", async () => {
77 const res = await get("/", aliceCookie);
78 expect(res.status).toBe(200);
79 const text = await res.text();
80 expect(text).toContain('id="live-feed"');
81 expect(text).toContain('data-live-src="/live/feed"');
82 expect(text).toContain("alice/open");
83 expect(text).not.toContain("<script>alert(1)</script>");
84 expect(text).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
85 const order = [...text.matchAll(/class="fcard"[^>]*data-item="([^"]+)"/g)].map((m) => m[1]);
86 expect(order).toEqual(readFeed(app.db, alice).items.map((i) => i.id));
87 });
88
89 test("a card links into the thing and steers or tells from where it is", async () => {
90 const text = await (await get("/", aliceCookie)).text();
91 expect(text).toContain('href="/alice/open/runs/r1"');
92 expect(text).toContain('action="/alice/open/runs/r1/steer"');
93 const item = readFeed(app.db, alice).items[0]!;
94 const tell = feedCard({ ...item, kind: "pull", link: "/alice/open/changes/1" }).text;
95 expect(tell).toContain('action="/alice/open/tell"');
96 expect(tell).toContain('data-feed="dismissed"');
97 expect(tell).toContain('data-feed="muted"');
98 });
99
100 test("a hostile app or reason cannot break out of a card", () => {
101 const card = feedCard({
102 id: "event:e1",
103 kind: "run",
104 app: 'a"><img src=x onerror=1>/b',
105 actor: null,
106 at: new Date().toISOString(),
107 link: "/a/b/runs/r",
108 reason: "<b>x</b>",
109 score: 1,
110 }).text;
111 expect(card).not.toContain("<img");
112 expect(card).not.toContain("<b>x</b>");
113 });
114
115 test("a viewer sees only their own feed: no leak from a private app", async () => {
116 const text = await (await get("/", bobCookie)).text();
117 expect(text).not.toContain("alice/secret");
118 expect(text).not.toContain("hidden work");
119 expect(text).toContain("alice/open");
120 });
121
122 test("the live address answers the region alone, conditionally", async () => {
123 const res = await get("/live/feed", aliceCookie);
124 expect(res.status).toBe(200);
125 const text = await res.text();
126 expect(text.trimStart().startsWith("<section")).toBe(true);
127 expect(text).not.toContain("<html");
128 const again = await get("/live/feed", aliceCookie, { "if-none-match": res.headers.get("etag")! });
129 expect(again.status).toBe(304);
130 expect((await get("/live/feed")).status).toBe(404);
131 });
132
133 test("dismissing a card takes it out of the next render", async () => {
134 const item = readFeed(app.db, bob).items.find((i) => i.kind === "run")!;
135 const post = (origin: string) =>
136 fetch(`${servers.url}/api/v1/feed/interactions`, {
137 method: "POST",
138 headers: { cookie: bobCookie, origin, "content-type": "application/json" },
139 body: JSON.stringify({ item: item.id, kind: "dismissed", app: item.app }),
140 });
141 expect((await post("http://evil.example")).status).toBe(403);
142 expect((await post(servers.url)).status).toBe(201);
143 const text = await (await get("/live/feed", bobCookie)).text();
144 expect(text).not.toContain(`data-item="${item.id}"`);
145 });
146
147 test("the page carries the feed script and a card is focusable for j/k", async () => {
148 const text = await (await get("/", aliceCookie)).text();
149 expect(text).toContain("data-nav");
150 expect(text).toContain("/api/v1/feed/interactions");
151 });
152 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs