Public: anyone can read this; the crew can change it.
An app's egress grants speak the home page's language, and say plainly that everything else is refused #9858
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9856-an-app-s-egress-grants-speak-the-home-pa → main 4 commits fork
3 files
changed +52
−25
docs/design/areas/settings.md +2
| @@ -130,6 +130,8 @@ Settings › Prompts (`promptsView()` in `src/web/pages/prompts.ts`, #9825) adds | ||
| 130 | 130 | |
| 131 | 131 | Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`. |
| 132 | 132 | |
| 133 | Settings › App › Private destinations asked for (`appEgressView()` in `src/web/pages/settings.ts`, #9856) adds no class and has no card: a `<section class="settings-block" id="egress">` with its `<h2>`, one sentence that every other host is refused, each destination a `row()` in `<ul class="list setting-list">` with Grant or a confirmed Revoke in the trail, and the recent changes as a timeline. With nothing asked for, one plain line: the app reaches only the forge. Pinned by `test/app-egress-grants.test.ts`. | |
| 134 | ||
| 133 | 135 | Settings › Model (`modelPage()` in `src/web/pages/model.ts`, #9800) adds no class and has no card: Your model budget is one `<section class="settings-block">` with an `<h2>`. A grant an admin gave is one `row()` in `<ul class="list setting-list" id="model-budget">`; with nothing (or an own subscription, or an admin) one plain sentence says what the account may spend and how to add a subscription. The token form sits straight in the block, typed once and never shown again. `ceilingCard` and `grantCard` stay cards on the account's page. Pinned by `test/model-page-shape.test.ts`. |
| 134 | 136 | |
| 135 | 137 | ## Organizations |
src/web/pages/settings.ts +21 −25
| @@ -763,7 +763,7 @@ function appSettingsView( | ||
| 763 | 763 | <div class="mt"> |
| 764 | 764 | ${appDomainsView(base, domains.ok ? domains.value : undefined)} |
| 765 | 765 | </div> |
| 766 | ${when(egress !== undefined && egress.rows.length > 0, () => html`<div class="mt">${appEgressView(base, egress!)}</div>`)} | |
| 766 | ${when(egress !== undefined, () => html`<div class="mt">${appEgressView(base, egress!)}</div>`)} | |
| 767 | 767 | <div class="mt"> |
| 768 | 768 | ${card( |
| 769 | 769 | html`<p> |
| @@ -1371,30 +1371,26 @@ function appEgressView(base: string, egress: EgressView): Html { | ||
| 1371 | 1371 | >`, |
| 1372 | 1372 | }), |
| 1373 | 1373 | ); |
| 1374 | return card( | |
| 1375 | html`<div class="card-body"> | |
| 1376 | <p class="muted small"> | |
| 1377 | Each is an exact address and port forge.json asks for. A grant opens | |
| 1378 | that one destination for the app's cells and nothing else.${egress.canChange | |
| 1379 | ? null | |
| 1380 | : " Only a site admin grants or revokes."} | |
| 1381 | </p> | |
| 1382 | </div> | |
| 1383 | <ul class="list setting-list"> | |
| 1384 | ${rows} | |
| 1385 | </ul> | |
| 1386 | ${when( | |
| 1387 | changes.length > 0, | |
| 1388 | () => | |
| 1389 | html`<div class="card-body"> | |
| 1390 | <div class="timeline">${changes}</div> | |
| 1391 | </div>`, | |
| 1392 | )}`, | |
| 1393 | { | |
| 1394 | head: html`${icon("globe")} | |
| 1395 | <h2>Private destinations asked for</h2>`, | |
| 1396 | }, | |
| 1397 | ); | |
| 1374 | return html`<section class="settings-block" id="egress"> | |
| 1375 | <h2>Private destinations asked for</h2> | |
| 1376 | ${rows.length === 0 | |
| 1377 | ? html`<p class="muted small"> | |
| 1378 | This app reaches only the forge; every other host is refused. | |
| 1379 | </p>` | |
| 1380 | : html`<p class="muted small"> | |
| 1381 | Each is an exact address and port forge.json asks for; every other | |
| 1382 | host is refused.${egress.canChange | |
| 1383 | ? null | |
| 1384 | : " Only a site admin grants or revokes."} | |
| 1385 | </p> | |
| 1386 | <ul class="list setting-list"> | |
| 1387 | ${rows} | |
| 1388 | </ul>`} | |
| 1389 | ${when( | |
| 1390 | changes.length > 0, | |
| 1391 | () => html`<div class="timeline">${changes}</div>`, | |
| 1392 | )} | |
| 1393 | </section>`; | |
| 1398 | 1394 | } |
| 1399 | 1395 | |
| 1400 | 1396 | const EGRESS_ERRORS: Record<string, string> = { |
test/app-egress-grants.test.ts +29
| @@ -235,3 +235,32 @@ describe("private destinations asked for", () => { | ||
| 235 | 235 | expect(verbs).toContain(`revoked the app's grant on ${ARM}`); |
| 236 | 236 | }); |
| 237 | 237 | }); |
| 238 | ||
| 239 | describe("the egress section speaks the home page's language", () => { | |
| 240 | const section = async (cookie: string, path: string) => { | |
| 241 | const page = await (await web(cookie, path)).text(); | |
| 242 | const from = page.indexOf('<section class="settings-block" id="egress">'); | |
| 243 | expect(from).toBeGreaterThan(-1); | |
| 244 | return words(page.slice(from, page.indexOf("</section>", from))); | |
| 245 | }; | |
| 246 | ||
| 247 | test("with destinations: a heading, the refusal sentence and rows, no card", async () => { | |
| 248 | const s = await section(adaCookie, "/cy/arm/settings/app"); | |
| 249 | expect(s).toContain("<h2>Private destinations asked for</h2>"); | |
| 250 | expect(s).toContain("every other host is refused"); | |
| 251 | expect(s).not.toContain('class="card'); | |
| 252 | expect(s).toContain(`/settings/app/egress/${encodeURIComponent(ARM)}/grant`); | |
| 253 | }); | |
| 254 | ||
| 255 | test("with none asked for: one plain line, no list, no card", async () => { | |
| 256 | const made = await createRepo(app, cy, { name: "plain", visibility: "public", description: "" }); | |
| 257 | if (!made.ok) throw new Error(String(made.error)); | |
| 258 | const row = insertApp(app.db, { repoId: made.value.id, slug: "plain", access: "public" }); | |
| 259 | if (!row.ok) throw new Error(row.error); | |
| 260 | const s = await section(adaCookie, "/cy/plain/settings/app"); | |
| 261 | expect(s).toContain("This app reaches only the forge; every other host is refused."); | |
| 262 | expect(s).not.toContain('class="card'); | |
| 263 | expect(s).not.toContain("setting-list"); | |
| 264 | expect(s).not.toContain('data-tone="danger"'); | |
| 265 | }); | |
| 266 | }); |