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

Admins mark repositories as app templates, and New app offers a picker when more than one is marked #10634

Merged opened this

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

Merged

scout/forge:scout/10571-admins-mark-repositories-as-app-template → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/10571-admins-mark-repositories-as-app-template was merged into main as 1e03ffa.

Review

Sign in to review this change.
23 files changed +749 −5
docs/design/attention-budget.json +2 −2
@@ -109,12 +109,12 @@
109109 },
110110 "settings": {
111111 "390": {
112 "words": 138,
112 "words": 139,
113113 "blocks": 32,
114114 "colours": 1
115115 },
116116 "1280": {
117 "words": 138,
117 "words": 139,
118118 "blocks": 32,
119119 "colours": 1
120120 }
docs/design/audit.md +1
@@ -114,6 +114,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
114114 - `/settings/resilience` — `pages/domains.ts` · failure domains and their last drill · `/resilience` · none seen.
115115 - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen.
116116 - `/settings/flags` — `pages/flags.ts` · the forge's switches · none · none seen.
117 - `/settings/app-templates` — `pages/app_templates.ts` · which repositories New app offers as templates · `/apps/new` · none seen.
117118 - `/settings/features` — `pages/features.ts` · apps absorbed as the forge's features, and their settings · `/apps` (the same apps, as apps) · none seen.
118119 - `/settings/packs` — `pages/packs-settings.ts` · the repositories /packs lists, one textarea and Save · `/packs`, `/settings/features` · none seen.
119120 - `/settings/model` — `pages/model.ts` · which model the crew runs on · `/settings/crew` · none seen.
docs/features/app-templates.md added +7
@@ -0,0 +1,7 @@
1 # App templates
2
3 A site admin marks repositories as app templates at `/settings/app-templates` (Settings → App templates), or with `POST /api/v1/admin/app-templates` `{ "repo": "owner/name", "marked": true }` (admin scope; anyone else gets 403). The marking is the `app_template` table. `trevato/app-template` is marked by default, comes first, and cannot be unmarked.
4
5 New app (`/apps/new`, and the terminal's New app) shows a "Start from" picker when more than one marked template is readable by the caller; with one, nothing changes. `GET /api/v1/app-templates` lists what the caller is offered. A template the caller cannot read is never listed, and naming it (`template` on `POST /api/v1/apps`, `?template=`) answers 404.
6
7 Code: `src/core/app_templates.ts` (read), `src/actions/app_templates.ts` (mark).
src/actions/app_templates.ts added +37
@@ -0,0 +1,37 @@
1 /** An admin marks or unmarks a repository as an app template; everyone else is refused (`forbidden`); an unknown name is `no_such_template`. */
outdated

Comment says everyone else is told 404; the code returns forbidden (403). Correct it.

2
3 import type { App } from "../app.ts";
4 import { DEFAULT_TEMPLATE, setTemplateMark } from "../core/app_templates.ts";
5 import { getRepo } from "../core/repos.ts";
6 import type { User } from "../core/users.ts";
7 import { parseTemplateName } from "../apps/template_tip.ts";
8 import { err, ok, type Result } from "../kernel/result.ts";
9
10 export type MarkTemplateError = "forbidden" | "no_such_template" | "default_template";
11
12 export const MARK_TEMPLATE_ERRORS: Record<MarkTemplateError, string> = {
13 forbidden: "Only a site admin marks app templates.",
14 no_such_template: "No repository by that name. Name it owner/name.",
15 default_template: `${DEFAULT_TEMPLATE} is always offered first.`,
16 };
17
18 export function markTemplate(
19 app: App,
20 actor: User | undefined,
21 named: string,
22 marked: boolean,
23 ): Result<{ name: string; marked: boolean }, MarkTemplateError> {
24 if (!actor || !actor.isAdmin || actor.kind !== "user") return err("forbidden");
25 const parsed = parseTemplateName(named);
26 const repo = parsed && getRepo(app.db, parsed.owner, parsed.name);
27 if (!repo) return err("no_such_template");
28 const name = `${repo.ownerName}/${repo.name}`;
29 if (!marked && name === DEFAULT_TEMPLATE) return err("default_template");
30 setTemplateMark(app.db, repo, marked, actor.id);
31 app.bus.append({
32 kind: marked ? "app_template.marked" : "app_template.unmarked",
33 actorId: actor.id,
34 payload: { repo: name },
35 });
36 return ok({ name, marked });
37 }
src/contract/openapi/routes/getApiV1AppTemplates.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/app-templates",
6 summary:
7 "The repositories New app offers as templates that you can read, `trevato/app-template` first; answers { templates: [\"owner/name\"] }",
8 auth: true,
9 refusals: {},
10 };
src/contract/openapi/routes/index.ts +2
@@ -53,6 +53,7 @@ export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts";
5353 export { route as getApiV1AdminTrafficProbe } from "./getApiV1AdminTrafficProbe.ts";
5454 export { route as getApiV1Agents } from "./getApiV1Agents.ts";
5555 export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts";
56 export { route as getApiV1AppTemplates } from "./getApiV1AppTemplates.ts";
5657 export { route as getApiV1Apps } from "./getApiV1Apps.ts";
5758 export { route as getApiV1Asked } from "./getApiV1Asked.ts";
5859 export { route as getApiV1Asks } from "./getApiV1Asks.ts";
@@ -229,6 +230,7 @@ export { route as postApiV1AdminAccountsUsernameRole } from "./postApiV1AdminAcc
229230 export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1AdminAccountsUsernameSuspend.ts";
230231 export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts";
231232 export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts";
233 export { route as postApiV1AdminAppTemplates } from "./postApiV1AdminAppTemplates.ts";
232234 export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
233235 export { route as postApiV1AdminMaintenancePrepare } from "./postApiV1AdminMaintenancePrepare.ts";
234236 export { route as postApiV1AdminMaintenanceResume } from "./postApiV1AdminMaintenanceResume.ts";
src/contract/openapi/routes/postApiV1AdminAppTemplates.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/app-templates",
6 summary:
7 "Mark or unmark a repository as an app template (site admin, admin scope); send { repo: \"owner/name\", marked: true|false }; the default template cannot be unmarked",
8 auth: true,
9 refusals: {
10 forbidden: "the caller is not a site admin",
11 invalid_body: "repo and marked are required",
12 default_template: "the default template is always offered",
13 },
14 };
src/core/app_templates.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * The repositories offered as app templates. A site admin marks or unmarks
3 * one (a row in `app_template`); `trevato/app-template` is marked by default
4 * and always comes first, the rest follow in the order they were marked.
5 * `offeredTemplates` lists only those the caller can read, so a private
6 * template is never named to someone who cannot see it.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { canRead, getRepo, getRepoById, type Repo } from "./repos.ts";
11 import type { User } from "./users.ts";
12
13 /** The template every forge offers first, marked without a row. */
14 export const DEFAULT_TEMPLATE = "trevato/app-template";
15
16 /** Marked templates in offer order: the default (when it exists), then by when marked. */
17 export function markedTemplates(db: Database): Repo[] {
18 const rows = db
19 .query<{ repo_id: string }, []>("SELECT repo_id FROM app_template ORDER BY marked_at, repo_id")
20 .all();
21 const out: Repo[] = [];
22 const seen = new Set<string>();
23 const [owner = "", name = ""] = DEFAULT_TEMPLATE.split("/");
24 const first = getRepo(db, owner, name);
25 if (first) {
26 out.push(first);
27 seen.add(first.id);
28 }
29 for (const r of rows) {
30 if (seen.has(r.repo_id)) continue;
31 const repo = getRepoById(db, r.repo_id);
32 if (repo) {
33 out.push(repo);
34 seen.add(repo.id);
35 }
36 }
37 return out;
38 }
39
40 /** The marked templates `actor` can read, as `owner/name`, default first. */
41 export function offeredTemplates(db: Database, actor: User | undefined): string[] {
42 return markedTemplates(db)
43 .filter((r) => canRead(db, r, actor))
44 .map((r) => `${r.ownerName}/${r.name}`);
45 }
46
47 /** Mark (`true`) or unmark (`false`) a repository. Refusing the default's unmarking is the action's job. */
48 export function setTemplateMark(db: Database, repo: Repo, marked: boolean, actorId: string): void {
outdated

Dead code: isMarkedTemplate has no caller. Remove it.

49 if (marked) {
50 db.query("INSERT OR IGNORE INTO app_template (repo_id, marked_by) VALUES (?1, ?2)").run(repo.id, actorId);
51 } else {
52 db.query("DELETE FROM app_template WHERE repo_id = ?1").run(repo.id);
outdated

Comment says the default's marking cannot be taken off, but this function does not enforce it (the action does). Fix the comment.

53 }
54 }
src/core/event_audience.ts +3 −1
@@ -244,7 +244,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
244244 kind.startsWith("release.") ||
245245 // A file held against new work is its admins' book-keeping (#6522).
246246 kind === "repo.file_held" ||
247 kind === "repo.file_released"
247 kind === "repo.file_released" ||
248 // Which repositories New app offers is the admins' choice (`actions/app_templates.ts`).
249 kind.startsWith("app_template.")
248250 )
249251 return "admins";
250252 // A broken adapter sync is for those who may write the repository (`apps/pull.ts`).
src/core/migration-pins/20261110100000-app-template.pin added +1
@@ -0,0 +1,1 @@
1 cec8df5c0d0be8206ed2db8c448c30ef269b3d258d83dff8a1323b3b5fe4163a
src/core/migrations/20261110100000-app-template.ts added +10
@@ -0,0 +1,10 @@
1 // Repositories a site admin has marked as app templates: New app offers them.
2 // One row per marked repository; the default (`trevato/app-template`) needs no
3 // row. Additive.
4 export default `
5 CREATE TABLE app_template (
6 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
7 marked_by TEXT REFERENCES user(id) ON DELETE SET NULL,
8 marked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
9 );
10 `;
src/http/api.ts +5
@@ -359,7 +359,9 @@ import { businessPlanRoutes } from "./api/business_plan.ts";
359359 import { tokenRoutes } from "./api/tokens.ts";
360360 import { accountAdmin, adminRoutes } from "./api/admin.ts";
361361 import { maintenanceRoutes } from "./api/maintenance.ts";
362 import { TEMPLATE_ERRORS } from "../apps/template_tip.ts";
362363 import { flagsRoutes } from "./api/flags.ts";
364 import { appTemplateRoutes } from "./api/app_templates.ts";
363365 import { issueRoutes, labelsHandler } from "./api/issues.ts";
364366 import { FORGE_NOT_CARRIED } from "../origins/words.ts";
365367 import { forgeOriginRoutes } from "./api/forge_origin.ts";
@@ -1245,6 +1247,7 @@ export function apiRoutes(router: Router<Handler>): void {
12451247 adminRoutes(router);
12461248 maintenanceRoutes(router);
12471249 flagsRoutes(router);
1250 appTemplateRoutes(router);
12481251
12491252 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
12501253 // Anyone else is refused; every agent read is logged.
@@ -3632,6 +3635,8 @@ export function apiRoutes(router: Router<Handler>): void {
36323635 ctx.auth.user?.username ||
36333636 "",
36343637 );
3638 // A template the caller cannot read answers as one that does not exist.
3639 if (made.error === "no_such_template") return apiError("no_such_template", 404, TEMPLATE_ERRORS.no_such_template);
36353640 return respondAccess(made.error, 422);
36363641 }
36373642 return json(
src/http/api/app_templates.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * App templates over the API. `GET /api/v1/app-templates` lists the marked
3 * templates the caller can read, default first. `POST /api/v1/admin/app-templates`
4 * with `{ repo: "owner/name", marked: true|false }` marks or unmarks one: a
5 * site admin only, anyone else signed in is told 403.
6 */
7
8 import { MARK_TEMPLATE_ERRORS, markTemplate } from "../../actions/app_templates.ts";
9 import { offeredTemplates } from "../../core/app_templates.ts";
10 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
11 import type { Handler } from "../context.ts";
12 import { apiError, body, json, notFound, requireScope, unauthorized } from "../respond.ts";
13 import type { Router } from "../router.ts";
14
15 export function appTemplateRoutes(router: Router<Handler>): void {
16 router.add("GET", "/api/v1/app-templates", (ctx) => {
17 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
18 if (!ctx.auth.user) return unauthorized();
19 return json({ templates: offeredTemplates(ctx.app.db, ctx.auth.user) });
20 });
21
22 router.add("POST", "/api/v1/admin/app-templates", async (ctx) => {
23 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
24 const user = ctx.auth.user;
25 if (!user) return unauthorized();
26 if (!user.isAdmin || user.kind !== "user") return apiError("forbidden", 403, MARK_TEMPLATE_ERRORS.forbidden);
27 const scoped = requireScope(ctx, "admin");
28 if (scoped) return scoped;
29 const input = await body(ctx.request);
30 if (typeof input["repo"] !== "string" || typeof input["marked"] !== "boolean")
31 return apiError("invalid_body", 422, "send repo (owner/name) and marked (true or false)");
32 const done = markTemplate(ctx.app, user, input["repo"], input["marked"]);
33 if (done.ok) return json(done.value);
34 if (done.error === "no_such_template") return notFound();
35 return apiError(done.error, 422, MARK_TEMPLATE_ERRORS[done.error]);
36 });
37 }
src/http/settings_routes.ts +3
@@ -16,6 +16,7 @@ import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1616 import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
1717 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1818 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
19 import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts";
1920 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
2021 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2122 import { usagePage } from "../web/pages/usage.ts";
@@ -204,6 +205,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
204205 router.add("POST", "/settings/features/:slug/unabsorb", featureSwitch(false));
205206 router.add("POST", "/settings/features/:slug/accept", featureAccept);
206207 router.add("POST", "/settings/flags", flagsSet);
208 router.add("GET", "/settings/app-templates", appTemplatesPage);
209 router.add("POST", "/settings/app-templates", appTemplatesSet);
207210 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
208211 router.add("GET", "/settings/spend", spendPage);
209212 router.add("GET", "/settings/traffic", trafficPage);
src/tui/screens/app_templates.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * App templates in the terminal, a forge admin's: the repositories New app
3 * offers, default first, as Settings → App templates lists them. `m` asks for
4 * an `owner/name` and marks it; `u` unmarks the one under the cursor after a
5 * confirm. Both go through `markTemplate`, so the refusals are the page's and
6 * the API's words. Anyone who is not an admin (or is an agent) is offered no
7 * entry and marks nothing.
8 */
9
10 import { MARK_TEMPLATE_ERRORS, markTemplate } from "../../actions/app_templates.ts";
11 import { DEFAULT_TEMPLATE, markedTemplates } from "../../core/app_templates.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import type { PaletteEntry } from "./entries.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { wrap } from "../term/width.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import type { Session } from "../session.ts";
19 import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts";
20
21 const mayMark = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user";
22
23 export function appTemplatesScreen(f: Session, ctx: Ctx): Screen {
24 const state = new ListState();
25 let rows: { name: string; visibility: string }[] = [];
26 let note = "";
27 const reload = (): void => {
28 rows = mayMark(f)
29 ? markedTemplates(f.db).map((r) => ({ name: `${r.ownerName}/${r.name}`, visibility: r.visibility }))
30 : [];
31 state.clamp(rows.length);
32 ctx.invalidate();
33 };
34 reload();
35 const change = (named: string, marked: boolean): string | undefined => {
36 const done = markTemplate(f.app, f.user, named, marked);
37 note = done.ok ? `${done.value.name} is ${marked ? "marked" : "unmarked"}.` : MARK_TEMPLATE_ERRORS[done.error];
38 reload();
39 return done.ok ? undefined : note;
40 };
41
42 const mark = (): Screen =>
43 promptScreen(ctx, screen, {
44 title: "Mark a template",
45 label: "Which repository?",
46 placeholder: "owner/name",
47 onSubmit: (named) => change(named.trim(), true),
48 });
49 const unmark = (name: string): Screen =>
50 confirmScreen(ctx, screen, {
51 title: "Unmark this template",
52 body: [name, "New app stops offering it."],
53 actions: [{ label: "Keep", variant: "ghost" }, { label: "Unmark", variant: "danger" }],
54 onChoose: (i) => {
55 if (i === 1) change(name, false);
56 },
57 });
58
59 const lines = (): ListRow[] =>
60 rows.map((r) => ({
61 title: r.name,
62 meta: r.name === DEFAULT_TEMPLATE ? "always offered, first" : r.visibility === "private" ? "private" : "",
63 }));
64
65 const screen: Screen = {
66 title: "App templates",
67 keys: [binding(["m"], "mark one"), binding(["u"], "unmark"), binding(["j", "k"], "move cursor")],
68 render(area, s) {
69 const c = ctx.theme.c;
70 const text = note !== "" ? note : mayMark(f) ? `${rows.length} offered by New app.` : MARK_TEMPLATE_ERRORS.forbidden;
71 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
72 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
73 said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w));
74 drawList(s, list!, c, state, lines());
75 },
76 key(e) {
77 if (state.key(e, rows.length)) return true;
78 if (e.ctrl || e.alt || !mayMark(f)) return false;
79 if (e.key === "m") {
80 note = "";
81 ctx.push(mark());
82 return true;
83 }
84 const r = rows[state.index];
85 if (e.key === "u" && r !== undefined) {
86 note = "";
87 ctx.push(unmark(r.name));
88 return true;
89 }
90 return false;
91 },
92 };
93 return screen;
94 }
95
96 /** Offered to a forge admin who is not an agent. */
97 export const entry: PaletteEntry = {
98 id: "app-templates",
99 title: "App templates",
100 hint: "the repositories New app offers; mark or unmark one",
101 after: ["economics"],
102 offered: (session) => mayMark(session),
103 run: (session, ctx) => ctx.push(appTemplatesScreen(session, ctx)),
104 };
src/tui/screens/entries.ts +2
@@ -13,6 +13,7 @@ import { entry as agents } from "./agents.ts";
1313 import { entry as askData } from "./ask_data.ts";
1414 import { entry as archived } from "./archived.ts";
1515 import { entry as audit } from "./audit.ts";
16 import { entry as appTemplates } from "./app_templates.ts";
1617 import { entry as bets } from "./bets.ts";
1718 import { entry as boards } from "./boards.ts";
1819 import { entry as bugs } from "./bugs.ts";
@@ -115,6 +116,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
115116 quotas,
116117 limits,
117118 economics,
119 appTemplates,
118120 flowsAll,
119121 scrapes,
120122 trafficScreenEntry,
src/tui/screens/newapp.ts +16
@@ -11,6 +11,7 @@ import { appLimitWords } from "../../actions/app_limit.ts";
1111 import { NEW_APP_ERRORS, NO_NAME_ERROR, newAppFromSentence } from "../../actions/apps.ts";
1212 import { repositoryLimitMessage } from "../../actions/repos.ts";
1313 import { appsRuntime } from "../../apps/runtime.ts";
14 import { offeredTemplates } from "../../core/app_templates.ts";
1415 import { listOrgsForUser } from "../../core/orgs.ts";
1516 import { storageLimitMessage } from "../../core/storage.ts";
1617 import { parseTemplateName } from "../../apps/template_tip.ts";
@@ -34,6 +35,21 @@ function refusal(f: Session, code: string, ownerId: string): string {
3435 * as `/apps/new?template=` does; the action refuses one the person cannot read.
3536 */
3637 export function newAppScreen(f: Session, ctx: Ctx, parent: Screen, template?: string): Screen {
38 // With no template named and more than one offered, ask which first.

No test covers this picker, and the terminal has no way to mark templates (both doors). Add both.

39 if (template === undefined && f.user !== undefined) {
40 const offered = offeredTemplates(f.db, f.user);
41 if (offered.length > 1)
42 return promptScreen(ctx, parent, {
43 title: "New app",
44 label: `Start from which template? ${offered.join(", ")}`,
45 value: offered[0]!,
46 onSubmit: (named) => {
47 const pick = named.trim();
48 if (!offered.includes(pick)) return `Pick one of: ${offered.join(", ")}.`;
49 return later(ctx, () => newAppScreen(f, ctx, parent, pick));
50 },
51 });
52 }
3753 const owners = f.user
3854 ? [f.user.username, ...listOrgsForUser(f.db, f.user.id).map((o) => o.username)]
3955 : [];
src/web/pages/app_templates.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * App templates, for a forge admin: the repositories New app offers, with a
3 * form to mark another or unmark one. Anyone else is told 403.
4 */
5
6 import { MARK_TEMPLATE_ERRORS, markTemplate } from "../../actions/app_templates.ts";
7 import { DEFAULT_TEMPLATE, markedTemplates } from "../../core/app_templates.ts";
8 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { settingsLayout, signInFirst } from "../chrome.ts";
11 import { field, readForm } from "../form.ts";
12 import { html } from "../html.ts";
13 import { flashTo, page } from "../layout.ts";
14 import { btn, empty, field as formField, icon, row } from "../ui.ts";
15 import { accountSections } from "./origins.ts";
16
17 function denied(ctx: Ctx): Response | undefined {
18 const user = ctx.auth.user;
19 if (!user) return signInFirst(ctx);
20 if (user.isAdmin && user.kind === "user") return undefined;
21 return page(
22 {
23 ctx,
24 title: "Forbidden",
25 body: html`<div class="page-head"><h1>403</h1></div>
26 ${empty({
27 icon: "lock",
28 title: "Admins only",
29 note: MARK_TEMPLATE_ERRORS.forbidden,
30 action: btn("Go home", { href: "/", variant: "primary" }),
31 })}`,
32 },
33 403,
34 );
35 }
36
37 // GET /settings/app-templates
38 export function appTemplatesPage(ctx: Ctx): Response {
39 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
40 const no = denied(ctx);
41 if (no) return no;
42 const rendering = performance.now();
43 const rows = markedTemplates(ctx.app.db).map((repo) => {
44 const name = `${repo.ownerName}/${repo.name}`;
45 return row({
46 lead: icon("rocket"),
47 title: html`<a class="mono" href="/${name}">${name}</a>`,
48 meta: html`${name === DEFAULT_TEMPLATE ? "Always offered, first." : repo.visibility === "private" ? "Private: offered only to people who can read it." : ""}`,
49 trail:
50 name === DEFAULT_TEMPLATE
51 ? html``
52 : html`<form method="post" action="/settings/app-templates">
53 <input type="hidden" name="repo" value="${name}" />
54 <input type="hidden" name="marked" value="0" />
55 ${btn("Unmark", { size: "sm", label: `Unmark ${name}` })}
56 </form>`,
57 });
58 });
59 const body = html`<section class="settings-block">
60 <p class="muted">The repositories New app can copy.</p>
outdated

Principle 1: the h1 already says 'App templates'; this h2 repeats it. Drop the h2.

61 ${rows.length === 0 ? html`<p class="muted">No template is marked.</p>` : html`<ul class="list setting-list">${rows}</ul>`}
outdated

Principle 1: one confident sentence, not an explanation of the picker mechanics. Shorten to what the page is for.

62 <form method="post" action="/settings/app-templates">
63 <input type="hidden" name="marked" value="1" />
64 ${formField({ label: "Mark a repository", name: "repo", placeholder: "owner/name", mono: true, required: true })}
65 ${btn("Mark as template", { variant: "primary" })}
66 </form>
67 </section>`;
68 const response = page({
69 ctx,
70 title: "App templates",
71 body: html`<div class="page-head"><h1>App templates</h1></div>
72 ${settingsLayout(accountSections("templates", { admin: true }), body, "Account settings")}`,
73 });
74 notePhase(ctx.request, "render", performance.now() - rendering);
75 return response;
76 }
77
78 // POST /settings/app-templates
79 export async function appTemplatesSet(ctx: Ctx): Promise<Response> {
80 const no = denied(ctx);
81 if (no) return no;
82 const form = await readForm(ctx, "admin");
83 if (form instanceof Response) return form;
84 const marked = field(form, "marked") === "1";
85 const done = markTemplate(ctx.app, ctx.auth.user, field(form, "repo"), marked);
86 return flashTo(
87 ctx,
88 "/settings/app-templates",
89 done.ok
90 ? { message: `${done.value.name} is ${marked ? "marked" : "unmarked"}.` }
91 : { tone: "danger", message: MARK_TEMPLATE_ERRORS[done.error] },
92 );
93 }
src/web/pages/apps.ts +12 −2
@@ -15,6 +15,7 @@
1515 import { storageLimitMessage } from "../../core/storage.ts";
1616 import { repositoryLimitMessage } from "../../actions/repos.ts";
1717 import { appLimitWords } from "../../actions/app_limit.ts";
18 import { offeredTemplates } from "../../core/app_templates.ts";
1819 import { getUserByName } from "../../core/users.ts";
1920 import {
2021 NEW_APP_ERRORS,
@@ -315,6 +316,8 @@ function newAppForm(
315316 );
316317 const crew = agents.ok ? agents.value : [];
317318 const domain = appsRuntime(ctx.app)?.options.domain;
319 // The marked templates this person can read; a picker only when there is a choice.
320 const offered = timePhase(ctx.request, "templates", () => offeredTemplates(ctx.app.db, user));
318321
319322 // An org's form stays the org's: carry the owner only when it is one the
320323 // select offers, and only when it is not the viewer's own default.
@@ -330,7 +333,13 @@ function newAppForm(
330333 ${html`<form method="post" action="/apps/new">
331334 ${values.template
332335 ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true })
333 : html``}
336 : offered.length > 1
337 ? selectField({
338 label: "Start from",
339 name: "template",
340 options: offered.map((t) => ({ value: t, label: t })),
341 })
342 : html``}
334343 ${formField({
335344 label: "What should it do?",
336345 name: "description",
@@ -542,7 +551,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
542551 ),
543552 scriptJs: appNameJs,
544553 },
545 422,
554 // A template the person cannot read answers as one that does not exist, as the API does.
555 made.error === "no_such_template" ? 404 : 422,
546556 );
547557 }
548558 const { repo, app: record, issue, plan } = made.value;
src/web/pages/origins.ts +2
@@ -114,6 +114,7 @@ export type AccountSection =
114114 | "limits"
115115 | "economics"
116116 | "flags"
117 | "templates"
117118 | "features"
118119 | "packs"
119120 | "cloud";
@@ -162,6 +163,7 @@ const SECTION_ROWS: readonly (readonly [
162163 ["This forge", "Economics", "/settings/economics", "activity", (c) => c === "economics", true],
163164 ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true],
164165 ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true],
166 ["This forge", "Templates", "/settings/app-templates", "rocket", (c) => c === "templates", true],
165167 ["This forge", "Features", "/settings/features", "rocket", (c) => c === "features", true],
166168 ["This forge", "Packs", "/settings/packs", "rocket", (c) => c === "packs", true],
167169 ["This forge", "Backups", "/settings/backups", "download", (c) => c === "backups", true],
test/app_templates.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * App templates: an admin marks repositories, New app offers the marked ones
3 * the caller can read (default first), and a name the caller cannot read is 404.
4 */
5 import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
10 import { setRole } from "../src/actions/roles.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { offeredTemplates } from "../src/core/app_templates.ts";
13 import { eventAudience } from "../src/core/event_audience.ts";
14 import { getRepo, listReposOwnedBy } from "../src/core/repos.ts";
15 import { createUser, getUserByName } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { tokenFor } from "./helpers/tokens.ts";
19
20 quietLogs();
21 setDefaultTimeout(30_000);
22
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 let base: string;
27 let adminToken: string;
28 let takerToken: string;
29 let takerCookie: string;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-apptpl-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 servers = await serve(app);
35 base = servers.url;
36 const mk = async (username: string, adminIfFirst = false) => {
37 const made = await createUser(app.db, {
38 origin: "seed", trusted: true, username, email: `${username}@x.test`,
39 password: "a-good-passphrase", adminIfFirst,
40 });
41 if (!made.ok) throw new Error(made.error);
42 return made.value;
43 };
44 const admin = await mk("giver", true);
45 const trevato = await mk("trevato");
46 const taker = await mk("taker");
47 setRole(app, admin, taker.username, "member");
48 setRole(app, admin, trevato.username, "member");
49 const make = async (owner: typeof admin, name: string, visibility: "public" | "private") => {
50 const repo = await createRepo(app, owner, { name, visibility, description: "" });
51 if (!repo.ok) throw new Error(repo.error);
52 const done = await commitToBranch(app, owner, repo.value, {
53 message: "t",
54 files: [{ path: "README.md", content: `# ${name}\n` }],
55 });
56 if (!done.ok) throw new Error(String(done.error));
57 };
58 await make(trevato, "app-template", "public");
59 await make(admin, "kit", "public");
60 await make(admin, "secret", "private");
61 adminToken = tokenFor(app, "giver", "admin");
62 takerToken = tokenFor(app, "taker", "admin");
63 takerCookie = (
64 (
65 await fetch(`${base}/signin`, {
66 method: "POST",
67 body: new URLSearchParams({ username: "taker", password: "a-good-passphrase" }),
68 redirect: "manual",
69 })
70 ).headers.get("set-cookie") ?? ""
71 ).split(";")[0]!;
72 });
73 afterAll(async () => {
74 servers.stop();
75 app.close();
76 await rm(dir, { recursive: true, force: true });
77 });
78
79 const mark = (token: string, repo: string, marked: boolean) =>
80 fetch(`${base}/api/v1/admin/app-templates`, {
81 method: "POST",
82 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
83 body: JSON.stringify({ repo, marked }),
84 });
85 const listed = async (token: string): Promise<string[]> =>
86 ((await (await fetch(`${base}/api/v1/app-templates`, { headers: { authorization: `Bearer ${token}` } })).json()) as { templates: string[] }).templates;
87 const page = async (): Promise<string> => (await fetch(`${base}/apps/new`, { headers: { cookie: takerCookie } })).text();
88
89 test("with only the default marked, it is listed and the page shows no picker", async () => {
90 expect(await listed(takerToken)).toEqual(["trevato/app-template"]);
91 expect(await page()).not.toContain('name="template"');
92 });
93
94 test("a non-admin cannot mark (403) and nothing changes", async () => {
95 const res = await mark(takerToken, "giver/kit", true);
96 expect(res.status).toBe(403);
97 expect(offeredTemplates(app.db, getUserByName(app.db, "taker"))).toEqual(["trevato/app-template"]);
98 });
99
100 test("an admin marks; the default stays first and the page shows a picker", async () => {
101 expect((await mark(adminToken, "giver/kit", true)).status).toBe(200);
102 expect((await mark(adminToken, "giver/kit", true)).status).toBe(200); // idempotent
103 expect(await listed(takerToken)).toEqual(["trevato/app-template", "giver/kit"]);
104 const html = await page();
105 expect(html).toContain('name="template"');
106 expect(html.indexOf("trevato/app-template")).toBeLessThan(html.indexOf("giver/kit"));
107 });
108
109 test("a private marked template is listed only to those who can read it", async () => {
110 expect((await mark(adminToken, "giver/secret", true)).status).toBe(200);
111 expect(await listed(adminToken)).toEqual(["trevato/app-template", "giver/kit", "giver/secret"]);
112 expect(await listed(takerToken)).toEqual(["trevato/app-template", "giver/kit"]);
113 expect(await page()).not.toContain("giver/secret");
114 });
115
116 test("choosing an unreadable template by name makes nothing and is refused", async () => {
117 const res = await fetch(`${base}/api/v1/apps`, {
118 method: "POST",
119 headers: { authorization: `Bearer ${takerToken}`, "content-type": "application/json" },
120 body: JSON.stringify({ name: "sneaky", description: "x", template: "giver/secret" }),
121 });
122 expect(res.status).toBe(404);
123 expect(getRepo(app.db, "taker", "sneaky")).toBeUndefined();
124 });
125
126 test("unmarking removes it; the default cannot be unmarked; an unknown name is 404", async () => {
127 expect((await mark(adminToken, "giver/secret", false)).status).toBe(200);
128 expect(await listed(adminToken)).toEqual(["trevato/app-template", "giver/kit"]);
129 expect((await mark(adminToken, "trevato/app-template", false)).status).toBe(422);
130 expect((await mark(adminToken, "giver/none", true)).status).toBe(404);
131 expect((await mark(adminToken, "bad name", true)).status).toBe(404);
132 });
133
134 test("the settings page is admin-only", async () => {
135 const forTaker = await fetch(`${base}/settings/app-templates`, { headers: { cookie: takerCookie } });
136 expect(forTaker.status).toBe(403);
137 const asAdmin = await fetch(`${base}/settings/app-templates`, { headers: { authorization: `Bearer ${adminToken}` } });
138 expect(asAdmin.status).toBe(200);
139 expect(await asAdmin.text()).toContain("giver/kit");
140 });
141
142 test("the web New app form refuses an unreadable named template with 404 and makes nothing", async () => {
143 expect((await mark(adminToken, "giver/secret", true)).status).toBe(200);
144 const res = await fetch(`${base}/apps/new`, {
145 method: "POST",
146 headers: { cookie: takerCookie, origin: base },
147 body: new URLSearchParams({ description: "a plant waterer", template: "giver/secret" }),
148 redirect: "manual",
149 });
150 expect(res.status).toBe(404);
151 expect(await res.text()).not.toContain("giver/secret</");
152 expect(listReposOwnedBy(app.db, getUserByName(app.db, "taker")!.id)).toEqual([]);
153 });
154
155 test("marking and unmarking is news for admins alone, so a private name is not broadcast", () => {
156 expect(eventAudience("app_template.marked")).toBe("admins");
157 expect(eventAudience("app_template.unmarked")).toBe("admins");
158 });
test/palette-roster.test.ts +1
@@ -142,6 +142,7 @@ test("a site-admin person is offered the whole roster", () => {
142142 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
143143 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
144144 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
145 ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"],
145146 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
146147 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
147148 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
test/tui-app-templates.test.ts added +175
@@ -0,0 +1,175 @@
1 /**
2 * App templates in the terminal: an admin marks and unmarks from a screen
3 * (nobody else is offered it), and New app asks which template when more than
4 * one is offered, goes straight to the form with one, and refuses a name that
5 * is not offered or not readable.
6 */
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { setRole } from "../src/actions/roles.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { offeredTemplates } from "../src/core/app_templates.ts";
15 import { listReposOwnedBy } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
18 import { blank } from "../src/tui/screens/common.ts";
19 import { appTemplatesScreen } from "../src/tui/screens/app_templates.ts";
20 import { newAppScreen } from "../src/tui/screens/newapp.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24
25 quietLogs();
26 setDefaultTimeout(30_000);
27
28 let dir: string;
29 let app: App;
30 let admin: User;
31 let taker: User;
32
33 const identity = (user?: User) => ({
34 sshUsername: user?.username ?? "git",
35 remoteAddress: "127.0.0.1",
36 fingerprint: "SHA256:test",
37 user,
38 });
39
40 beforeAll(async () => {
41 dir = await mkdtemp(join(tmpdir(), "forge-tui-apptpl-"));
42 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
43 const mk = async (username: string, adminIfFirst = false) => {
44 const made = await createUser(app.db, {
45 origin: "seed", trusted: true, username, email: `${username}@x.test`,
46 password: "a-good-passphrase", adminIfFirst,
47 });
48 if (!made.ok) throw new Error(made.error);
49 return made.value;
50 };
51 admin = await mk("giver", true);
52 taker = await mk("taker");
53 const trevato = await mk("trevato");
54 setRole(app, admin, taker.username, "member");
55 setRole(app, admin, trevato.username, "member");
56 const make = async (owner: User, name: string, visibility: "public" | "private") => {
57 const repo = await createRepo(app, owner, { name, visibility, description: "" });
58 if (!repo.ok) throw new Error(repo.error);
59 const done = await commitToBranch(app, owner, repo.value, {
60 message: "t",
61 files: [{ path: "README.md", content: `# ${name}\n` }],
62 });
63 if (!done.ok) throw new Error(String(done.error));
64 };
65 await make(trevato, "app-template", "public");
66 await make(admin, "kit", "public");
67 await make(admin, "secret", "private");
68 });
69 afterAll(async () => {
70 app.db.close();
71 await rm(dir, { recursive: true, force: true });
72 });
73
74 async function until(done: () => boolean): Promise<void> {
75 const deadline = Date.now() + 5000;
76 while (!done() && Date.now() < deadline) await Bun.sleep(20);
77 if (!done()) throw new Error("never came");
78 }
79
80 async function withScreen(
81 user: User,
82 make: (session: Session, ctx: Parameters<typeof newAppScreen>[1]) => ReturnType<typeof newAppScreen>,
83 body: (h: { text: () => string; feed: (s: string) => Promise<void> }) => Promise<void>,
84 ) {
85 const terminal = headlessTerminal(120, 30);
86 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
87 tui.start();
88 tui.ctx.push(make(session, tui.ctx));
89 try {
90 await settleApp(tui);
91 await body({
92 text: () => tui.screen.toText(),
93 feed: async (s) => {
94 terminal.feed(s);
95 await settleApp(tui);
96 },
97 });
98 } finally {
99 tui.stop();
100 }
101 }
102
103 const markScreen = (s: Session, c: Parameters<typeof newAppScreen>[1]) => appTemplatesScreen(s, c);
104 const newApp = (s: Session, c: Parameters<typeof newAppScreen>[1]) => newAppScreen(s, c, blank("New app"));
105
106 describe("the app templates screen", () => {
107 test("only an admin is offered it", () => {
108 const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id);
109 expect(ids(admin)).toContain("app-templates");
110 expect(ids(taker)).not.toContain("app-templates");
111 expect(ids({ ...admin, kind: "agent" })).not.toContain("app-templates");
112 expect(ids()).not.toContain("app-templates");
113 });
114
115 test("a non-admin who reaches it sees the refusal and marks nothing", () =>
116 withScreen(taker, markScreen, async ({ text, feed }) => {
117 expect(text()).toContain("Only a site admin marks app templates.");
118 await feed("m");
119 await feed("giver/kit\r");
120 expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template"]);
121 }));
122
123 test("m marks, a missing name is refused in the action's words, u asks first then unmarks", () =>
124 withScreen(admin, markScreen, async ({ text, feed }) => {
125 await feed("m");
126 await feed("giver/none\r");
127 expect(text()).toContain("No repository by that name.");
128 await feed("\x1b");
129 await Bun.sleep(100);
130 await feed("m");
131 await feed("giver/kit\r");
132 expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template", "giver/kit"]);
133 await feed("j");
134 await feed("u");
135 await feed("\x1b"); // esc keeps it
136 await Bun.sleep(100);
137 expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template", "giver/kit"]);
138 await feed("u");
139 await feed("\x1b[C\r"); // right to Unmark, enter
140 expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template"]);
141 }));
142 });
143
144 describe("the New app picker", () => {
145 test("one offered goes straight to the form", () =>
146 withScreen(taker, newApp, async ({ text }) => {
147 expect(text()).toContain("What should it do?");
148 expect(text()).not.toContain("Start from which template?");
149 }));
150
151 test("more than one offered asks which first, refuses a name not offered, and makes nothing", async () => {
152 const { markTemplate } = await import("../src/actions/app_templates.ts");
153 expect(markTemplate(app, admin, "giver/kit", true).ok).toBe(true);
154 expect(markTemplate(app, admin, "giver/secret", true).ok).toBe(true);
155 await withScreen(taker, newApp, async ({ text, feed }) => {
156 expect(text()).toContain("Start from which template?");
157 expect(text()).toContain("trevato/app-template");
158 expect(text()).not.toContain("giver/secret");
159 for (let i = 0; i < "trevato/app-template".length; i++) await feed("\x7f");
160 await feed("giver/secret\r");
161 expect(text()).toContain("Pick one of: trevato/app-template, giver/kit.");
162 expect(listReposOwnedBy(app.db, taker.id)).toEqual([]);
163 for (let i = 0; i < "giver/secret".length; i++) await feed("\x7f");
164 await feed("giver/kit\r");
165 expect(text()).toContain("New app from giver/kit");
166 });
167 });
168
169 test("a named unreadable template is refused as no such template and makes nothing", () =>
170 withScreen(taker, (s, c) => newAppScreen(s, c, blank("New app"), "giver/secret"), async ({ text, feed }) => {
171 await feed("a plant waterer\r");
172 await until(() => text().includes("No template by that name that you can read."));
173 expect(listReposOwnedBy(app.db, taker.id)).toEqual([]);
174 }));
175 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs