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

The Secrets card speaks the home page's language, at the forge, an organization and a repository #9723

Merged opened this

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

Merged

mason/forge:mason/9718-the-secrets-card-speaks-the-home-page-s → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/9718-the-secrets-card-speaks-the-home-page-s was merged into main as 7d697c5.

Review

Sign in to review this change.
3 files changed +110 −57
docs/design/areas/secrets.md added +8
@@ -0,0 +1,8 @@
1 # Secrets (Settings › Secrets, an organization's page, a repository's settings)
2
3 `secretsCard()` in `src/web/pages/secrets.ts` adds no component class. It draws one block in three places: the forge's
4 `/settings/secrets`, an organization's page and a repository's settings. As on the home page, it is a heading over rows:
5 a `<section class="settings-block">` with an `<h2>`, one muted sentence (sealed values handed to a run by name; the narrowest place wins), a `.setting-list` of `row()`s (name, when it
6 was set, when it was last opened, Remove), and the set form as an `.edit-row`. No card around it, and no icon in a
7 row's lead. Values are write-only: the value field is a password input that is never filled. `test/secrets-card.test.ts`
8 renders all three places and finds the same class names. Classes used are named in `docs/design/areas/settings.md`.
src/web/pages/secrets.ts +38 −57
@@ -23,7 +23,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2323 import { field, readForm } from "../form.ts";
2424 import { html, when, type Html } from "../html.ts";
2525 import { flashTo, page } from "../layout.ts";
26 import { btn, card, icon, row, time } from "../ui.ts";
26 import { btn, row, time } from "../ui.ts";
2727 import { accountSections } from "./origins.ts";
2828
2929 const ERRORS: Record<string, string> = {
@@ -45,12 +45,6 @@ function backTo(scope: string, ref: string): string {
4545 return "/settings/secrets";
4646 }
4747
48 const WHERE: Record<SecretScope, string> = {
49 forge: "the whole forge",
50 org: "every repository in this organization",
51 repo: "this repository",
52 };
53
5448 /** The card: names, never values, and a form to set one. */
5549 export function secretsCard(
5650 scope: SecretScope,
@@ -59,7 +53,6 @@ export function secretsCard(
5953 ): Html {
6054 const rows = names.map((s) =>
6155 row({
62 lead: icon("key"),
6356 title: html`<span class="mono">${s.name}</span>`,
6457 meta: html`set ${time(s.updatedAt)} ·
6558 ${
@@ -83,55 +76,43 @@ export function secretsCard(
8376 </form>`,
8477 }),
8578 );
86 return card(
87 html`<div class="card-body">
88 <p class="muted small">
89 What the forge itself needs and nobody should read back — an API
90 token, a bucket key. Set one for ${WHERE[scope]}; it is sealed, shown
91 to no one, and handed only to the run that asks for it by name. Where
92 the same name is set more than once, the narrowest place wins: a
93 repository over its organization over the forge.
94 </p>
95 </div>
96 ${when(
97 rows.length > 0,
98 () =>
99 html`<ul class="list">
100 ${rows}
101 </ul>`,
102 )}
103 <form class="edit-row" method="post" action="/settings/secrets">
104 <input type="hidden" name="scope" value="${scope}" />
105 <input type="hidden" name="ref" value="${ref}" />
106 <input
107 class="input mono"
108 type="text"
109 name="name"
110 placeholder="NAME"
111 aria-label="Secret name"
112 pattern="[A-Z][A-Z0-9_]*"
113 maxlength="${String(NAME_MAX)}"
114 autocomplete="off"
115 spellcheck="false"
116 required
117 />
118 <input
119 class="input"
120 type="password"
121 name="value"
122 placeholder="Value"
123 aria-label="Value"
124 autocomplete="off"
125 required
126 />
127 ${btn("Seal", { variant: "primary", size: "sm", icon: "plus" })}
128 </form>`,
129 {
130 head: html`${icon("key")}
131 <h2>Secrets</h2>`,
132 pad: false,
133 },
134 );
79 return html`<section class="settings-block">
80 <h2>Secrets</h2>
81 <p class="muted small">Sealed values the forge hands to a run by name; the narrowest place wins.</p>
82 ${when(
83 rows.length > 0,
84 () =>
85 html`<ul class="list setting-list">
86 ${rows}
87 </ul>`,
outdated

Principle 1 (one confident sentence; nothing explains itself twice) and 8 (plain words): this is a four-sentence paragraph that explains sealing, visibility, hand-off and precedence, and WHERE[scope] repeats what the place already says. Replace it with one muted sentence, e.g. "Sealed values the forge hands to a run by name; the narrowest place wins." Update the docs/design/areas/secrets.md line ("one muted sentence") to match, and add a test assertion that the intro is a single sentence.

88 )}
89 <form class="edit-row mt" method="post" action="/settings/secrets">
90 <input type="hidden" name="scope" value="${scope}" />
91 <input type="hidden" name="ref" value="${ref}" />
92 <input
93 class="input mono"
94 type="text"
95 name="name"
96 placeholder="NAME"
97 aria-label="Secret name"
98 pattern="[A-Z][A-Z0-9_]*"
99 maxlength="${String(NAME_MAX)}"
100 autocomplete="off"
101 spellcheck="false"
102 required
103 />
104 <input
105 class="input"
106 type="password"
107 name="value"
108 placeholder="Value"
109 aria-label="Value"
110 autocomplete="off"
111 required
112 />
113 ${btn("Seal", { variant: "primary", size: "sm", icon: "plus" })}
114 </form>
115 </section>`;
135116 }
136117
137118 /** The card for a place, or nothing for anyone who is not a forge admin. */
test/secrets-card.test.ts added +64
@@ -0,0 +1,64 @@
1 /** The Secrets card is one card in three places, drawn as the home page draws a block (#9718). */
2 import { expect, test } from "bun:test";
3 import type { SecretName, SecretScope } from "../src/core/secret_store.ts";
4 import { secretsCard } from "../src/web/pages/secrets.ts";
5
6 const names: SecretName[] = [
7 { name: "CLOUDFLARE_API_TOKEN", scope: "forge", scopeId: "", updatedAt: "2026-11-01T10:00:00.000Z", lastReadAt: null },
8 { name: "BUCKET_KEY", scope: "forge", scopeId: "", updatedAt: "2026-11-02T10:00:00.000Z", lastReadAt: "2026-11-03T10:00:00.000Z" },
9 ];
10
11 const places: [SecretScope, string][] = [
12 ["forge", ""],
13 ["org", "acme"],
14 ["repo", "acme/site"],
15 ];
16
17 function classes(markup: string): string[] {
18 const found = new Set<string>();
19 for (const m of markup.matchAll(/class="([^"]*)"/g)) for (const c of m[1]!.split(/\s+/)) if (c) found.add(c);
20 return [...found].sort();
21 }
22
23 test("the card has the same classes at the forge, an organization and a repository", () => {
24 const drawn = places.map(([scope, ref]) => classes(String(secretsCard(scope, ref, names))));
25 expect(drawn[1]).toEqual(drawn[0]!);
26 expect(drawn[2]).toEqual(drawn[0]!);
27 });
28
29 test("the card is a heading over rows, not a boxed card with icons", () => {
30 for (const [scope, ref] of places) {
31 const markup = String(secretsCard(scope, ref, names));
32 expect(markup).toContain('<section class="settings-block">');
33 expect(markup).toContain("<h2>Secrets</h2>");
34 expect(markup).toContain('class="list setting-list"');
35 const found = classes(markup);
36 expect(found).not.toContain("card");
37 expect(found).not.toContain("card-head");
38 expect(found).not.toContain("row-lead");
39 }
40 });
41
42 test("each row keeps its name, when it was set and when it was last opened; the value field stays empty", () => {
43 const markup = String(secretsCard("repo", "acme/site", names));
44 expect(markup).toContain("CLOUDFLARE_API_TOKEN");
45 expect(markup).toContain("never used");
46 expect(markup).toContain("last used");
47 expect(markup.match(/set <time/g)?.length).toBe(2);
48 const value = /<input[^>]*name="value"[^>]*>/.exec(markup)?.[0] ?? "";
49 expect(value).toContain('type="password"');
50 expect(value).not.toContain("value=");
51 expect(markup).toContain('action="/settings/secrets"');
52 expect(markup).toContain('action="/settings/secrets/delete"');
53 });
54
55 test("the card says what it is for in one sentence, the same in every place", () => {
56 const intros = places.map(([scope, ref]) => {
57 const markup = String(secretsCard(scope, ref, names));
58 const intro = /<h2>Secrets<\/h2>\s*<p class="muted small">([^<]*)<\/p>/.exec(markup)?.[1]?.trim() ?? "";
59 expect(intro.length).toBeGreaterThan(0);
60 expect(intro.match(/[.!?](\s|$)/g)?.length).toBe(1);
61 return intro;
62 });
63 expect(new Set(intros).size).toBe(1);
64 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs