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

/settings/people says what a guest cannot do, instead of "nothing is refused because of one yet" #7223

Merged opened this

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

Merged

torque/forge:torque/7200-settings-people-says-what-a-guest-cannot → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/7200-settings-people-says-what-a-guest-cannot was merged into main as 5b443c0.

Review

Sign in to review this change.
6 files changed +108 −10
docs/ROLES.md +6 −1
@@ -35,12 +35,17 @@ model allowance (dollars a month, default none, through the grant of #1145),
3535 or demotes a member to guest. Demoting leaves any allowance as it is; take it
3636 back on the account's page.
3737
38 The page states what the roles mean in two sentences built from the table
39 (`roleLimits` in `src/core/roles.ts`): what a guest cannot do, every row a
40 member has and a guest lacks, and the admin refusal's own words, so it cannot
41 say less than the guards refuse (`test/people-role-limits.test.ts`).
42
3843 <!-- roles-table -->
3944 | Ability | guest | member | admin |
4045 | --- | --- | --- | --- |
4146 | Read what is public | yes | yes | yes |
4247 | Comment and open issues | yes | yes | yes |
43 | Make an app their own | no | yes | yes |
48 | Create a repository, fork one or copy an app | no | yes | yes |
4449 | Run a crew on a model allowance | no | yes | yes |
4550 | Administer the forge | no | no | yes |
4651 <!-- /roles-table -->
src/core/roles.ts +22 −1
@@ -15,7 +15,7 @@ export type Role = (typeof ROLES)[number];
1515 export const ABILITIES = {
1616 read: "Read what is public",
1717 comment: "Comment and open issues",
18 make_it_yours: "Make an app their own",
18 make_it_yours: "Create a repository, fork one or copy an app",
1919 run_crew: "Run a crew on a model allowance",
2020 administer: "Administer the forge",
2121 } as const;
@@ -51,6 +51,27 @@ export const NOT_A_MEMBER = "the owner has not made this account a member";
5151 /** The words a member (or guest) is refused admin settings and releases with. */
5252 export const NOT_AN_ADMIN = "only an admin can change the forge's settings or its releases";
5353
54 const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1);
55 const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
56
57 /**
58 * What a role means, in two sentences built from `CAN` and the refusal
59 * words, so the page that explains roles says what the guards enforce:
60 * what a guest is refused, and what only an admin may do. `guestLacks` and
61 * `adminOnly` name the rows each sentence stands for.
62 */
63 export const guestLacks = (): Ability[] =>
64 (Object.keys(ABILITIES) as Ability[]).filter((a) => !can("guest", a) && can("member", a));
65 export const adminOnly = (): Ability[] =>
66 (Object.keys(ABILITIES) as Ability[]).filter((a) => can("admin", a) && !can("member", a));
outdated

Only lists abilities in the table; creating/forking a repository is refused by the guard but absent here, so the page understates what a guest cannot do. Put it in the table.

67
68 export function roleLimits(): string {
69 const withheld = guestLacks().map((a) => lower(ABILITIES[a]));
70 // A label may hold its own "or", so the last item is set off with ", or".
71 const list = withheld.length < 2 ? withheld.join("") : `${withheld.slice(0, -1).join(", ")}, or ${withheld.at(-1)}`;
72 return `A guest cannot ${list}. ${capital(NOT_AN_ADMIN)}.`;
73 }
74
5475 /**
5576 * Whether the table withholds `ability` from this account. Only a person
5677 * can be a guest: agents and organizations are not accounts the owner
src/web/pages/people.ts +4 −5
@@ -1,13 +1,13 @@
11 /**
22 * People — every account and its role in one table: guest, member or admin.
33 * An admin promotes a guest to member (with a starter model allowance, none
4 * by default) or demotes a member, in one same-origin form post. Roles are
5 * shown and recorded; nothing is refused because of one yet (`docs/ROLES.md`).
4 * by default) or demotes a member, in one same-origin form post. The guards
5 * refuse what `CAN` withholds; the card says so in `roleLimits` (`docs/ROLES.md`).
66 */
77
88 import { setRole } from "../../actions/roles.ts";
99 import { GRANT_MAX_USD } from "../../core/model_grant.ts";
10 import { listPeople, type Person } from "../../core/roles.ts";
10 import { listPeople, roleLimits, type Person } from "../../core/roles.ts";
1111 import { getUserByName, type User } from "../../core/users.ts";
1212 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1313 import type { Ctx } from "../../http/context.ts";
@@ -65,8 +65,7 @@ export function peoplePage(ctx: Ctx): Response {
6565 {
6666 head: html`<h2>Guests, members and admins</h2>
6767 <p class="muted">A member may be given a starter model allowance (up to
68 $${GRANT_MAX_USD} a month). Roles are recorded here; nothing is refused
69 because of one yet.</p>`,
68 $${GRANT_MAX_USD} a month). ${roleLimits()}</p>`,
7069 },
7170 ),
7271 "Account settings",
test/people-role-limits.test.ts added +73
@@ -0,0 +1,73 @@
1 /** `/settings/people` says what a guest cannot do and what only an admin can, in the words of `roles.ts` (#7200). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { ABILITIES, NOT_AN_ADMIN, adminOnly, guestLacks, roleLimits } from "../src/core/roles.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { proveAll } from "./helpers/verified.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let root = "";
16 let app: App;
17 let servers: Servers;
18
19 beforeAll(async () => {
20 root = mkdtempSync(join(tmpdir(), "forge-people-limits-"));
21 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
22 const u = await createUser(app.db, {
23 origin: "seed",
24 trusted: true,
25 username: "alice",
26 email: "[email protected]",
27 password: "a-good-passphrase",
28 adminIfFirst: true,
29 });
30 if (!u.ok) throw new Error(u.error);
31 proveAll(app);
32 servers = await serve(app);
33 });
34 afterAll(() => {
35 servers.stop();
36 app.close();
37 rmSync(root, { recursive: true, force: true });
38 });
39
40 test("the sentences come from the table and the refusal words", () => {
41 expect(roleLimits()).toBe(
42 "A guest cannot create a repository, fork one or copy an app, or run a crew on a model allowance. " +
43 "Only an admin can change the forge's settings or its releases.",
44 );

Pins one literal string, so adding an ability passes silently or breaks for the wrong reason. Also assert every ability a guest lacks appears in the page text.

45 });
46
47 test("the guest sentence names every row a guest lacks; the admin sentence is the refusal", () => {
48 const [guest, admin] = roleLimits().split(". ");
49 expect(guestLacks().length).toBeGreaterThan(0);
50 for (const a of guestLacks()) expect(guest!.toLowerCase()).toContain(ABILITIES[a].toLowerCase());
51 expect(adminOnly()).toEqual(["administer"]);
52 expect(admin!.toLowerCase()).toBe(`${NOT_AN_ADMIN}.`);
53 });
54
55 test("an admin reads what a guest cannot do, and not that nothing is refused", async () => {
56 const signin = await fetch(`${servers.url}/signin`, {
57 method: "POST",
58 headers: { origin: servers.url },
59 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
60 redirect: "manual",
61 });
62 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
63 const res = await fetch(`${servers.url}/settings/people`, { headers: { cookie } });
64 expect(res.status).toBe(200);
65 const text = (await res.text()).replace(/&#39;|&#x27;/g, "'").replace(/\s+/g, " ");
66 expect(text).toContain(
67 "A guest cannot create a repository, fork one or copy an app, or run a crew on a model allowance.",
68 );
69 expect(text).toContain("Only an admin can change the forge's settings or its releases.");
70 expect(text).toContain(roleLimits());
71 for (const a of guestLacks()) expect(text.toLowerCase()).toContain(ABILITIES[a].toLowerCase());
72 expect(text).not.toContain("nothing is refused");
73 });
test/roles.test.ts +1 −1
@@ -95,7 +95,7 @@ describe("roles on /settings/people", () => {
9595 expect(bob).toMatch(/Administer the forge: <strong>no<\/strong>/);
9696 setMember(app.db, (getUserByName(app.db, "bob"))!.id, true, "x", new Date());
9797 const member = await page(await signIn("bob"));
98 expect(member).toMatch(/Make an app their own: <strong>yes<\/strong>/);
98 expect(member).toMatch(/Create a repository, fork one or copy an app: <strong>yes<\/strong>/);
9999 expect(member).toMatch(/Administer the forge: <strong>no<\/strong>/);
100100 setMember(app.db, getUserByName(app.db, "bob")!.id, false, "x", new Date());
101101 const alice = await page(await signIn("alice"));
test/tui-can.test.ts +2 −2
@@ -64,11 +64,11 @@ describe("what I can do in the terminal", () => {
6464 test("a guest, a member and an admin each see their own row", async () => {
6565 const guest = await shown("bob");
6666 expect(guest).toContain("You are guest here.");
67 expect(guest).toContain("Make an app their own: no");
67 expect(guest).toContain("Create a repository, fork one or copy an app: no");
6868 setMember(forge.db, getUserByName(forge.db, "bob")!.id, true, "x", new Date());
6969 const member = await shown("bob");
7070 expect(member).toContain("You are member here.");
71 expect(member).toContain("Make an app their own: yes");
71 expect(member).toContain("Create a repository, fork one or copy an app: yes");
7272 expect(member).toContain("Administer the forge: no");
7373 const admin = await shown("alice");
7474 expect(admin).toContain("You are admin here.");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs