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

Roles: drop the guard_guests switch and test redirectProposal's guest refusal #2380

Merged opened this

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

Merged

mason/forge:mason/2359-roles-drop-the-guard-guests-switch-and-t → main 6 commits fork 4 threads · 4 unresolved

  • Merged. mason/2359-roles-drop-the-guard-guests-switch-and-t was merged into main as ef5d472.

Review

Sign in to review this change.
71 files changed +313 −34
README.md +1 −1
@@ -747,7 +747,7 @@ the work repositories and nothing else. Two on one grant take turns, and
747747 one the provider rests hands its runs to the other until the reset.
748748 **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks.
749749
750 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); `guard_guests` in Capacity switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included).
750 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included).
751751
752752 **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting and revoking are events (`invite.minted`, `invite.revoked`). Signup does not read invites yet.
753753
docs/ROLES.md +7 −3
@@ -15,9 +15,13 @@ verify, a suspended one is refused, an agent and a stranger to a private
1515 repository meet their own reasons first. A comment from a guest is still a
1616 comment; it just starts no run. Agents and organizations are not guests.
1717
18 The Capacity setting `guard_guests` (`FORGE_GUARD_GUESTS`, default `on`) is the
19 switch: `off` leaves the usual permissions. The test suite boots with it `off`
20 and `test/guests.test.ts` turns it on.
18 Nothing switches the refusal off: the test suite runs with it everywhere, and
19 a suite whose people make things promotes them first (`promote` in
20 `test/helpers/members.ts`). The crew's wake-up sweep, which starts a run on an
21 agent-assigned issue that never had one, skips an issue whose last assigner is
22 a guest, so an assignment made before the guard starts nothing either. A
23 spawned forge's founder is made a member, so its first boot can hand the
24 direction to the planner.
2125
2226 On `/settings/people` an admin promotes a guest to member, with a starter
2327 model allowance (dollars a month, default none, through the grant of #1145),
src/actions/spawn.ts +4
@@ -25,6 +25,7 @@ import {
2525 updateAgent,
2626 } from "../core/agents.ts";
2727 import { createOrg, getOrg } from "../core/orgs.ts";
28 import { setMember } from "../core/roles.ts";
2829 import {
2930 canRead,
3031 createRepo,
@@ -308,6 +309,9 @@ async function copyInto(
308309 return made.ok ? made.value : undefined;
309310 })());
310311 if (!founder) return err("could not make the child's founder");
312 // A member, not a guest: the child's first boot acts as the founder to
313 // open the direction and hand it to the planner, which starts a run.
314 setMember(child.db, founder.id, true, founder.id, new Date());
311315
312316 let org = getOrg(child.db, plan.org);
313317 if (!org) {
src/core/capacity.ts +1 −9
@@ -21,8 +21,7 @@ export type CapacityKey =
2121 | "checks.shards"
2222 | "children.max"
2323 | "cells.egress"
24 | "guard_new_accounts"
25 | "guard_guests";
24 | "guard_new_accounts";
2625
2726 interface Definition {
2827 /** What the settings page and the API call it. */
@@ -81,13 +80,6 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
8180 fallback: "off",
8281 help: "on refuses repository creation and forks for accounts younger than 7 days (agents and admins are exempt); off leaves it to the usual permissions. Takes effect immediately. Replaces the hand-installed database trigger that used to do this with no toggle.",
8382 },
84 "guard_guests": {
85 label: "Guest guard",
86 env: "FORGE_GUARD_GUESTS",
87 kind: { type: "choice", values: ["on", "off"] },
88 fallback: "on",
89 help: "on refuses a guest — an account the owner has not made a member — creating a repository, making an app their own, or starting a crew run; off leaves it to the usual permissions. Takes effect immediately.",
90 },
9183 };
9284
9385 export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[];
src/core/roles.ts +13 −4
@@ -8,7 +8,6 @@
88 */
99
1010 import type { Database } from "bun:sqlite";
11 import { capacityValue } from "./capacity.ts";
1211
1312 export const ROLES = ["guest", "member", "admin"] as const;
1413 export type Role = (typeof ROLES)[number];
@@ -52,18 +51,28 @@ export const NOT_A_MEMBER = "the owner has not made this account a member";
5251 /**
5352 * Whether the table withholds `ability` from this account. Only a person
5453 * can be a guest: agents and organizations are not accounts the owner
55 * promotes, and other rules govern them. The `guard_guests` setting can
56 * turn the refusal off (the test suite boots with it off).
54 * promotes, and other rules govern them. Nothing switches it off.
5755 */
5856 export function lacks(
5957 db: Database,
6058 user: { id: string; isAdmin: boolean; kind: string },
6159 ability: Ability,
6260 ): boolean {
63 if (user.kind !== "user" || capacityValue(db, "guard_guests") === "off") return false;
61 if (user.kind !== "user") return false;
6462 return !can(roleOf(db, user), ability);
6563 }
6664
65 /**
66 * `lacks(…, "run_crew")` as SQL, for a query that must not spend a row on
67 * a guest: true when `userId` (a column or subquery, never input) names a
68 * person who is neither an admin nor a member. Kept beside `roleOf` so the
69 * two say the same thing.
70 */

Not blocking: today this is safe, because the only caller passes a constant subquery. But it is an exported function that splices a string into SQL, and that is a sharp edge for the next caller. Consider not exporting a builder: either keep the fragment as a module-private constant next to sweepAssigned, or take a fixed column name and check it against an allow-list. The doc comment saying "never input" is the only thing guarding it right now.

71 export const guestSql = (userId: string): string =>
72 `EXISTS (SELECT 1 FROM user g WHERE g.id = ${userId}
73 AND g.kind = 'user' AND g.is_agent = 0 AND g.is_admin = 0
74 AND NOT EXISTS (SELECT 1 FROM member m WHERE m.user_id = g.id))`;
75
6776 export function roleOf(db: Database, user: { id: string; isAdmin: boolean }): Role {
6877 if (user.isAdmin) return "admin";
6978 const row = db.query<{ n: number }, [string]>("SELECT 1 AS n FROM member WHERE user_id = ?1").get(user.id);
src/crew/dispatch.ts +16 −4
@@ -53,7 +53,7 @@ import {
5353 type Run,
5454 } from "../core/runs.ts";
5555 import { getUserById, type User } from "../core/users.ts";
56 import { lacks } from "../core/roles.ts";
56 import { guestSql, lacks } from "../core/roles.ts";
5757 import { createLog } from "../kernel/log.ts";
5858 import { issueOfBranch, type RunReason } from "./brief.ts";
5959 import { detectClaude } from "./claude.ts";
@@ -216,7 +216,7 @@ export async function startCrew(
216216 pump(app, ready, inFlight);
217217 }, TICK_MS);
218218 requeueInterrupted(app);
219 sweep(app);
219 sweepAssigned(app);
220220 pump(app, ready, inFlight);
221221
222222 const handle: CrewHandle = {
@@ -1074,9 +1074,18 @@ export function taskOf(
10741074
10751075 /**
10761076 * The sweep behind the wake-up: an open issue assigned to an agent that
1077 * has never had a run (assigned while the crew was off) gets one now.
1077 * has never had a run (assigned while the crew was off) gets one now —
1078 * unless the person who last assigned it is a guest. The assignment is
1079 * theirs to pay for, and an issue a guest assigned before the guard existed
1080 * would otherwise start the run the guard refuses them. An assignment with
1081 * no event (made by the crew itself) is the crew's, as before.
10781082 */
1079 function sweep(app: App): void {
1083 export function sweepAssigned(app: App): void {
1084 // Who last assigned it: the newest `issue.assigned` or `pull.assigned`.
1085 const assigner = `(SELECT e.actor_id FROM event e
1086 WHERE e.repo_id = issue.repo_id AND e.kind = issue.kind || '.assigned'
1087 AND json_extract(e.payload, '$.number') = issue.number
1088 ORDER BY e.id DESC LIMIT 1)`;
10801089 const rows = app.db
10811090 .query<{ id: string; repo_id: string }, []>(
10821091 `SELECT issue.id, issue.repo_id FROM issue
@@ -1086,6 +1095,9 @@ function sweep(app: App): void {
10861095 -- work landing, not a task waiting for a run.
10871096 AND NOT (issue.kind = 'pull' AND issue.author_id = issue.assignee_id)
10881097 AND NOT EXISTS (SELECT 1 FROM run WHERE run.issue_id = issue.id AND run.kind = 'agent')
1098 -- In the query, not after it: a guest's issues never take one of
1099 -- the hundred places, however many of them wait.
1100 AND NOT ${guestSql(assigner)}
10891101 ORDER BY issue.id LIMIT 100`,
10901102 )
10911103 .all();
outdated

Medium: this skip happens after LIMIT 100, so skipped issues keep their slots in every sweep. 100 old guest-assigned or demoted-member issues would starve every other wake-up for good. Filter in the SQL instead, and test it with more than 100 skipped rows.

src/http/mcp.ts +1 −1
@@ -2346,7 +2346,7 @@ const TOOLS: readonly Tool[] = [
23462346 properties: {
23472347 key: {
23482348 type: "string",
2349 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts", "guard_guests"],
2349 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts"],
23502350 },
23512351 value: { type: "string", maxLength: 16 },
23522352 dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." },
test/account-budget.test.ts +2
@@ -29,6 +29,7 @@ import {
2929 stopStandInCrew,
3030 type StandInCrew,
3131 } from "./helpers/crew-standin.ts";
32 import { promote } from "./helpers/members.ts";
3233
3334 describe("per-account spend ceilings", () => {
3435 let standIn: StandInCrew;
@@ -52,6 +53,7 @@ describe("per-account spend ceilings", () => {
5253 });
5354 if (!made.ok) throw new Error(made.error);
5455 const user = made.value;
56 promote(app.db, user);
5557 const r = await createRepo(app, user, { name: "yard", visibility: "private" });
5658 if (!r.ok) throw new Error(String(r.error));
5759 const seeded = await commitToBranch(app, user, r.value, {
test/accounts-retire.test.ts +2
@@ -13,6 +13,7 @@ import { listReposOwnedBy } from "../src/core/repos.ts";
1313 import { repoDir } from "../src/git/repos.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
1515 import { proveAll } from "./helpers/verified.ts";
16 import { promote } from "./helpers/members.ts";
1617
1718 describe("accounts: the confirm page for removing an organization too", () => {
1819 let dataDir: string;
@@ -181,6 +182,7 @@ describe("accounts: the confirm page for removing an organization too", () => {
181182 test("a person's own repository is named, and the lead no longer says only the account", async () => {
182183 await mkUser("rh");
183184 proveAll(app);
185 promote(app.db, getUserByName(app.db, "rh")!);
184186 const made = await createRepo(app, getUserByName(app.db, "rh")!, { name: "notes" });
185187 if (!made.ok) throw new Error(made.error);
186188 const res = await fetch(`${base}/settings/accounts/rh/retire`, { headers: admin });
test/accounts.test.ts +4
@@ -27,6 +27,7 @@ import { createUser as insertAgent, getUserByName } from "../src/core/users.ts";
2727 import { serve, type Servers } from "../src/http/server.ts";
2828 import { forgeSshSession, identityFor } from "../src/tui/ssh.ts";
2929 import { proveAll } from "./helpers/verified.ts";
30 import { promote } from "./helpers/members.ts";
3031
3132 const EDGE = "edge-secret-for-accounts";
3233 const KEY_LINE =
@@ -103,6 +104,8 @@ describe("accounts: the owner sees, suspends and removes", () => {
103104 // This suite is about standing, not email: its outsiders are proven.
104105 proveAll(app);
105106 const user = getUserByName(app.db, name)!;
107 // Nor about roles: its outsiders are members (`test/guests.test.ts`).
108 promote(app.db, user);

Low: the formatting is mangled here (return { user, followed by token: ...). Restore the one-line return.

106109 return { user, token: createToken(app.db, user.id, "o", "admin").token };
107110 };
108111
@@ -622,6 +625,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
622625 password: "outsider-passphrase",
623626 });
624627 if (!made.ok) throw new Error(made.error);
628 promote(app.db, made.value);
625629 const token = createToken(app.db, made.value.id, "t", "admin").token;
626630 const create = () =>
627631 api("/api/v1/repos", { method: "POST", token, body: JSON.stringify({ name: "first" }) });
test/actions.test.ts +2
@@ -33,6 +33,7 @@ import { getOrg, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
3333 import { getPull } from "../src/core/pulls.ts";
3434 import { getRepo, type Repo } from "../src/core/repos.ts";
3535 import type { User } from "../src/core/users.ts";
36 import { promote } from "./helpers/members.ts";
3637
3738 /** Event kinds in chronological order (listEvents returns newest first). */
3839 const kinds = (app: App, repoId?: string) =>
@@ -64,6 +65,7 @@ describe("actions own authorization and events", () => {
6465 if (!a.ok || !b.ok) throw new Error("signup failed");
6566 alice = a.value;
6667 bob = b.value;
68 promote(app.db, alice, bob);

Low: const s = await is split from createRepo(...) on the next line. The insertion broke the formatting; put the original line back.

6769 const s = await createRepo(app, alice, { name: "secret" });
6870 const o = await createRepo(app, alice, {
6971 name: "open",
test/api-reader-marks.test.ts +2
@@ -32,6 +32,7 @@ import {
3232 import { READER } from "../src/crew/direct.ts";
3333 import { serve, type Servers } from "../src/http/server.ts";
3434 import { quietLogs } from "./helpers/globals.ts";
35 import { promote } from "./helpers/members.ts";
3536
3637 quietLogs();
3738
@@ -90,6 +91,7 @@ beforeAll(async () => {
9091 password: "a-good-passphrase",
9192 }),
9293 );
94 promote(app.db, mallory);
9395 bot = ok(await createAgent(app, alice, { username: "bot" }));
9496 repo = ok(
9597 await createRepo(app, alice, { name: "square", visibility: "public" }),
test/approve-here.test.ts +2
@@ -28,6 +28,7 @@ import { local } from "../src/tui/screens/where.ts";
2828 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2929 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
3030 import { quietLogs } from "./helpers/globals.ts";
31 import { promote } from "./helpers/members.ts";
3132
3233 quietLogs();
3334
@@ -99,6 +100,7 @@ describe("a local writer approval lets an origin fork's pull run", () => {
99100 people.push(made.value);
100101 }
101102 [alice, carol] = people as [User, User];
103 promote(app.db, carol);
102104 const made = await createRepo(app, alice, {
103105 name: "lib",
104106 visibility: "public",
test/asks-autonomous.test.ts +2
@@ -15,6 +15,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
1515 import { BREAKER_NOTE } from "../src/crew/breaker.ts";
1616 import { askLinesFor } from "../src/web/studio/asks.ts";
1717 import { quietLogs } from "./helpers/globals.ts";
18 import { promote } from "./helpers/members.ts";
1819
1920 quietLogs();
2021
@@ -35,6 +36,7 @@ describe("autonomous repositories ask no one about the crew's own stalls", () =>
3536 });
3637 if (!made.ok) throw new Error("no alice");
3738 alice = getUserByName(app.db, "alice")!;
39 promote(app.db, alice);
3840 const r = await createRepo(app, alice, { name: "shelf", visibility: "public" });
3941 if (!r.ok) throw new Error(String(r.error));
4042 repo = r.value;
test/asks.test.ts +2
@@ -27,6 +27,7 @@ import { answerSetting, UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts";
2727 import { hunt } from "../src/crew/sentinel.ts";
2828 import { ASK_WORDS, decisionsFor } from "../src/web/studio/asks.ts";
2929 import { quietLogs } from "./helpers/globals.ts";
30 import { promote } from "./helpers/members.ts";
3031
3132 quietLogs();
3233
@@ -401,6 +402,7 @@ describe("the asks page", () => {
401402 const carol = await signUp("carol");
402403 const carolUser = getUserByName(app.db, "carol")!;
403404 markVerified(app.db, carolUser.id, new Date().toISOString());
405 promote(app.db, carolUser);
404406 const plot = await createRepo(app, carolUser, { name: "plot", visibility: "public" });
405407 if (!plot.ok) throw new Error(String(plot.error));
406408 away("cells.egress", "report");
test/checks.test.ts +2
@@ -47,6 +47,7 @@ import { createUser, type User } from "../src/core/users.ts";
4747 import type { Repo } from "../src/core/repos.ts";
4848 import { serve, type Servers } from "../src/http/server.ts";
4949 import { readRunLog } from "../src/runs/log.ts";
50 import { promote } from "./helpers/members.ts";
5051
5152 const checkJson = (run: string, extra = ""): string =>
5253 `{"check": {"run": ${JSON.stringify(run)}, "install": ""${extra}}}\n`;
@@ -351,6 +352,7 @@ describe("checks", () => {
351352 });
352353 if (!b.ok) throw new Error(b.error);
353354 bob = b.value;
355 promote(app.db, bob);
354356 const made = await createRepo(app, alice, {
355357 name: "lib",
356358 visibility: "public",
test/commit-identity.test.ts +2
@@ -29,6 +29,7 @@ import {
2929 } from "../src/core/users.ts";
3030 import { repoDir } from "../src/git/repos.ts";
3131 import { serve, type Servers } from "../src/http/server.ts";
32 import { promote } from "./helpers/members.ts";
3233
3334 describe("commitIdentity", () => {
3435 const person = {
@@ -148,6 +149,7 @@ describe("commits the forge makes for a person", () => {
148149 const opted = updateProfile(app, c.value, { commitEmail: "account" });
149150 if (!opted.ok) throw new Error(opted.error);
150151 carol = opted.value;
152 promote(app.db, carol);
151153 repo = await madeRepo(alice, "app");
152154 carols = await madeRepo(carol, "notes");
153155 dir = dirOf(repo);
test/converge-settled.test.ts +2
@@ -23,6 +23,7 @@ import { listRuns } from "../src/core/runs.ts";
2323 import { createUser, type User } from "../src/core/users.ts";
2424 import { resolveOid } from "../src/git/merge.ts";
2525 import { repoDir } from "../src/git/repos.ts";
26 import { promote } from "./helpers/members.ts";
2627
2728 describe("a converge issue whose parent tip the fork already holds", () => {
2829 let dataDir: string;
@@ -59,6 +60,7 @@ describe("a converge issue whose parent tip the fork already holds", () => {
5960 if (!a.ok || !b.ok) throw new Error("users");
6061 alice = a.value;
6162 bob = b.value;
63 promote(app.db, bob);
6264 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
6365 if (!made.ok) throw new Error(String(made.error));
6466 parent = made.value;
test/crew-brief-audience.test.ts +2
@@ -14,6 +14,7 @@ import { addCrew } from "../src/core/agents.ts";
1414 import { createToken } from "../src/core/tokens.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
17 import { promote } from "./helpers/members.ts";
1718
1819 const BRIEF = "Q7BRIEFWORDS look after the parser";
1920
@@ -54,6 +55,7 @@ describe("a crew agent's brief is the owner's configuration", () => {
5455 olive = await person("olive", true);
5556 rita = await person("rita");
5657 wren = await person("wren");
58 promote(app.db, wren);
5759 const made = await createAgent(app, olive, {
5860 username: "scout",
5961 brief: BRIEF,
test/crew-direction-autonomy.test.ts +2
@@ -64,6 +64,7 @@ import { serve, type Servers } from "../src/http/server.ts";
6464 import { mapJson, marksFor } from "../src/web/pages/map.ts";
6565 import { listEvents } from "../src/core/events.ts";
6666 import { quietLogs } from "./helpers/globals.ts";
67 import { promote } from "./helpers/members.ts";
6768
6869 quietLogs();
6970
@@ -154,6 +155,7 @@ beforeAll(async () => {
154155 password: "a-good-passphrase",
155156 }),
156157 );
158 promote(app.db, mallory);
157159 for (const name of ["mason", "atlas", "judge"])
158160 ok(await createAgent(app, alice, { username: name }));
159161 mason = getUserByName(app.db, "mason")!;
test/crew-direction.test.ts +3
@@ -38,6 +38,7 @@ import { convergeFork } from "../src/crew/converge.ts";
3838 import { mayDirectCrew, readersAmong } from "../src/crew/direct.ts";
3939 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
4040 import { serve, type Servers } from "../src/http/server.ts";
41 import { promote } from "./helpers/members.ts";
4142
4243 let dataDir: string;
4344 let app: App;
@@ -115,6 +116,7 @@ fi
115116 });
116117 if (!m.ok) throw new Error(m.error);
117118 mallory = m.value;
119 promote(app.db, mallory);
118120 const b = await createAgent(app, alice, { username: "bot", brief: "Be brief." });
119121 if (!b.ok) throw new Error(String(b.error));
120122 bot = b.value;
@@ -445,6 +447,7 @@ describe("every other door that queues a run", () => {
445447 });
446448 if (!u.ok) throw new Error(u.error);
447449 const uma = u.value;
450 promote(app.db, uma);
448451 // Her own repository, with the bot on its crew: a writer in every way
449452 // but a verified address.
450453 const own = await createRepoRow(app.db, app.reposRoot, {
test/crew-nocredential.test.ts +3
@@ -20,6 +20,7 @@ import {
2020 stopStandInCrew,
2121 type StandInCrew,
2222 } from "./helpers/crew-standin.ts";
23 import { promote } from "./helpers/members.ts";
2324
2425 describe("the crew, with a stand-in Claude Code: no credential reaches the repository", () => {
2526 let standIn: StandInCrew;
@@ -37,6 +38,7 @@ describe("the crew, with a stand-in Claude Code: no credential reaches the repos
3738
3839 test("a non-admin owner's run fails with a person-worded line, before any agent starts", async () => {
3940 app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(alice.id);
41 promote(app.db, alice);
4042 process.env.ANTHROPIC_API_KEY = "sk-forge-own-token";
4143 try {
4244 const issue = openIssue(app, alice, repo, {
@@ -78,6 +80,7 @@ describe("the crew, with a stand-in Claude Code: no credential reaches the repos
7880 });
7981 if (!o.ok) throw new Error(o.error);
8082 const otto = o.value;
83 promote(app.db, otto);
8184 const made = await createRepo(app, otto, {
8285 name: "yard",
8386 visibility: "private",
test/crew-parks.test.ts +2
@@ -70,6 +70,7 @@ import {
7070 parkReason,
7171 struckOut,
7272 } from "../src/crew/parks.ts";
73 import { promote } from "./helpers/members.ts";
7374
7475 /**
7576 * One forge with alice's `engine` on its own: atlas plans, mason builds.
@@ -467,6 +468,7 @@ describe("an issue parked twice is the planner's", () => {
467468 });
468469 if (!b.ok) throw new Error(b.error);
469470 bob = b.value;
471 promote(app.db, bob);
470472 for (const name of ["alice", "bob"]) cookies[name] = await signIn(name);
471473 }, 30_000);
472474
test/cross-repo-pull.test.ts +2
@@ -6,6 +6,7 @@ import { createApp, type App } from "../src/app.ts";
66 import { serve, type Servers } from "../src/http/server.ts";
77 import { gitClientEnv } from "./helpers/git-client.ts";
88 import { proveAll } from "./helpers/verified.ts";
9 import { promote } from "./helpers/members.ts";
910
1011 const gitEnv = gitClientEnv({
1112 GIT_AUTHOR_NAME: "X",
@@ -79,6 +80,7 @@ describe("cross-repo pull requests (fork → upstream)", () => {
7980 ({ token: aliceToken, cookie: aliceCookie } = await mkUser("alice"));
8081 ({ token: bobToken, cookie: bobCookie } = await mkUser("bob"));
8182 proveAll(app);
83 promote(app.db);
8284
8385 // Alice's upstream repo with a seed commit.
8486 await fetch(`${base}/api/v1/repos`, {
test/explore.test.ts +2
@@ -20,6 +20,7 @@ import { createUser, type User } from "../src/core/users.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
2121 import { quietLogs } from "./helpers/globals.ts";
2222 import { proveAll } from "./helpers/verified.ts";
23 import { promote } from "./helpers/members.ts";
2324
2425 quietLogs();
2526
@@ -66,6 +67,7 @@ describe("explore", () => {
6667 });
6768 if (!made.ok) throw new Error(made.error);
6869 proveAll(app);
70 promote(app.db, made.value);
6971 return made.value;
7072 };
7173 const appOf = async (
test/feed-chips.test.ts +2
@@ -19,6 +19,7 @@ import { chipHides } from "../src/feed/chips.ts";
1919 import type { FeedKind } from "../src/feed/items.ts";
2020 import { readFeed } from "../src/feed/feed.ts";
2121 import { feedJs } from "../src/web/studio/feed.ts";
22 import { promote } from "./helpers/members.ts";
2223
2324 const ALL: FeedKind[] = [
2425 "run", "ask", "pull", "decision", "map", "cost", "tell", "reply", "look", "milestone", "choose", "release",
@@ -79,6 +80,7 @@ describe("the chips of a forge", () => {
7980 adminIfFirst: first,
8081 });
8182 if (!r.ok) throw new Error(r.error);
83 promote(app.db, r.value);
8284 return r.value;
8385 };
8486 await mk("root", true);
test/feed-choose.test.ts +2
@@ -14,6 +14,7 @@ import type { Repo } from "../src/core/repos.ts";
1414 import { createUser, type User } from "../src/core/users.ts";
1515 import { readFeed } from "../src/feed/feed.ts";
1616 import { chooseCandidates } from "../src/feed/choose.ts";
17 import { promote } from "./helpers/members.ts";
1718
1819 describe("the feed's choose item", () => {
1920 let dataDir: string;
@@ -36,6 +37,7 @@ describe("the feed's choose item", () => {
3637 adminIfFirst: first,
3738 });
3839 if (!r.ok) throw new Error(r.error);
40 promote(app.db, r.value);
3941 return r.value;
4042 };
4143 await mk("root", true);
test/feed-page.test.ts +2
@@ -15,6 +15,7 @@ import { createUser, type User } from "../src/core/users.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
1616 import { readFeed } from "../src/feed/feed.ts";
1717 import { feedCard } from "../src/web/studio/feed.ts";
18 import { promote } from "./helpers/members.ts";
1819
1920 describe("the feed on the home page", () => {
2021 let dataDir: string;
@@ -43,6 +44,7 @@ describe("the feed on the home page", () => {
4344 adminIfFirst: first,
4445 });
4546 if (!r.ok) throw new Error(r.error);
47 promote(app.db, r.value);
4648 return r.value;
4749 };
4850 await mk("root", true);
test/feed.test.ts +3
@@ -19,6 +19,7 @@ import { Affinity, affinityFor } from "../src/feed/affinity.ts";
1919 import type { Candidate } from "../src/feed/items.ts";
2020 import { readFeed } from "../src/feed/feed.ts";
2121 import { rank } from "../src/feed/rank.ts";
22 import { promote } from "./helpers/members.ts";
2223
2324 const NOW = Date.parse("2026-10-02T12:00:00Z");
2425 const HOUR = 3600_000;
@@ -141,6 +142,7 @@ describe("the feed of a forge", () => {
141142 adminIfFirst: first,
142143 });
143144 if (!r.ok) throw new Error(r.error);
145 promote(app.db, r.value);
144146 return r.value;
145147 };
146148 // The first account is a site admin, who sees everything; neither of
@@ -293,6 +295,7 @@ describe("affinity", () => {
293295 });
294296 if (!u.ok) throw new Error(u.error);
295297 user = u.value;
298 promote(app.db, user);
296299 const r = await createRepo(app, user, { name: "mine", visibility: "public" });
297300 if (!r.ok) throw new Error("repo");
298301 repo = r.value;
test/feeds.test.ts +2
@@ -18,6 +18,7 @@ import {
1818 import { createToken } from "../src/core/tokens.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
21 import { promote } from "./helpers/members.ts";
2122
2223 describe("a repository's feed", () => {
2324 let dataDir: string;
@@ -47,6 +48,7 @@ describe("a repository's feed", () => {
4748 });
4849 if (!b.ok) throw new Error(b.error);
4950 bob = b.value;
51 promote(app.db, bob);
5052 const made = await createRepo(app, alice, {
5153 name: "game",
5254 visibility: "public",
test/fleet-graph.test.ts +2
@@ -25,6 +25,7 @@ import { createUser, type User } from "../src/core/users.ts";
2525 import { serve, type Servers } from "../src/http/server.ts";
2626 import { niceCeiling, windowSvg } from "../src/web/fleet-graph.ts";
2727 import { fleetGraphCsvText } from "../src/web/pages/fleet-graph.ts";
28 import { promote } from "./helpers/members.ts";
2829
2930 describe("the windows", () => {
3031 const now = new Date("2026-10-02T14:37:12.345Z");
@@ -147,6 +148,7 @@ describe("the graph", () => {
147148 if (!a.ok || !b.ok) throw new Error("users");
148149 alice = a.value;
149150 bob = b.value;
151 promote(app.db, alice, bob);
150152 for (const name of ["one", "two"]) {
151153 const r = await createRepo(app, alice, { name, visibility: "private" });
152154 if (!r.ok) throw new Error("repo");
test/fleet-ops.test.ts +2
@@ -33,6 +33,7 @@ import { createToken } from "../src/core/tokens.ts";
3333 import { createUser, type User } from "../src/core/users.ts";
3434 import { parseQuery } from "../src/fleet/selection.ts";
3535 import { serve, type Servers } from "../src/http/server.ts";
36 import { promote } from "./helpers/members.ts";
3637
3738 describe("fleet operations", () => {
3839 let dataDir: string;
@@ -56,6 +57,7 @@ describe("fleet operations", () => {
5657 adminIfFirst,
5758 });
5859 if (!made.ok) throw new Error(made.error);
60 promote(app.db, made.value);
5961 return made.value;
6062 };
6163 const repoOf = async (owner: User, name: string, visibility: "public" | "private") => {
test/flow-card.test.ts +2
@@ -29,6 +29,7 @@ import { stalledRows } from "../src/tui/screens/stalled.ts";
2929 import { feedJs } from "../src/web/studio/feed.ts";
3030 import { serve, type Servers } from "../src/http/server.ts";
3131 import { quietLogs } from "./helpers/globals.ts";
32 import { promote } from "./helpers/members.ts";
3233
3334 quietLogs();
3435
@@ -98,6 +99,7 @@ describe("stalled steps on the home", () => {
9899 adminIfFirst: first,
99100 });
100101 if (!r.ok) throw new Error(r.error);
102 promote(app.db, r.value);
101103 return r.value;
102104 };
103105 await mk("keeper", true);
test/following.test.ts +6
@@ -45,6 +45,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
4545 import { resolveOid } from "../src/git/merge.ts";
4646 import { repoDir } from "../src/git/repos.ts";
4747 import { serve, type Servers } from "../src/http/server.ts";
48 import { promote } from "./helpers/members.ts";
4849
4950 describe("a fork that follows its parent", () => {
5051 let dataDir: string;
@@ -104,6 +105,7 @@ describe("a fork that follows its parent", () => {
104105 });
105106 if (!b.ok) throw new Error(b.error);
106107 bob = b.value;
108 promote(app.db, bob);
107109 bobToken = createToken(app.db, bob.id, "t", "rw").token;
108110 const made = await createRepo(app, alice, {
109111 name: "game",
@@ -432,6 +434,7 @@ describe("a fork that follows its parent", () => {
432434 password: "a-good-passphrase",
433435 });
434436 if (!c.ok) throw new Error(c.error);
437 promote(app.db, c.value);
435438 const forked = await forkRepo(app, c.value, fresh(parent), "game-carol");
436439 if (!forked.ok) throw new Error(String(forked.error));
437440 const carols = forked.value;
@@ -470,6 +473,7 @@ describe("a fork that follows its parent", () => {
470473 password: "a-good-passphrase",
471474 });
472475 if (!d.ok) throw new Error(d.error);
476 promote(app.db, d.value);
473477 const forked = await forkRepo(app, d.value, fresh(parent), "game-dan");
474478 if (!forked.ok) throw new Error(String(forked.error));
475479 const dans = forked.value;
@@ -699,6 +703,7 @@ describe("a fork that follows its parent", () => {
699703 password: "a-good-passphrase",
700704 });
701705 if (!f.ok) throw new Error(f.error);
706 promote(app.db, f.value);
702707 const grand = await forkRepo(app, f.value, fresh(fork), "game-fay");
703708 if (!grand.ok) throw new Error(String(grand.error));
704709
@@ -921,6 +926,7 @@ describe("a landed pull moves the forks, like the push it amounts to", () => {
921926 people.push(made.value);
922927 }
923928 [alice, bob, carol] = people as [User, User, User];
929 promote(app.db, bob, carol);
924930 const made = await createRepo(app, alice, {
925931 name: "engine",
926932 visibility: "public",
test/fork-counts.test.ts +2
@@ -21,6 +21,7 @@ import { lineageOf } from "../src/core/following.ts";
2121 import { canRead, type Repo } from "../src/core/repos.ts";
2222 import { createUser, type User } from "../src/core/users.ts";
2323 import { serve, type Servers } from "../src/http/server.ts";
24 import { promote } from "./helpers/members.ts";
2425
2526 describe("fork counts hold only the forks the viewer may read", () => {
2627 let dataDir: string;
@@ -55,6 +56,7 @@ describe("fork counts hold only the forks the viewer may read", () => {
5556 }
5657 bob = users["bob"]!;
5758 carol = users["carol"]!;
59 promote(app.db, bob, carol);
5860 const made = await createRepo(app, users["alice"]!, {
5961 name: "lib",
6062 visibility: "public",
test/fork-flow.test.ts +3
@@ -15,6 +15,7 @@ import {
1515 type FakeState,
1616 } from "./fake-origin.ts";
1717 import { proveAll } from "./helpers/verified.ts";
18 import { promote } from "./helpers/members.ts";
1819
1920 /**
2021 * The fork flow: grab a repository you cannot push to, branch, edit, and
@@ -165,6 +166,7 @@ describe("the fork flow", () => {
165166 await mkAccount(originServers, UPSTREAM_OWNER);
166167 originToken = await mkAccount(originServers, ORIGIN_LOGIN);
167168 proveAll(originApp);
169 promote(originApp.db);
168170
169171 await seedUpstream("gears");
170172 await seedUpstream("widgets");
@@ -232,6 +234,7 @@ describe("the fork flow", () => {
232234 aliceToken = await mkAccount(servers, "alice");
233235 bobToken = await mkAccount(servers, "bob");
234236 proveAll(app);
237 promote(app.db);
235238
236239 forgejoOrigin = await connect(forgejo);
237240 githubOrigin = await connect(github, "github");
test/fork-pull-approval.test.ts +2
@@ -23,6 +23,7 @@ import { listRuns } from "../src/core/runs.ts";
2323 import { createUser, type User } from "../src/core/users.ts";
2424 import { serve, type Servers } from "../src/http/server.ts";
2525 import { quietLogs } from "./helpers/globals.ts";
26 import { promote } from "./helpers/members.ts";
2627
2728 quietLogs();
2829
@@ -127,6 +128,7 @@ describe("a reader's fork pull runs nothing until a writer approves its head", (
127128 people.push(made.value);
128129 }
129130 [alice, carol] = people as [User, User];
131 promote(app.db, carol);
130132 const agent = await createAgent(app, alice, { username: "bot" });
131133 if (!agent.ok) throw new Error(String(agent.error));
132134 bot = agent.value.user;
test/forks.test.ts +2
@@ -19,6 +19,7 @@ import { commitFiles } from "../src/git/write.ts";
1919 import { resolveOid } from "../src/git/merge.ts";
2020 import { repoDir } from "../src/git/repos.ts";
2121 import { serve, type Servers } from "../src/http/server.ts";
22 import { promote } from "./helpers/members.ts";
2223
2324 /**
2425 * The invariants, one line each (the old file proved them through git over
@@ -91,6 +92,7 @@ describe("forks", () => {
9192 adminIfFirst: name === "alice",
9293 });
9394 users[name] = must(made);
95 promote(app.db, users[name]!);
9496 cookies[name] =
9597 `forge_session=${createSession(app.db, users[name]!.id).token}; forge_mode=dev`;
9698 }
test/front.test.ts +4
@@ -43,6 +43,7 @@ import { serve, type Servers } from "../src/http/server.ts";
4343 import { startLooks } from "../src/looks/service.ts";
4444 import { quietLogs } from "./helpers/globals.ts";
4545 import { proveAll } from "./helpers/verified.ts";
46 import { promote } from "./helpers/members.ts";
4647
4748 quietLogs();
4849
@@ -235,6 +236,7 @@ describe("the front page", () => {
235236 adminIfFirst: true,
236237 });
237238 if (!made.ok) throw new Error(made.error);
239 promote(app.db, made.value);
238240 return made.value;
239241 };
240242 admin = await user("owner");
@@ -637,6 +639,7 @@ describe("the front page", () => {
637639 });
638640 if (!sus.ok) throw new Error(sus.error);
639641 proveAll(app);
642 promote(app.db, sus.value);
640643 await make(sus.value, "sus-app");
641644 expect((await put(adminToken, { pinned: ["suspended-maker/sus-app"], hidden: [] })).status).toBe(200);
642645 forgetFrontScores(app);
@@ -714,6 +717,7 @@ describe("the front page", () => {
714717 const forked = await make(other, "weighed-forks");
715718 const forkerA = await createUser(app.db, { trusted: true, username: "forker-a", email: "[email protected]", password: "a-good-passphrase" });
716719 if (!forkerA.ok) throw new Error(forkerA.error);
720 promote(app.db, forkerA.value);
717721 const forkA = await createRepo(app, forkerA.value, { name: "my-weighed", visibility: "public" });
718722 if (!forkA.ok) throw new Error(String(forkA.error));
719723 app.db.query(`UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2`).run(forked.repo.id, forkA.value.id);
test/guard-new-accounts.test.ts +2
@@ -23,6 +23,7 @@ import { createToken } from "../src/core/tokens.ts";
2323 import { createUser, type User } from "../src/core/users.ts";
2424 import { serve, type Servers } from "../src/http/server.ts";
2525 import { NEW_FOR_MS } from "../src/actions/accounts.ts";
26 import { promote } from "./helpers/members.ts";
2627
2728 describe("guard_new_accounts", () => {
2829 let dataDir: string;
@@ -54,6 +55,7 @@ describe("guard_new_accounts", () => {
5455 ...(opts.adminIfFirst === true ? { adminIfFirst: true as const } : {}),
5556 });
5657 if (!made.ok) throw new Error(made.error);
58 promote(app.db, made.value);
5759 if (opts.backdate)
5860 app.db
5961 .query("UPDATE user SET created_at = ?1 WHERE id = ?2")
test/guests.test.ts +68 −7
@@ -10,6 +10,12 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { changeCapacity } from "../src/actions/capacity.ts";
13 import { proposePlan } from "../src/actions/plan.ts";
14 import { redirectProposal } from "../src/actions/proposals.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import { getProposal } from "../src/core/proposals.ts";
17 import { listComments } from "../src/core/issues.ts";
18 import { ensureDirectionIssue } from "../src/crew/autonomy.ts";
1319 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1420 import { assignIssue, openIssue } from "../src/actions/issues.ts";
1521 import { newApp } from "../src/actions/apps.ts";
@@ -24,7 +30,7 @@ import { cancelRun, createRun, listRuns } from "../src/core/runs.ts";
2430 import { addComment } from "../src/actions/issues.ts";
2531 import { requeueRepoRun } from "../src/actions/runs.ts";
2632 import { proposeResolution } from "../src/crew/proposal.ts";
27 import { agentFor, queueAgentRun } from "../src/crew/dispatch.ts";
33 import { agentFor, queueAgentRun, sweepAssigned } from "../src/crew/dispatch.ts";
2834 import { getIssueById } from "../src/core/issues.ts";
2935 import { convergeFork } from "../src/crew/converge.ts";
3036 import { convergeWords } from "../src/actions/follow.ts";
@@ -95,8 +101,6 @@ describe("a guest cannot own, copy or run", () => {
95101 helper = agent.value.user;
96102 const crewed = addToCrew(app, bob, lab, "helper");
97103 if (!crewed.ok) throw new Error(crewed.error);
98 // The suite boots with the guard off; this one turns it on.
99 expect(changeCapacity(app, alice, "guard_guests", "on").ok).toBe(true);
100104 }, 60_000);
101105
102106 afterAll(async () => {
@@ -183,11 +187,12 @@ describe("a guest cannot own, copy or run", () => {
183187 expect(hidden.status).toBe(404);
184188 });
185189
186 test("an admin is never a guest, and the guard can be turned off", async () => {
190 test("an admin is never a guest, and no setting turns the guard off", async () => {
187191 expect((await api("/api/v1/repos", alice, { name: "admins" })).status).toBe(201);
188 expect(changeCapacity(app, alice, "guard_guests", "off").ok).toBe(true);
189 expect((await api("/api/v1/repos", bob, { name: "open-door" })).status).toBe(201);
190 expect(changeCapacity(app, alice, "guard_guests", "on").ok).toBe(true);
192 expect(changeCapacity(app, alice, "guard_guests" as never, "off").ok).toBe(false);
193 const capacity = await mcpTool(servers.url, createToken(app.db, alice.id, "admin", "admin").token, "capacity", {});
194 expect(JSON.stringify(capacity)).not.toContain("guard_guests");
195 expect((await api("/api/v1/repos", bob, { name: "open-door" })).status).toBe(403);
191196 });
192197
193198 test("push-to-create: a guest's push to a new repository is refused with the words; a member's is not", async () => {
@@ -257,4 +262,60 @@ describe("a guest cannot own, copy or run", () => {
257262 .get(lab.id)!.n;
258263 expect(assigned).toBe(0);
259264 });
265 test("redirecting a wave: a guest's own pending proposal is refused before anything is written", async () => {
266 setCrewRole(app.db, lab.id, helper.id, "planner");
267 const direction = ensureDirectionIssue(app, alice, lab)!;
268 const proposed = proposePlan(app, alice, lab, [{ key: "a", title: "Weed", body: "", blockedBy: [] }], {
269 title: "Spring",
270 });
271 if (!proposed.ok) throw new Error(JSON.stringify(proposed.error));
272 const id = proposed.value.id;
273 const comments = listComments(app.db, direction.id).length;
274
275 expect(redirectProposal(app, bob, id, "Roses first")).toEqual({
276 ok: false,
277 error: { code: "forbidden", message: NOT_A_MEMBER },
278 });
279 expect(getProposal(app.db, id)!.state).toBe("pending");
280 expect(listComments(app.db, direction.id)).toHaveLength(comments);
281 expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(direction.assigneeId);
282 expect(listRuns(app.db, lab.id, { kind: "agent", limit: 50 }).some((r) => r.issueId === direction.id)).toBe(false);
283
284 // Promoted, the same words go through.
285 setMember(app.db, bob.id, true, alice.id, new Date());
286 expect(redirectProposal(app, bob, id, "Roses first").ok).toBe(true);
287 setMember(app.db, bob.id, false, alice.id, new Date());
288 expect(getProposal(app.db, id)!.state).toBe("redirected");
289 });
290
291 test("the wake-up sweep starts no run on an issue a guest assigned, and a hundred of them do not hold back a member's", async () => {
292 // As an assignment made before the guard (or by a member since demoted):
293 // the agent set, the event kept.
294 const assignedBy = (who: User, title: string) => {
295 const opened = openIssue(app, alice, lab, { title, body: "" });
296 if (!opened.ok) throw new Error(String(opened.error));
297 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(helper.id, opened.value.id);
298 app.bus.append({
299 kind: "issue.assigned",
300 repoId: lab.id,
301 actorId: who.id,
302 payload: { number: opened.value.number, assignee: helper.username, agent: true },
303 });
304 return opened.value;
305 };
306 const runsOn = (id: string) => listRuns(app.db, lab.id, { kind: "agent", limit: 500 }).filter((r) => r.issueId === id);
307 // More than the sweep takes at once, all older than the member's.
308 const guests = Array.from({ length: 101 }, (_, n) => assignedBy(bob, `a guest's old assignment ${n}`));
309 const admins = assignedBy(alice, "an admin's assignment");
310
311 sweepAssigned(app);
312 expect(guests.filter((g) => runsOn(g.id).length > 0)).toHaveLength(0);
313 expect(runsOn(admins.id)).toHaveLength(1);
314
315 // The same assignment, once its assigner is a member, is picked up.
316 setMember(app.db, bob.id, true, alice.id, new Date());
317 sweepAssigned(app);
318 setMember(app.db, bob.id, false, alice.id, new Date());
319 expect(runsOn(guests[0]!.id)).toHaveLength(1);
320 });
260321 });
test/hand-decider.test.ts +2
@@ -31,6 +31,7 @@ import { createForgeApp } from "../src/tui/forge.ts";
3131 import { headlessTerminal } from "../src/tui/term/terminal.ts";
3232 import { settleApp } from "./helpers/tui.ts";
3333 import { quietLogs } from "./helpers/globals.ts";
34 import { promote } from "./helpers/members.ts";
3435
3536 quietLogs();
3637
@@ -101,6 +102,7 @@ describe("let the decider answer a hand-over", () => {
101102 });
102103 cookie = `${(signin.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`;
103104 alice = getUserByName(app.db, "alice")!;
105 promote(app.db, alice);
104106 const r = await createRepo(app, alice, { name: "shelf", visibility: "public" });
105107 if (!r.ok) throw new Error(String(r.error));
106108 repo = r.value;
test/helpers/members.ts added +23
@@ -0,0 +1,23 @@
1 import type { Database } from "bun:sqlite";
2 import { setMember } from "../../src/core/roles.ts";
3
4 /**
5 * Make accounts members. A guest cannot own a repository, make an app their
6 * own or start a crew run (`test/guests.test.ts` holds that); suites about
7 * something else whose people make things promote them first, as an owner
8 * would. With no accounts named, every person not yet a member is promoted:
9 * for suites whose people sign up over HTTP.
10 */
11 export function promote(db: Database, ...users: readonly { readonly id: string }[]): void {
12 const now = new Date();
13 const ids =
14 users.length > 0
15 ? users.map((u) => u.id)
16 : db
17 .query<{ id: string }, []>(
18 "SELECT id FROM user WHERE kind = 'user' AND is_agent = 0 AND id NOT IN (SELECT user_id FROM member)",
19 )
20 .all()
21 .map((r) => r.id);
22 for (const id of ids) setMember(db, id, true, id, now);
23 }
test/helpers/preload.ts −4
@@ -6,7 +6,3 @@
66 import { installCheapHash } from "./cheap-hash.ts";
77
88 installCheapHash();
9
10 // Most suites make plain accounts and expect them to own things; the guest
11 // guard (`guard_guests`, on in production) has its own suite that turns it on.
12 process.env["FORGE_GUARD_GUESTS"] = "off";
test/here-manifest.test.ts +2
@@ -19,6 +19,7 @@ import { openPull } from "../src/actions/pulls.ts";
1919 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
2020 import { getRepo } from "../src/core/repos.ts";
2121 import { serve, type Servers } from "../src/http/server.ts";
22 import { promote } from "./helpers/members.ts";
2223
2324 describe("the manifest: what you can do here, as who you are", () => {
2425 let dataDir: string;
@@ -50,6 +51,7 @@ describe("the manifest: what you can do here, as who you are", () => {
5051 if (!made.ok) throw new Error(made.error);
5152 users[name] = made.value;
5253 }
54 promote(app.db, ...Object.values(users));
5355 const alice = users["alice"]!;
5456 const org = createOrg(app.db, "acme", alice.id);
5557 if (!org.ok) throw new Error(org.error);
test/home-feed.test.ts +2
@@ -21,6 +21,7 @@ import { createToken } from "../src/core/tokens.ts";
2121 import { createUser, type User } from "../src/core/users.ts";
2222 import { readFeed } from "../src/feed/feed.ts";
2323 import { serve, type Servers } from "../src/http/server.ts";
24 import { promote } from "./helpers/members.ts";
2425
2526 describe("home is the feed", () => {
2627 let dataDir: string;
@@ -52,6 +53,7 @@ describe("home is the feed", () => {
5253 adminIfFirst: first,
5354 });
5455 if (!r.ok) throw new Error(r.error);
56 promote(app.db, r.value);
5557 return r.value;
5658 };
5759 await mk("keeper", true);
test/home-walk.test.ts +2
@@ -15,6 +15,7 @@ import { createSession } from "../src/core/sessions.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { share } from "../src/feed/share.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
18 import { promote } from "./helpers/members.ts";
1819
1920 let dataDir: string;
2021 let app: App;
@@ -42,6 +43,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app
4243 adminIfFirst: first,
4344 });
4445 if (!r.ok) throw new Error(r.error);
46 promote(app.db, r.value);
4547 return r.value;
4648 };
4749 const root = await mk("keeper", true);
test/live.test.ts +2
@@ -13,6 +13,7 @@ import { createSession } from "../src/core/sessions.ts";
1313 import { createUser, type User } from "../src/core/users.ts";
1414 import { liveClientJs } from "../src/web/live.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
16 import { promote } from "./helpers/members.ts";
1617
1718 async function readEvents(
1819 res: Response,
@@ -67,6 +68,7 @@ describe("the viewer's stream", () => {
6768 });
6869 if (!a.ok) throw new Error(a.error);
6970 alice = a.value;
71 promote(app.db, alice);
7072 const session = createSession(app.db, alice.id);
7173 cookie = `forge_session=${session.token}`;
7274 });
test/make-it-yours.test.ts +6
@@ -24,6 +24,7 @@ import { createToken } from "../src/core/tokens.ts";
2424 import { createUser, getUserById, type User } from "../src/core/users.ts";
2525 import { serve, type Servers } from "../src/http/server.ts";
2626 import { quietLogs } from "./helpers/globals.ts";
27 import { promote } from "./helpers/members.ts";
2728
2829 quietLogs();
2930
@@ -62,6 +63,7 @@ describe("make it yours", () => {
6263 adminIfFirst: first,
6364 });
6465 if (!r.ok) throw new Error(r.error);
66 promote(app.db, r.value);
6567 return r.value;
6668 };
6769 alice = await mk("alice", true);
@@ -258,6 +260,7 @@ describe("make it yours", () => {
258260 password: "a-good-passphrase",
259261 });
260262 if (!r.ok) throw new Error(r.error);
263 promote(app.db, r.value);
261264 return r.value;
262265 };
263266 // A private repository, and a public one whose app is private.
@@ -332,6 +335,7 @@ describe("make it yours", () => {
332335 password: "a-good-passphrase",
333336 });
334337 if (!frank.ok) throw new Error(frank.error);
338 promote(app.db, frank.value);
335339 const plan = await makeItYours(app, frank.value, garden, { dryRun: true });
336340 expect(plan.ok && plan.value.visibility).toBe("private");
337341 const made = await makeItYours(app, frank.value, garden, { dryRun: false });
@@ -365,6 +369,7 @@ describe("make it yours", () => {
365369 password: "a-good-passphrase",
366370 });
367371 if (!gina.ok) throw new Error(gina.error);
372 promote(app.db, gina.value);
368373 const token = createToken(app.db, gina.value.id, "rw", "rw").token;
369374 for (let n = 0; n < COPIES_PER_DAY; n++) {
370375 const src = await newApp(app, alice, { name: `bed-${n}`, visibility: "public" });
@@ -403,6 +408,7 @@ describe("make it yours", () => {
403408 password: "a-good-passphrase",
404409 });
405410 if (!r.ok) throw new Error(r.error);
411 promote(app.db, r.value);
406412 return r.value;
407413 };
408414 const source = async (name: string) => {
test/map.test.ts +2
@@ -32,6 +32,7 @@ import {
3232 wrapTitle,
3333 type Layout,
3434 } from "../src/web/pages/map.ts";
35 import { promote } from "./helpers/members.ts";
3536
3637 // ---- the layout, on paper ----
3738
@@ -197,6 +198,7 @@ describe("the map, per app and across the forge", () => {
197198 adminIfFirst: admin,
198199 });
199200 if (!made.ok) throw new Error(made.error);
201 promote(app.db, made.value);
200202 return made.value;
201203 };
202204 const repoOf = async (
test/mcp.test.ts +3 −1
@@ -25,6 +25,7 @@ import { createUser, type User } from "../src/core/users.ts";
2525 import { savePlacement } from "../src/core/placements.ts";
2626 import { serve, type Servers } from "../src/http/server.ts";
2727 import { limitChanges, limitValue } from "../src/settings/limits.ts";
28 import { promote } from "./helpers/members.ts";
2829
2930 describe("the forge over MCP", () => {
3031 let dataDir: string;
@@ -89,6 +90,7 @@ describe("the forge over MCP", () => {
8990 });
9091 if (!b.ok) throw new Error(b.error);
9192 bob = b.value;
93 promote(app.db, bob);
9294 rw = createToken(app.db, alice.id, "rw", "rw").token;
9395 ro = createToken(app.db, alice.id, "ro", "ro").token;
9496 bobRw = createToken(app.db, bob.id, "rw", "rw").token;
@@ -933,6 +935,7 @@ describe("the forge over MCP", () => {
933935 });
934936
935937 test("whoami_can: the caller's role and its row, for a guest, a member and an admin", async () => {
938 setMember(app.db, bob.id, false, alice.id, new Date());
936939 const guest = (await tool(bobRw, "whoami_can")).value;
937940 expect(guest.role).toBe("guest");
938941 expect(guest.can).toEqual({ read: true, comment: true, make_it_yours: false, run_crew: false, administer: false });
@@ -940,7 +943,6 @@ describe("the forge over MCP", () => {
940943 const member = (await tool(bobRw, "whoami_can")).value;
941944 expect(member.role).toBe("member");
942945 expect(member.can).toEqual({ read: true, comment: true, make_it_yours: true, run_crew: true, administer: false });
943 setMember(app.db, bob.id, false, alice.id, new Date());
944946 const admin = (await tool(ro, "whoami_can")).value;
945947 expect(admin.role).toBe("admin");
946948 expect(admin.can.administer).toBe(true);
test/migrate.test.ts +2
@@ -43,6 +43,7 @@ import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
4343 import { gitClientEnv } from "./helpers/git-client.ts";
4444 import { prefill } from "../src/web/pages/migrate-prefill.ts";
4545 import { quietLogs } from "./helpers/globals.ts";
46 import { promote } from "./helpers/members.ts";
4647
4748 quietLogs();
4849
@@ -112,6 +113,7 @@ describe("migrate", () => {
112113 adminIfFirst,
113114 });
114115 if (!made.ok) throw new Error(String(made.error));
116 promote(app.db, made.value);
115117 return made.value;
116118 };
117119
test/model-grant.test.ts +2
@@ -24,6 +24,7 @@ import {
2424 stopStandInCrew,
2525 type StandInCrew,
2626 } from "./helpers/crew-standin.ts";
27 import { promote } from "./helpers/members.ts";
2728
2829 describe("model budget: zero, an own subscription, a capped grant", () => {
2930 let standIn: StandInCrew;
@@ -54,6 +55,7 @@ describe("model budget: zero, an own subscription, a capped grant", () => {
5455 });
5556 if (!made.ok) throw new Error(made.error);
5657 const user = made.value;
58 promote(app.db, user);
5759 const r = await createRepo(app, user, { name: "yard", visibility: "private" });
5860 if (!r.ok) throw new Error(String(r.error));
5961 const seeded = await commitToBranch(app, user, r.value, {
test/parity.test.ts +2
@@ -18,6 +18,7 @@ import { getRepo } from "../src/core/repos.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
1919 import { gitClientEnv } from "./helpers/git-client.ts";
2020 import { proveAll } from "./helpers/verified.ts";
21 import { promote } from "./helpers/members.ts";
2122
2223 const gitEnv = gitClientEnv({
2324 GIT_AUTHOR_NAME: "A",
@@ -123,6 +124,7 @@ describe("parity: full control through actions and the API", () => {
123124 expect(res.status).toBe(201);
124125 }
125126 proveAll(app);
127 promote(app.db);
126128 aliceRw = await mint("alice", "rw");
127129 aliceAdmin = await mint("alice", "admin");
128130 aliceRo = await mint("alice", "ro");
test/proposal-token.test.ts +2
@@ -32,6 +32,7 @@ import { settleApp } from "./helpers/tui.ts";
3232 import { proposalLimiter } from "../src/http/proposal_gate.ts";
3333 import { serve, type Servers } from "../src/http/server.ts";
3434 import { quietLogs } from "./helpers/globals.ts";
35 import { promote } from "./helpers/members.ts";
3536
3637 quietLogs();
3738
@@ -165,6 +166,7 @@ describe("over HTTP", () => {
165166 people.push(made.value);
166167 }
167168 [alice, carol] = people as [User, User];
169 promote(app.db, carol);
168170 const made = await createRepo(app, alice, { name: "lib", visibility: "public" });
169171 if (!made.ok) throw new Error(String(made.error));
170172 lib = made.value;
test/quotas-running.test.ts +2
@@ -28,6 +28,7 @@ import { startCrew } from "../src/crew/dispatch.ts";
2828 import { serve, type Servers } from "../src/http/server.ts";
2929 import { readRunLog } from "../src/runs/log.ts";
3030 import { QUOTA_KEYS, getQuota, runningLimitSentence, setQuota } from "../src/settings/quotas.ts";
31 import { promote } from "./helpers/members.ts";
3132
3233 async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> {
3334 const deadline = Date.now() + ms;
@@ -52,6 +53,7 @@ async function twoUsers(app: App): Promise<{ alice: User; bob: User }> {
5253 if (!made.ok) throw new Error(made.error);
5354 users.push(made.value);
5455 }
56 promote(app.db, ...users);
5557 return { alice: users[0]!, bob: users[1]! };
5658 }
5759
test/quotas-storage.test.ts +6
@@ -27,6 +27,7 @@ import { setQuota } from "../src/settings/quotas.ts";
2727 import { serve, type Servers } from "../src/http/server.ts";
2828 import { gitClientEnv } from "./helpers/git-client.ts";
2929 import { proveAll } from "./helpers/verified.ts";
30 import { promote } from "./helpers/members.ts";
3031
3132 describe("storage quota", () => {
3233 let dataDir: string;
@@ -50,6 +51,7 @@ describe("storage quota", () => {
5051 carolId = carol.value.id;
5152 carolToken = createToken(app.db, carolId, "t", "admin").token;
5253 proveAll(app);
54 promote(app.db);
5355 expect(ada.value.isAdmin).toBe(true);
5456 expect(bob.value.isAdmin).toBe(false);
5557 adaId = ada.value.id;
@@ -240,6 +242,7 @@ describe("storage quota", () => {
240242 const dave = await signUp(app, { username: "dave", email: "[email protected]", password: "dave-passphrase" });
241243 if (!dave.ok) throw new Error("signup");
242244 proveAll(app);
245 promote(app.db);
243246 const daveToken = createToken(app.db, dave.value.id, "t", "admin").token;
244247 const state = newFakeState("forgejo", "someone", "tok");
245248 state.repos.push({
@@ -282,6 +285,7 @@ describe("storage quota", () => {
282285 const gina = await signUp(app, { username: "gina", email: "[email protected]", password: "gina-passphrase" });
283286 if (!gina.ok) throw new Error("signup");
284287 proveAll(app);
288 promote(app.db);
285289 const token = createToken(app.db, gina.value.id, "t", "admin").token;
286290 const state = newFakeState("forgejo", "someone", "tok");
287291 state.repos.push({
@@ -320,6 +324,7 @@ describe("storage quota", () => {
320324 const made = await signUp(app, { username, email: `${username}@f.dev`, password: `${username}-passphrase` });
321325 if (!made.ok) throw new Error("signup");
322326 proveAll(app);
327 promote(app.db);
323328 setQuota(app.db, "storage", 2, adaId);
324329 const token = createToken(app.db, made.value.id, "t", "admin").token;
325330 expect((await push(username, token, "data", 1_500_000)).code).toBe(0);
@@ -363,6 +368,7 @@ describe("storage quota", () => {
363368 const gwen = await signUp(app, { username: "gwen", email: "[email protected]", password: "gwen-passphrase" });
364369 if (!gwen.ok) throw new Error("signup");
365370 proveAll(app);
371 promote(app.db);
366372 const org = createOrg(app.db, "guild", gwen.value.id);
367373 if (!org.ok) throw new Error("org");
368374 setQuota(app.db, "storage", 2, adaId);
test/quotas.test.ts +6
@@ -25,6 +25,7 @@ import { createForgeApp } from "../src/tui/forge.ts";
2525 import { forkScreen, newRepoScreen } from "../src/tui/screens/newrepo.ts";
2626 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2727 import { settleApp } from "./helpers/tui.ts";
28 import { promote } from "./helpers/members.ts";
2829
2930 const NONE = { repositories: null, apps: null, storage: null, previews: null, runs: null, checks: null };
3031
@@ -48,6 +49,7 @@ describe("quotas", () => {
4849 adminIfFirst: username === "alice",
4950 });
5051 if (!made.ok) throw new Error(made.error);
52 promote(app.db, made.value);
5153 if (username === "alice") alice = made.value;
5254 else bob = made.value;
5355 }
@@ -254,6 +256,7 @@ describe("quotas", () => {
254256 });
255257 if (!made.ok) throw new Error(made.error);
256258 const carol = made.value;
259 promote(app.db, carol);
257260 const source = await newApp(app, alice, { name: "seed", visibility: "public" });
258261 if (!source.ok) throw new Error(String(source.error));
259262 await api(admin, "PATCH", { quotas: { apps: 1 } });
@@ -352,6 +355,7 @@ describe("the repositories limit", () => {
352355 if (!made.ok) throw new Error(made.error);
353356 users.push(made.value);
354357 }
358 promote(app.db, ...users);
355359 freshUser = users[1]!;
356360 boss = users[0]!;
357361 admin = createToken(app.db, users[0]!.id, "t", "admin").token;
@@ -454,6 +458,7 @@ describe("the repositories limit", () => {
454458 });
455459 if (!made.ok) throw new Error(made.error);
456460 const exact = made.value;
461 promote(app.db, exact);
457462 expect((await setLimit(2)).status).toBe(200);
458463 for (const name of ["x1", "x2"]) expect((await createRepo(app, exact, { name })).ok).toBe(true);
459464
@@ -503,6 +508,7 @@ describe("the repositories limit", () => {
503508 });
504509 if (!made.ok) throw new Error(made.error);
505510 const doors = made.value;
511 promote(app.db, doors);
506512 const source = await newApp(app, boss, { name: "doorsrc", visibility: "public" });
507513 if (!source.ok) throw new Error(String(source.error));
508514 expect((await setLimit(2)).status).toBe(200);
test/scale.test.ts +3
@@ -53,6 +53,7 @@ import {
5353 } from "../src/git/read.ts";
5454 import { quietLogs } from "./helpers/globals.ts";
5555 import type { User } from "../src/core/users.ts";
56 import { promote } from "./helpers/members.ts";
5657
5758 quietLogs();
5859
@@ -91,6 +92,7 @@ describe("the index carries the hot queries", () => {
9192 if (!a.ok || !b.ok) throw new Error("seed users");
9293 alice = a.value;
9394 bob = b.value;
95 promote(app.db, bob);
9496
9597 const made = await createRepo(app, alice, {
9698 name: "public-one",
@@ -422,6 +424,7 @@ describe("visibility is enforced in SQL, not after the fact", () => {
422424 if (!a.ok || !b.ok) throw new Error("seed");
423425 alice = a.value;
424426 bob = b.value;
427 promote(app.db, bob);
425428 const one = await createRepo(app, bob, {
426429 name: "open-book",
427430 visibility: "public",
test/share.test.ts +2
@@ -23,6 +23,7 @@ import { readFeed } from "../src/feed/feed.ts";
2323 import { share } from "../src/feed/share.ts";
2424 import { parseTargets } from "../src/http/feed.ts";
2525 import { serve, type Servers } from "../src/http/server.ts";
26 import { promote } from "./helpers/members.ts";
2627
2728 const HOUR = 3600_000;
2829 const DAILY_BUDGET = LIMITS["share.daily_max"].default;
@@ -61,6 +62,7 @@ describe("share", () => {
6162 adminIfFirst: first,
6263 });
6364 if (!r.ok) throw new Error(r.error);
65 promote(app.db, r.value);
6466 return r.value;
6567 };
6668 const root = await mk("root", true);
test/standing-every-process.test.ts +2
@@ -21,6 +21,7 @@ import { createUser, type User } from "../src/core/users.ts";
2121 import { holdOpen, sweepStanding } from "../src/http/caller.ts";
2222 import { serve, type Servers } from "../src/http/server.ts";
2323 import { forgeSshSession } from "../src/tui/ssh.ts";
24 import { promote } from "./helpers/members.ts";
2425
2526 const KEY_LINE =
2627 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo sam";
@@ -46,6 +47,7 @@ describe("a suspension holds in every process serving the forge", () => {
4647 adminIfFirst,
4748 });
4849 if (!made.ok) throw new Error(made.error);
50 promote(fresh.db, made.value);
4951 return made.value;
5052 };
5153 /** Resolves true once `p` settles, false if it has not within `ms`. */
test/standing-rule.test.ts +2
@@ -26,6 +26,7 @@ import { sweepStanding } from "../src/crew/standing.ts";
2626 import { serve, type Servers } from "../src/http/server.ts";
2727 import { askLinesFor } from "../src/web/studio/asks.ts";
2828 import { quietLogs } from "./helpers/globals.ts";
29 import { promote } from "./helpers/members.ts";
2930
3031 quietLogs();
3132
@@ -82,6 +83,7 @@ describe("a standing rule per repository", () => {
8283 });
8384 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
8485 alice = getUserByName(app.db, "alice")!;
86 promote(app.db, alice);
8587 const r = await createRepo(app, alice, { name: "shelf", visibility: "public" });
8688 if (!r.ok) throw new Error(String(r.error));
8789 repo = r.value;
test/studio-changes.test.ts +8
@@ -31,6 +31,7 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
3131 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3232 import { serve, type Servers } from "../src/http/server.ts";
3333 import { PNG, serving } from "./helpers/studio.ts";
34 import { promote } from "./helpers/members.ts";
3435
3536 describe("the studio, continued", () => {
3637 let dataDir: string;
@@ -66,6 +67,7 @@ describe("the studio, continued", () => {
6667 adminIfFirst: true,
6768 });
6869 if (!a.ok) throw new Error(a.error);
70 promote(app.db, a.value);
6971 alice = a.value;
7072 cookie = (
7173 (
@@ -549,6 +551,7 @@ describe("the studio, continued", () => {
549551 password: "a-good-passphrase",
550552 });
551553 if (!stranger.ok) throw new Error(stranger.error);
554 promote(app.db, stranger.value);
552555 const strangerCookie = (
553556 (
554557 await fetch(`${base}/signin`, {
@@ -772,6 +775,7 @@ describe("the studio, continued", () => {
772775 password: "a-good-passphrase",
773776 });
774777 if (!bob.ok) throw new Error(bob.error);
778 promote(app.db, bob.value);
775779 const hidden = await createRepo(app, bob.value, {
776780 name: "hidden",
777781 visibility: "private",
@@ -861,6 +865,7 @@ describe("the studio, continued", () => {
861865 password: "a-good-passphrase",
862866 });
863867 if (!carol.ok) throw new Error(carol.error);
868 promote(app.db, carol.value);
864869 const carolCookie = (
865870 (
866871 await fetch(`${base}/signin`, {
@@ -1080,6 +1085,7 @@ describe("the studio, continued", () => {
10801085 password: "a-good-passphrase",
10811086 });
10821087 if (!erin.ok) throw new Error(erin.error);
1088 promote(app.db, erin.value);
10831089 const erinCookie = (
10841090 (
10851091 await fetch(`${base}/signin`, {
@@ -1136,6 +1142,7 @@ describe("the studio, continued", () => {
11361142 password: "a-good-passphrase",
11371143 });
11381144 if (!owner.ok) throw new Error(owner.error);
1145 promote(app2.db, owner.value);
11391146 const plain = await createRepo(app2, owner.value, {
11401147 name: "just-code",
11411148 visibility: "public",
@@ -1182,6 +1189,7 @@ describe("the studio, continued", () => {
11821189 password: "a-good-passphrase",
11831190 });
11841191 if (!made.ok) throw new Error(made.error);
1192 promote(app2.db, made.value);
11851193 const pia = made.value;
11861194 const appOf = async (name: string) => {
11871195 const created = await createRepo(app2, pia, {
test/studio-door.test.ts +15
@@ -48,6 +48,7 @@ import { liveClientJs } from "../src/web/live.ts";
4848 import { createUser, getUserByName, type User } from "../src/core/users.ts";
4949 import { serve, type Servers } from "../src/http/server.ts";
5050 import { serving } from "./helpers/studio.ts";
51 import { promote } from "./helpers/members.ts";
5152
5253 describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => {
5354 let dataDir: string;
@@ -90,6 +91,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
9091 adminIfFirst: true,
9192 });
9293 if (!dan.ok) throw new Error(dan.error);
94 promote(app.db, dan.value);
9395 const cookie = await signIn("dan", "a-good-passphrase");
9496
9597 const madeApp = await createRepo(app, dan.value, {
@@ -149,6 +151,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
149151 password: "a-good-passphrase",
150152 });
151153 if (!gus.ok) throw new Error(gus.error);
154 promote(app.db, gus.value);
152155 const plainOnly = await createRepo(app, gus.value, {
153156 name: "notebook",
154157 visibility: "public",
@@ -173,6 +176,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
173176 password: "a-good-passphrase",
174177 });
175178 if (!eve.ok) throw new Error(eve.error);
179 promote(app.db, eve.value);
176180 const cookie = await signIn("eve", "a-good-passphrase");
177181
178182 const madeApp = await createRepo(app, eve.value, {
@@ -208,6 +212,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
208212 password: "a-good-passphrase",
209213 });
210214 if (!hal.ok) throw new Error(hal.error);
215 promote(app.db, hal.value);
211216 const ownerCookie = await signIn("hal", "a-good-passphrase");
212217 const made = await createRepo(app, hal.value, {
213218 name: "ledger",
@@ -231,6 +236,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
231236 password: "a-good-passphrase",
232237 });
233238 if (!ivy.ok) throw new Error(ivy.error);
239 promote(app.db, ivy.value);
234240 const strangerCookie = await signIn("ivy", "a-good-passphrase");
235241
236242 const visit = (cookie?: string) =>
@@ -257,6 +263,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
257263 password: "a-good-passphrase",
258264 });
259265 if (!fay.ok) throw new Error(fay.error);
266 promote(app.db, fay.value);
260267 const cookie = await signIn("fay", "a-good-passphrase");
261268
262269 const org = await createOrg(app, fay.value, "vega");
@@ -373,6 +380,7 @@ describe("every open pull's preview on the studio page", () => {
373380 adminIfFirst: true,
374381 });
375382 if (!a.ok) throw new Error(a.error);
383 promote(app.db, a.value);
376384 cookie = (
377385 (
378386 await fetch(`${base}/signin`, {
@@ -528,6 +536,7 @@ describe("the studio home keeps itself current", () => {
528536 adminIfFirst: true,
529537 });
530538 if (!a.ok) throw new Error(a.error);
539 promote(app.db, a.value);
531540 alice = a.value;
532541 cookie = (
533542 (
@@ -778,12 +787,14 @@ describe("the studio head says why the app is not up", () => {
778787 adminIfFirst: true,
779788 });
780789 if (!a.ok) throw new Error(a.error);
790 promote(app.db, a.value);
781791 const b = await createUser(app.db, { trusted: true,
782792 username: "bob",
783793 email: "[email protected]",
784794 password: "a-good-passphrase",
785795 });
786796 if (!b.ok) throw new Error(b.error);
797 promote(app.db, b.value);
787798 writer = await signIn("alice");
788799 reader = await signIn("bob");
789800 const made = await createRepo(app, a.value, {
@@ -1043,12 +1054,14 @@ describe("the studio app page's terminal", () => {
10431054 adminIfFirst: true,
10441055 });
10451056 if (!a.ok) throw new Error(a.error);
1057 promote(app.db, a.value);
10461058 const b = await createUser(app.db, { trusted: true,
10471059 username: "bob",
10481060 email: "[email protected]",
10491061 password: "a-good-passphrase",
10501062 });
10511063 if (!b.ok) throw new Error(b.error);
1064 promote(app.db, b.value);
10521065 writer = await signIn("alice");
10531066 reader = await signIn("bob");
10541067 const made = await createRepo(app, a.value, {
@@ -1198,12 +1211,14 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
11981211 adminIfFirst: true,
11991212 });
12001213 if (!a.ok) throw new Error(a.error);
1214 promote(app.db, a.value);
12011215 const c = await createUser(app.db, { trusted: true,
12021216 username: "carol",
12031217 email: "[email protected]",
12041218 password: "a-good-passphrase",
12051219 });
12061220 if (!c.ok) throw new Error(c.error);
1221 promote(app.db, c.value);
12071222 owner = await signIn("alice");
12081223 stranger = await signIn("carol");
12091224 const made = await createRepo(app, a.value, {
test/studio-search.test.ts +12
@@ -37,6 +37,7 @@ import {
3737 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3838 import { serve, type Servers } from "../src/http/server.ts";
3939 import { serving } from "./helpers/studio.ts";
40 import { promote } from "./helpers/members.ts";
4041
4142 describe("finding things in the studio: /search", () => {
4243 let dataDir: string;
@@ -61,6 +62,7 @@ describe("finding things in the studio: /search", () => {
6162 password: "a-good-passphrase",
6263 });
6364 if (!a.ok) throw new Error(a.error);
65 promote(app.db, a.value);
6466 alice = a.value;
6567 aliceCookie = (
6668 (
@@ -121,6 +123,7 @@ describe("finding things in the studio: /search", () => {
121123 password: "a-good-passphrase",
122124 });
123125 if (!bob.ok) throw new Error(bob.error);
126 promote(app.db, bob.value);
124127 const secret = await createRepo(app, bob.value, {
125128 name: "secret",
126129 visibility: "private",
@@ -266,6 +269,7 @@ describe("finding things in the studio: /search", () => {
266269 password: "a-good-passphrase",
267270 });
268271 if (!carol.ok) throw new Error(carol.error);
272 promote(app.db, carol.value);
269273 const carolCookie = (
270274 (
271275 await fetch(`${base}/signin`, {
@@ -362,6 +366,7 @@ describe("the studio home says what the crew cost you today", () => {
362366 adminIfFirst: true,
363367 });
364368 if (!a.ok) throw new Error(a.error);
369 promote(app.db, a.value);
365370 alice = a.value;
366371 const c = await createUser(app.db, { trusted: true,
367372 username: "carol",
@@ -369,6 +374,7 @@ describe("the studio home says what the crew cost you today", () => {
369374 password: "a-good-passphrase",
370375 });
371376 if (!c.ok) throw new Error(c.error);
377 promote(app.db, c.value);
372378 aliceCookie = await signIn("alice");
373379 carolCookie = await signIn("carol");
374380
@@ -508,6 +514,7 @@ describe("every studio page's headings form one outline", () => {
508514 adminIfFirst: true,
509515 });
510516 if (!a.ok) throw new Error(a.error);
517 promote(app.db, a.value);
511518 alice = a.value;
512519 cookie = (
513520 (
@@ -707,6 +714,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
707714 adminIfFirst: true,
708715 });
709716 if (!a.ok) throw new Error(a.error);
717 promote(app.db, a.value);
710718 cookie = (
711719 (
712720 await fetch(`${base}/signin`, {
@@ -1024,6 +1032,7 @@ describe("the studio names a person on the app's page, and the crew only for the
10241032 adminIfFirst: true,
10251033 });
10261034 if (!a.ok) throw new Error(a.error);
1035 promote(app.db, a.value);
10271036 alice = a.value;
10281037 ownerCookie = await signIn("ivy");
10291038
@@ -1192,6 +1201,7 @@ describe("the studio's empty lists", () => {
11921201 adminIfFirst: true,
11931202 });
11941203 if (!a.ok) throw new Error(a.error);
1204 promote(app.db, a.value);
11951205 alice = a.value;
11961206 cookie = (
11971207 (
@@ -1302,12 +1312,14 @@ describe("the studio can change what an app says it is (#230)", () => {
13021312 adminIfFirst: true,
13031313 });
13041314 if (!alice.ok) throw new Error(alice.error);
1315 promote(app.db, alice.value);
13051316 const carol = await createUser(app.db, { trusted: true,
13061317 username: "carol",
13071318 email: "[email protected]",
13081319 password: "a-good-passphrase",
13091320 });
13101321 if (!carol.ok) throw new Error(carol.error);
1322 promote(app.db, carol.value);
13111323 ownerCookie = await signIn("alice");
13121324 readerCookie = await signIn("carol");
13131325 const made = await createRepo(app, alice.value, {
test/suspended-hidden.test.ts +2
@@ -12,6 +12,7 @@ import { createSession } from "../src/core/sessions.ts";
1212 import { createToken } from "../src/core/tokens.ts";
1313 import { createUser, type User } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
15 import { promote } from "./helpers/members.ts";
1516
1617 // A suspended account's repositories are kept, and hidden: a stranger and
1718 // a signed-in reader find them in no list, page or route; the forge's owner
@@ -36,6 +37,7 @@ describe("a suspended account's repositories leave every list", () => {
3637 adminIfFirst,
3738 });
3839 if (!made.ok) throw new Error(made.error);
40 promote(app.db, made.value);
3941 return made.value;
4042 };
4143 const get = (path: string, cookie?: string, mode?: "dev") => {
test/thought-stream.test.ts +2
@@ -16,6 +16,7 @@ import { serve } from "../src/http/server.ts";
1616 import { createToken } from "../src/core/tokens.ts";
1717 import { createUser, type User } from "../src/core/users.ts";
1818 import { quietLogs } from "./helpers/globals.ts";
19 import { promote } from "./helpers/members.ts";
1920
2021 quietLogs();
2122 const DAY = 24 * 60 * 60 * 1000;
@@ -37,6 +38,7 @@ describe("thought stream", () => {
3738 password: "a-good-passphrase", adminIfFirst: first,
3839 });
3940 if (!u.ok) throw new Error(u.error);
41 promote(app.db, u.value);
4042 return u.value;
4143 };
4244 // The first account is the forge's admin, who reads everything: not the viewer.
test/tui-asked.test.ts +2
@@ -17,6 +17,7 @@ import { askedLines, askedScreen } from "../src/tui/screens/asked.ts";
1717 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1818 import { quietLogs } from "./helpers/globals.ts";
1919 import { settleApp } from "./helpers/tui.ts";
20 import { promote } from "./helpers/members.ts";
2021
2122 quietLogs();
2223
@@ -69,6 +70,7 @@ beforeAll(async () => {
6970 password: "a-good-passphrase", adminIfFirst: first,
7071 });
7172 if (!r.ok) throw new Error(r.error);
73 promote(app.db, r.value);
7274 return r.value;
7375 };
7476 carol = await mk("carol", true);
test/tui-onyou.test.ts +2
@@ -27,6 +27,7 @@ import { createForgeApp } from "../src/tui/forge.ts";
2727 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2828 import { quietLogs } from "./helpers/globals.ts";
2929 import { settleApp } from "./helpers/tui.ts";
30 import { promote } from "./helpers/members.ts";
3031
3132 quietLogs();
3233
@@ -86,6 +87,7 @@ describe("terminal On you: mutes and many in one move", () => {
8687 alice = a.value;
8788 bob = b.value;
8889 markVerified(forge.db, bob.id, new Date().toISOString());
90 promote(forge.db, bob);
8991 const mk = async (name: string) => {
9092 const r = await createRepo(forge, alice, { name, visibility: "public" });
9193 if (!r.ok) throw new Error("repo failed");
test/verify-email.test.ts +2
@@ -13,6 +13,7 @@ import { emailVerified } from "../src/core/verify.ts";
1313 import { missing } from "./helpers/tools.ts";
1414 import { sendMail, type Mail } from "../src/kernel/mail.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
16 import { promote } from "./helpers/members.ts";
1617
1718 // A fresh outsider signs up and, until the link in its email is followed,
1819 // can read but cannot make or cost anything.
@@ -126,6 +127,7 @@ describe("verified email", () => {
126127
127128 test("following the link verifies, and the account can create at once", async () => {
128129 const { user, token, link } = await join_("grace");
130 promote(app.db, user);
129131 expect(emailVerified(app.db, user.id)).toBe(false);
130132 const before = await api("/api/v1/repos", token, {
131133 method: "POST",
test/web-workflows.test.ts +4
@@ -41,6 +41,7 @@ import { clearViewCache } from "../src/view/cache.ts";
4141 import { quietLogs } from "./helpers/globals.ts";
4242 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
4343 import { proveAll } from "./helpers/verified.ts";
44 import { promote } from "./helpers/members.ts";
4445
4546 quietLogs();
4647
@@ -122,6 +123,7 @@ describe("fork at the origin, and propose a change through it", () => {
122123 redirect: "manual",
123124 });
124125 proveAll(app);
126 promote(app.db);
125127 return (response.headers.get("set-cookie") ?? "").split(";")[0]!;
126128 };
127129
@@ -137,6 +139,7 @@ describe("fork at the origin, and propose a change through it", () => {
137139 }),
138140 });
139141 proveAll(upApp);
142 promote(upApp.db);
140143 const made = await fetch(`${upServers.url}/api/v1/tokens`, {
141144 method: "POST",
142145 headers: { "content-type": "application/json" },
@@ -573,6 +576,7 @@ describe("verdicts, threads, and the gate they open", () => {
573576 });
574577 cookies[username] = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
575578 proveAll(app);
579 promote(app.db);
576580 const token = await fetch(`${base}/api/v1/tokens`, {
577581 method: "POST",
578582 headers: { "content-type": "application/json" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs