Public: anyone can read this; the crew can change it.
Roles: drop the guard_guests switch and test redirectProposal's guest refusal #2380
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2359-roles-drop-the-guard-guests-switch-and-t → main 6 commits fork 4 threads · 4 unresolved
README.md +1 −1
| @@ -747,7 +747,7 @@ the work repositories and nothing else. Two on one grant take turns, and | ||
| 747 | 747 | one the provider rests hands its runs to the other until the reset. |
| 748 | 748 | **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. |
| 749 | 749 | |
| 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). | |
| 751 | 751 | |
| 752 | 752 | **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. |
| 753 | 753 |
docs/ROLES.md +7 −3
| @@ -15,9 +15,13 @@ verify, a suspended one is refused, an agent and a stranger to a private | ||
| 15 | 15 | repository meet their own reasons first. A comment from a guest is still a |
| 16 | 16 | comment; it just starts no run. Agents and organizations are not guests. |
| 17 | 17 | |
| 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. | |
| 21 | 25 | |
| 22 | 26 | On `/settings/people` an admin promotes a guest to member, with a starter |
| 23 | 27 | model allowance (dollars a month, default none, through the grant of #1145), |
src/actions/spawn.ts +4
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | updateAgent, |
| 26 | 26 | } from "../core/agents.ts"; |
| 27 | 27 | import { createOrg, getOrg } from "../core/orgs.ts"; |
| 28 | import { setMember } from "../core/roles.ts"; | |
| 28 | 29 | import { |
| 29 | 30 | canRead, |
| 30 | 31 | createRepo, |
| @@ -308,6 +309,9 @@ async function copyInto( | ||
| 308 | 309 | return made.ok ? made.value : undefined; |
| 309 | 310 | })()); |
| 310 | 311 | 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()); | |
| 311 | 315 | |
| 312 | 316 | let org = getOrg(child.db, plan.org); |
| 313 | 317 | if (!org) { |
src/core/capacity.ts +1 −9
| @@ -21,8 +21,7 @@ export type CapacityKey = | ||
| 21 | 21 | | "checks.shards" |
| 22 | 22 | | "children.max" |
| 23 | 23 | | "cells.egress" |
| 24 | | "guard_new_accounts" | |
| 25 | | "guard_guests"; | |
| 24 | | "guard_new_accounts"; | |
| 26 | 25 | |
| 27 | 26 | interface Definition { |
| 28 | 27 | /** What the settings page and the API call it. */ |
| @@ -81,13 +80,6 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 81 | 80 | fallback: "off", |
| 82 | 81 | 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.", |
| 83 | 82 | }, |
| 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 | }, | |
| 91 | 83 | }; |
| 92 | 84 | |
| 93 | 85 | export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[]; |
src/core/roles.ts +13 −4
| @@ -8,7 +8,6 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { capacityValue } from "./capacity.ts"; | |
| 12 | 11 | |
| 13 | 12 | export const ROLES = ["guest", "member", "admin"] as const; |
| 14 | 13 | export type Role = (typeof ROLES)[number]; |
| @@ -52,18 +51,28 @@ export const NOT_A_MEMBER = "the owner has not made this account a member"; | ||
| 52 | 51 | /** |
| 53 | 52 | * Whether the table withholds `ability` from this account. Only a person |
| 54 | 53 | * 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. | |
| 57 | 55 | */ |
| 58 | 56 | export function lacks( |
| 59 | 57 | db: Database, |
| 60 | 58 | user: { id: string; isAdmin: boolean; kind: string }, |
| 61 | 59 | ability: Ability, |
| 62 | 60 | ): boolean { |
| 63 | if (user.kind !== "user" || capacityValue(db, "guard_guests") === "off") return false; | |
| 61 | if (user.kind !== "user") return false; | |
| 64 | 62 | return !can(roleOf(db, user), ability); |
| 65 | 63 | } |
| 66 | 64 | |
| 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 | */ | |
| 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 | ||
| 67 | 76 | export function roleOf(db: Database, user: { id: string; isAdmin: boolean }): Role { |
| 68 | 77 | if (user.isAdmin) return "admin"; |
| 69 | 78 | 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 { | ||
| 53 | 53 | type Run, |
| 54 | 54 | } from "../core/runs.ts"; |
| 55 | 55 | import { getUserById, type User } from "../core/users.ts"; |
| 56 | import { lacks } from "../core/roles.ts"; | |
| 56 | import { guestSql, lacks } from "../core/roles.ts"; | |
| 57 | 57 | import { createLog } from "../kernel/log.ts"; |
| 58 | 58 | import { issueOfBranch, type RunReason } from "./brief.ts"; |
| 59 | 59 | import { detectClaude } from "./claude.ts"; |
| @@ -216,7 +216,7 @@ export async function startCrew( | ||
| 216 | 216 | pump(app, ready, inFlight); |
| 217 | 217 | }, TICK_MS); |
| 218 | 218 | requeueInterrupted(app); |
| 219 | sweep(app); | |
| 219 | sweepAssigned(app); | |
| 220 | 220 | pump(app, ready, inFlight); |
| 221 | 221 | |
| 222 | 222 | const handle: CrewHandle = { |
| @@ -1074,9 +1074,18 @@ export function taskOf( | ||
| 1074 | 1074 | |
| 1075 | 1075 | /** |
| 1076 | 1076 | * 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. | |
| 1078 | 1082 | */ |
| 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)`; | |
| 1080 | 1089 | const rows = app.db |
| 1081 | 1090 | .query<{ id: string; repo_id: string }, []>( |
| 1082 | 1091 | `SELECT issue.id, issue.repo_id FROM issue |
| @@ -1086,6 +1095,9 @@ function sweep(app: App): void { | ||
| 1086 | 1095 | -- work landing, not a task waiting for a run. |
| 1087 | 1096 | AND NOT (issue.kind = 'pull' AND issue.author_id = issue.assignee_id) |
| 1088 | 1097 | 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)} | |
| 1089 | 1101 | ORDER BY issue.id LIMIT 100`, |
| 1090 | 1102 | ) |
| 1091 | 1103 | .all(); |
outdated
Medium: this skip happens after |
src/http/mcp.ts +1 −1
| @@ -2346,7 +2346,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2346 | 2346 | properties: { |
| 2347 | 2347 | key: { |
| 2348 | 2348 | 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"], | |
| 2350 | 2350 | }, |
| 2351 | 2351 | value: { type: "string", maxLength: 16 }, |
| 2352 | 2352 | dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." }, |
test/account-budget.test.ts +2
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | stopStandInCrew, |
| 30 | 30 | type StandInCrew, |
| 31 | 31 | } from "./helpers/crew-standin.ts"; |
| 32 | import { promote } from "./helpers/members.ts"; | |
| 32 | 33 | |
| 33 | 34 | describe("per-account spend ceilings", () => { |
| 34 | 35 | let standIn: StandInCrew; |
| @@ -52,6 +53,7 @@ describe("per-account spend ceilings", () => { | ||
| 52 | 53 | }); |
| 53 | 54 | if (!made.ok) throw new Error(made.error); |
| 54 | 55 | const user = made.value; |
| 56 | promote(app.db, user); | |
| 55 | 57 | const r = await createRepo(app, user, { name: "yard", visibility: "private" }); |
| 56 | 58 | if (!r.ok) throw new Error(String(r.error)); |
| 57 | 59 | 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"; | ||
| 13 | 13 | import { repoDir } from "../src/git/repos.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | 15 | import { proveAll } from "./helpers/verified.ts"; |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 16 | 17 | |
| 17 | 18 | describe("accounts: the confirm page for removing an organization too", () => { |
| 18 | 19 | let dataDir: string; |
| @@ -181,6 +182,7 @@ describe("accounts: the confirm page for removing an organization too", () => { | ||
| 181 | 182 | test("a person's own repository is named, and the lead no longer says only the account", async () => { |
| 182 | 183 | await mkUser("rh"); |
| 183 | 184 | proveAll(app); |
| 185 | promote(app.db, getUserByName(app.db, "rh")!); | |
| 184 | 186 | const made = await createRepo(app, getUserByName(app.db, "rh")!, { name: "notes" }); |
| 185 | 187 | if (!made.ok) throw new Error(made.error); |
| 186 | 188 | 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"; | ||
| 27 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | 28 | import { forgeSshSession, identityFor } from "../src/tui/ssh.ts"; |
| 29 | 29 | import { proveAll } from "./helpers/verified.ts"; |
| 30 | import { promote } from "./helpers/members.ts"; | |
| 30 | 31 | |
| 31 | 32 | const EDGE = "edge-secret-for-accounts"; |
| 32 | 33 | const KEY_LINE = |
| @@ -103,6 +104,8 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 103 | 104 | // This suite is about standing, not email: its outsiders are proven. |
| 104 | 105 | proveAll(app); |
| 105 | 106 | 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 ( |
||
| 106 | 109 | return { user, token: createToken(app.db, user.id, "o", "admin").token }; |
| 107 | 110 | }; |
| 108 | 111 | |
| @@ -622,6 +625,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 622 | 625 | password: "outsider-passphrase", |
| 623 | 626 | }); |
| 624 | 627 | if (!made.ok) throw new Error(made.error); |
| 628 | promote(app.db, made.value); | |
| 625 | 629 | const token = createToken(app.db, made.value.id, "t", "admin").token; |
| 626 | 630 | const create = () => |
| 627 | 631 | 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"; | ||
| 33 | 33 | import { getPull } from "../src/core/pulls.ts"; |
| 34 | 34 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 35 | 35 | import type { User } from "../src/core/users.ts"; |
| 36 | import { promote } from "./helpers/members.ts"; | |
| 36 | 37 | |
| 37 | 38 | /** Event kinds in chronological order (listEvents returns newest first). */ |
| 38 | 39 | const kinds = (app: App, repoId?: string) => |
| @@ -64,6 +65,7 @@ describe("actions own authorization and events", () => { | ||
| 64 | 65 | if (!a.ok || !b.ok) throw new Error("signup failed"); |
| 65 | 66 | alice = a.value; |
| 66 | 67 | bob = b.value; |
| 68 | promote(app.db, alice, bob); | |
Low: |
||
| 67 | 69 | const s = await createRepo(app, alice, { name: "secret" }); |
| 68 | 70 | const o = await createRepo(app, alice, { |
| 69 | 71 | name: "open", |
test/api-reader-marks.test.ts +2
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | import { READER } from "../src/crew/direct.ts"; |
| 33 | 33 | import { serve, type Servers } from "../src/http/server.ts"; |
| 34 | 34 | import { quietLogs } from "./helpers/globals.ts"; |
| 35 | import { promote } from "./helpers/members.ts"; | |
| 35 | 36 | |
| 36 | 37 | quietLogs(); |
| 37 | 38 | |
| @@ -90,6 +91,7 @@ beforeAll(async () => { | ||
| 90 | 91 | password: "a-good-passphrase", |
| 91 | 92 | }), |
| 92 | 93 | ); |
| 94 | promote(app.db, mallory); | |
| 93 | 95 | bot = ok(await createAgent(app, alice, { username: "bot" })); |
| 94 | 96 | repo = ok( |
| 95 | 97 | 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"; | ||
| 28 | 28 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 29 | 29 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; |
| 30 | 30 | import { quietLogs } from "./helpers/globals.ts"; |
| 31 | import { promote } from "./helpers/members.ts"; | |
| 31 | 32 | |
| 32 | 33 | quietLogs(); |
| 33 | 34 | |
| @@ -99,6 +100,7 @@ describe("a local writer approval lets an origin fork's pull run", () => { | ||
| 99 | 100 | people.push(made.value); |
| 100 | 101 | } |
| 101 | 102 | [alice, carol] = people as [User, User]; |
| 103 | promote(app.db, carol); | |
| 102 | 104 | const made = await createRepo(app, alice, { |
| 103 | 105 | name: "lib", |
| 104 | 106 | visibility: "public", |
test/asks-autonomous.test.ts +2
| @@ -15,6 +15,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts"; | ||
| 15 | 15 | import { BREAKER_NOTE } from "../src/crew/breaker.ts"; |
| 16 | 16 | import { askLinesFor } from "../src/web/studio/asks.ts"; |
| 17 | 17 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 18 | 19 | |
| 19 | 20 | quietLogs(); |
| 20 | 21 | |
| @@ -35,6 +36,7 @@ describe("autonomous repositories ask no one about the crew's own stalls", () => | ||
| 35 | 36 | }); |
| 36 | 37 | if (!made.ok) throw new Error("no alice"); |
| 37 | 38 | alice = getUserByName(app.db, "alice")!; |
| 39 | promote(app.db, alice); | |
| 38 | 40 | const r = await createRepo(app, alice, { name: "shelf", visibility: "public" }); |
| 39 | 41 | if (!r.ok) throw new Error(String(r.error)); |
| 40 | 42 | repo = r.value; |
test/asks.test.ts +2
| @@ -27,6 +27,7 @@ import { answerSetting, UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts"; | ||
| 27 | 27 | import { hunt } from "../src/crew/sentinel.ts"; |
| 28 | 28 | import { ASK_WORDS, decisionsFor } from "../src/web/studio/asks.ts"; |
| 29 | 29 | import { quietLogs } from "./helpers/globals.ts"; |
| 30 | import { promote } from "./helpers/members.ts"; | |
| 30 | 31 | |
| 31 | 32 | quietLogs(); |
| 32 | 33 | |
| @@ -401,6 +402,7 @@ describe("the asks page", () => { | ||
| 401 | 402 | const carol = await signUp("carol"); |
| 402 | 403 | const carolUser = getUserByName(app.db, "carol")!; |
| 403 | 404 | markVerified(app.db, carolUser.id, new Date().toISOString()); |
| 405 | promote(app.db, carolUser); | |
| 404 | 406 | const plot = await createRepo(app, carolUser, { name: "plot", visibility: "public" }); |
| 405 | 407 | if (!plot.ok) throw new Error(String(plot.error)); |
| 406 | 408 | away("cells.egress", "report"); |
test/checks.test.ts +2
| @@ -47,6 +47,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 47 | 47 | import type { Repo } from "../src/core/repos.ts"; |
| 48 | 48 | import { serve, type Servers } from "../src/http/server.ts"; |
| 49 | 49 | import { readRunLog } from "../src/runs/log.ts"; |
| 50 | import { promote } from "./helpers/members.ts"; | |
| 50 | 51 | |
| 51 | 52 | const checkJson = (run: string, extra = ""): string => |
| 52 | 53 | `{"check": {"run": ${JSON.stringify(run)}, "install": ""${extra}}}\n`; |
| @@ -351,6 +352,7 @@ describe("checks", () => { | ||
| 351 | 352 | }); |
| 352 | 353 | if (!b.ok) throw new Error(b.error); |
| 353 | 354 | bob = b.value; |
| 355 | promote(app.db, bob); | |
| 354 | 356 | const made = await createRepo(app, alice, { |
| 355 | 357 | name: "lib", |
| 356 | 358 | visibility: "public", |
test/commit-identity.test.ts +2
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | } from "../src/core/users.ts"; |
| 30 | 30 | import { repoDir } from "../src/git/repos.ts"; |
| 31 | 31 | import { serve, type Servers } from "../src/http/server.ts"; |
| 32 | import { promote } from "./helpers/members.ts"; | |
| 32 | 33 | |
| 33 | 34 | describe("commitIdentity", () => { |
| 34 | 35 | const person = { |
| @@ -148,6 +149,7 @@ describe("commits the forge makes for a person", () => { | ||
| 148 | 149 | const opted = updateProfile(app, c.value, { commitEmail: "account" }); |
| 149 | 150 | if (!opted.ok) throw new Error(opted.error); |
| 150 | 151 | carol = opted.value; |
| 152 | promote(app.db, carol); | |
| 151 | 153 | repo = await madeRepo(alice, "app"); |
| 152 | 154 | carols = await madeRepo(carol, "notes"); |
| 153 | 155 | dir = dirOf(repo); |
test/converge-settled.test.ts +2
| @@ -23,6 +23,7 @@ import { listRuns } from "../src/core/runs.ts"; | ||
| 23 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 24 | import { resolveOid } from "../src/git/merge.ts"; |
| 25 | 25 | import { repoDir } from "../src/git/repos.ts"; |
| 26 | import { promote } from "./helpers/members.ts"; | |
| 26 | 27 | |
| 27 | 28 | describe("a converge issue whose parent tip the fork already holds", () => { |
| 28 | 29 | let dataDir: string; |
| @@ -59,6 +60,7 @@ describe("a converge issue whose parent tip the fork already holds", () => { | ||
| 59 | 60 | if (!a.ok || !b.ok) throw new Error("users"); |
| 60 | 61 | alice = a.value; |
| 61 | 62 | bob = b.value; |
| 63 | promote(app.db, bob); | |
| 62 | 64 | const made = await createRepo(app, alice, { name: "game", visibility: "public" }); |
| 63 | 65 | if (!made.ok) throw new Error(String(made.error)); |
| 64 | 66 | parent = made.value; |
test/crew-brief-audience.test.ts +2
| @@ -14,6 +14,7 @@ import { addCrew } from "../src/core/agents.ts"; | ||
| 14 | 14 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 17 | 18 | |
| 18 | 19 | const BRIEF = "Q7BRIEFWORDS look after the parser"; |
| 19 | 20 | |
| @@ -54,6 +55,7 @@ describe("a crew agent's brief is the owner's configuration", () => { | ||
| 54 | 55 | olive = await person("olive", true); |
| 55 | 56 | rita = await person("rita"); |
| 56 | 57 | wren = await person("wren"); |
| 58 | promote(app.db, wren); | |
| 57 | 59 | const made = await createAgent(app, olive, { |
| 58 | 60 | username: "scout", |
| 59 | 61 | brief: BRIEF, |
test/crew-direction-autonomy.test.ts +2
| @@ -64,6 +64,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 64 | 64 | import { mapJson, marksFor } from "../src/web/pages/map.ts"; |
| 65 | 65 | import { listEvents } from "../src/core/events.ts"; |
| 66 | 66 | import { quietLogs } from "./helpers/globals.ts"; |
| 67 | import { promote } from "./helpers/members.ts"; | |
| 67 | 68 | |
| 68 | 69 | quietLogs(); |
| 69 | 70 | |
| @@ -154,6 +155,7 @@ beforeAll(async () => { | ||
| 154 | 155 | password: "a-good-passphrase", |
| 155 | 156 | }), |
| 156 | 157 | ); |
| 158 | promote(app.db, mallory); | |
| 157 | 159 | for (const name of ["mason", "atlas", "judge"]) |
| 158 | 160 | ok(await createAgent(app, alice, { username: name })); |
| 159 | 161 | mason = getUserByName(app.db, "mason")!; |
test/crew-direction.test.ts +3
| @@ -38,6 +38,7 @@ import { convergeFork } from "../src/crew/converge.ts"; | ||
| 38 | 38 | import { mayDirectCrew, readersAmong } from "../src/crew/direct.ts"; |
| 39 | 39 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 40 | 40 | import { serve, type Servers } from "../src/http/server.ts"; |
| 41 | import { promote } from "./helpers/members.ts"; | |
| 41 | 42 | |
| 42 | 43 | let dataDir: string; |
| 43 | 44 | let app: App; |
| @@ -115,6 +116,7 @@ fi | ||
| 115 | 116 | }); |
| 116 | 117 | if (!m.ok) throw new Error(m.error); |
| 117 | 118 | mallory = m.value; |
| 119 | promote(app.db, mallory); | |
| 118 | 120 | const b = await createAgent(app, alice, { username: "bot", brief: "Be brief." }); |
| 119 | 121 | if (!b.ok) throw new Error(String(b.error)); |
| 120 | 122 | bot = b.value; |
| @@ -445,6 +447,7 @@ describe("every other door that queues a run", () => { | ||
| 445 | 447 | }); |
| 446 | 448 | if (!u.ok) throw new Error(u.error); |
| 447 | 449 | const uma = u.value; |
| 450 | promote(app.db, uma); | |
| 448 | 451 | // Her own repository, with the bot on its crew: a writer in every way |
| 449 | 452 | // but a verified address. |
| 450 | 453 | const own = await createRepoRow(app.db, app.reposRoot, { |
test/crew-nocredential.test.ts +3
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | stopStandInCrew, |
| 21 | 21 | type StandInCrew, |
| 22 | 22 | } from "./helpers/crew-standin.ts"; |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 23 | 24 | |
| 24 | 25 | describe("the crew, with a stand-in Claude Code: no credential reaches the repository", () => { |
| 25 | 26 | let standIn: StandInCrew; |
| @@ -37,6 +38,7 @@ describe("the crew, with a stand-in Claude Code: no credential reaches the repos | ||
| 37 | 38 | |
| 38 | 39 | test("a non-admin owner's run fails with a person-worded line, before any agent starts", async () => { |
| 39 | 40 | app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(alice.id); |
| 41 | promote(app.db, alice); | |
| 40 | 42 | process.env.ANTHROPIC_API_KEY = "sk-forge-own-token"; |
| 41 | 43 | try { |
| 42 | 44 | const issue = openIssue(app, alice, repo, { |
| @@ -78,6 +80,7 @@ describe("the crew, with a stand-in Claude Code: no credential reaches the repos | ||
| 78 | 80 | }); |
| 79 | 81 | if (!o.ok) throw new Error(o.error); |
| 80 | 82 | const otto = o.value; |
| 83 | promote(app.db, otto); | |
| 81 | 84 | const made = await createRepo(app, otto, { |
| 82 | 85 | name: "yard", |
| 83 | 86 | visibility: "private", |
test/crew-parks.test.ts +2
| @@ -70,6 +70,7 @@ import { | ||
| 70 | 70 | parkReason, |
| 71 | 71 | struckOut, |
| 72 | 72 | } from "../src/crew/parks.ts"; |
| 73 | import { promote } from "./helpers/members.ts"; | |
| 73 | 74 | |
| 74 | 75 | /** |
| 75 | 76 | * 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", () => { | ||
| 467 | 468 | }); |
| 468 | 469 | if (!b.ok) throw new Error(b.error); |
| 469 | 470 | bob = b.value; |
| 471 | promote(app.db, bob); | |
| 470 | 472 | for (const name of ["alice", "bob"]) cookies[name] = await signIn(name); |
| 471 | 473 | }, 30_000); |
| 472 | 474 |
test/cross-repo-pull.test.ts +2
| @@ -6,6 +6,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 8 | 8 | import { proveAll } from "./helpers/verified.ts"; |
| 9 | import { promote } from "./helpers/members.ts"; | |
| 9 | 10 | |
| 10 | 11 | const gitEnv = gitClientEnv({ |
| 11 | 12 | GIT_AUTHOR_NAME: "X", |
| @@ -79,6 +80,7 @@ describe("cross-repo pull requests (fork → upstream)", () => { | ||
| 79 | 80 | ({ token: aliceToken, cookie: aliceCookie } = await mkUser("alice")); |
| 80 | 81 | ({ token: bobToken, cookie: bobCookie } = await mkUser("bob")); |
| 81 | 82 | proveAll(app); |
| 83 | promote(app.db); | |
| 82 | 84 | |
| 83 | 85 | // Alice's upstream repo with a seed commit. |
| 84 | 86 | await fetch(`${base}/api/v1/repos`, { |
test/explore.test.ts +2
| @@ -20,6 +20,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 20 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | 21 | import { quietLogs } from "./helpers/globals.ts"; |
| 22 | 22 | import { proveAll } from "./helpers/verified.ts"; |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 23 | 24 | |
| 24 | 25 | quietLogs(); |
| 25 | 26 | |
| @@ -66,6 +67,7 @@ describe("explore", () => { | ||
| 66 | 67 | }); |
| 67 | 68 | if (!made.ok) throw new Error(made.error); |
| 68 | 69 | proveAll(app); |
| 70 | promote(app.db, made.value); | |
| 69 | 71 | return made.value; |
| 70 | 72 | }; |
| 71 | 73 | const appOf = async ( |
test/feed-chips.test.ts +2
| @@ -19,6 +19,7 @@ import { chipHides } from "../src/feed/chips.ts"; | ||
| 19 | 19 | import type { FeedKind } from "../src/feed/items.ts"; |
| 20 | 20 | import { readFeed } from "../src/feed/feed.ts"; |
| 21 | 21 | import { feedJs } from "../src/web/studio/feed.ts"; |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 22 | 23 | |
| 23 | 24 | const ALL: FeedKind[] = [ |
| 24 | 25 | "run", "ask", "pull", "decision", "map", "cost", "tell", "reply", "look", "milestone", "choose", "release", |
| @@ -79,6 +80,7 @@ describe("the chips of a forge", () => { | ||
| 79 | 80 | adminIfFirst: first, |
| 80 | 81 | }); |
| 81 | 82 | if (!r.ok) throw new Error(r.error); |
| 83 | promote(app.db, r.value); | |
| 82 | 84 | return r.value; |
| 83 | 85 | }; |
| 84 | 86 | await mk("root", true); |
test/feed-choose.test.ts +2
| @@ -14,6 +14,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 15 | import { readFeed } from "../src/feed/feed.ts"; |
| 16 | 16 | import { chooseCandidates } from "../src/feed/choose.ts"; |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 17 | 18 | |
| 18 | 19 | describe("the feed's choose item", () => { |
| 19 | 20 | let dataDir: string; |
| @@ -36,6 +37,7 @@ describe("the feed's choose item", () => { | ||
| 36 | 37 | adminIfFirst: first, |
| 37 | 38 | }); |
| 38 | 39 | if (!r.ok) throw new Error(r.error); |
| 40 | promote(app.db, r.value); | |
| 39 | 41 | return r.value; |
| 40 | 42 | }; |
| 41 | 43 | await mk("root", true); |
test/feed-page.test.ts +2
| @@ -15,6 +15,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | import { readFeed } from "../src/feed/feed.ts"; |
| 17 | 17 | import { feedCard } from "../src/web/studio/feed.ts"; |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 18 | 19 | |
| 19 | 20 | describe("the feed on the home page", () => { |
| 20 | 21 | let dataDir: string; |
| @@ -43,6 +44,7 @@ describe("the feed on the home page", () => { | ||
| 43 | 44 | adminIfFirst: first, |
| 44 | 45 | }); |
| 45 | 46 | if (!r.ok) throw new Error(r.error); |
| 47 | promote(app.db, r.value); | |
| 46 | 48 | return r.value; |
| 47 | 49 | }; |
| 48 | 50 | await mk("root", true); |
test/feed.test.ts +3
| @@ -19,6 +19,7 @@ import { Affinity, affinityFor } from "../src/feed/affinity.ts"; | ||
| 19 | 19 | import type { Candidate } from "../src/feed/items.ts"; |
| 20 | 20 | import { readFeed } from "../src/feed/feed.ts"; |
| 21 | 21 | import { rank } from "../src/feed/rank.ts"; |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 22 | 23 | |
| 23 | 24 | const NOW = Date.parse("2026-10-02T12:00:00Z"); |
| 24 | 25 | const HOUR = 3600_000; |
| @@ -141,6 +142,7 @@ describe("the feed of a forge", () => { | ||
| 141 | 142 | adminIfFirst: first, |
| 142 | 143 | }); |
| 143 | 144 | if (!r.ok) throw new Error(r.error); |
| 145 | promote(app.db, r.value); | |
| 144 | 146 | return r.value; |
| 145 | 147 | }; |
| 146 | 148 | // The first account is a site admin, who sees everything; neither of |
| @@ -293,6 +295,7 @@ describe("affinity", () => { | ||
| 293 | 295 | }); |
| 294 | 296 | if (!u.ok) throw new Error(u.error); |
| 295 | 297 | user = u.value; |
| 298 | promote(app.db, user); | |
| 296 | 299 | const r = await createRepo(app, user, { name: "mine", visibility: "public" }); |
| 297 | 300 | if (!r.ok) throw new Error("repo"); |
| 298 | 301 | repo = r.value; |
test/feeds.test.ts +2
| @@ -18,6 +18,7 @@ import { | ||
| 18 | 18 | import { createToken } from "../src/core/tokens.ts"; |
| 19 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | import { promote } from "./helpers/members.ts"; | |
| 21 | 22 | |
| 22 | 23 | describe("a repository's feed", () => { |
| 23 | 24 | let dataDir: string; |
| @@ -47,6 +48,7 @@ describe("a repository's feed", () => { | ||
| 47 | 48 | }); |
| 48 | 49 | if (!b.ok) throw new Error(b.error); |
| 49 | 50 | bob = b.value; |
| 51 | promote(app.db, bob); | |
| 50 | 52 | const made = await createRepo(app, alice, { |
| 51 | 53 | name: "game", |
| 52 | 54 | visibility: "public", |
test/fleet-graph.test.ts +2
| @@ -25,6 +25,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 25 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 26 | 26 | import { niceCeiling, windowSvg } from "../src/web/fleet-graph.ts"; |
| 27 | 27 | import { fleetGraphCsvText } from "../src/web/pages/fleet-graph.ts"; |
| 28 | import { promote } from "./helpers/members.ts"; | |
| 28 | 29 | |
| 29 | 30 | describe("the windows", () => { |
| 30 | 31 | const now = new Date("2026-10-02T14:37:12.345Z"); |
| @@ -147,6 +148,7 @@ describe("the graph", () => { | ||
| 147 | 148 | if (!a.ok || !b.ok) throw new Error("users"); |
| 148 | 149 | alice = a.value; |
| 149 | 150 | bob = b.value; |
| 151 | promote(app.db, alice, bob); | |
| 150 | 152 | for (const name of ["one", "two"]) { |
| 151 | 153 | const r = await createRepo(app, alice, { name, visibility: "private" }); |
| 152 | 154 | if (!r.ok) throw new Error("repo"); |
test/fleet-ops.test.ts +2
| @@ -33,6 +33,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 33 | 33 | import { createUser, type User } from "../src/core/users.ts"; |
| 34 | 34 | import { parseQuery } from "../src/fleet/selection.ts"; |
| 35 | 35 | import { serve, type Servers } from "../src/http/server.ts"; |
| 36 | import { promote } from "./helpers/members.ts"; | |
| 36 | 37 | |
| 37 | 38 | describe("fleet operations", () => { |
| 38 | 39 | let dataDir: string; |
| @@ -56,6 +57,7 @@ describe("fleet operations", () => { | ||
| 56 | 57 | adminIfFirst, |
| 57 | 58 | }); |
| 58 | 59 | if (!made.ok) throw new Error(made.error); |
| 60 | promote(app.db, made.value); | |
| 59 | 61 | return made.value; |
| 60 | 62 | }; |
| 61 | 63 | 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"; | ||
| 29 | 29 | import { feedJs } from "../src/web/studio/feed.ts"; |
| 30 | 30 | import { serve, type Servers } from "../src/http/server.ts"; |
| 31 | 31 | import { quietLogs } from "./helpers/globals.ts"; |
| 32 | import { promote } from "./helpers/members.ts"; | |
| 32 | 33 | |
| 33 | 34 | quietLogs(); |
| 34 | 35 | |
| @@ -98,6 +99,7 @@ describe("stalled steps on the home", () => { | ||
| 98 | 99 | adminIfFirst: first, |
| 99 | 100 | }); |
| 100 | 101 | if (!r.ok) throw new Error(r.error); |
| 102 | promote(app.db, r.value); | |
| 101 | 103 | return r.value; |
| 102 | 104 | }; |
| 103 | 105 | await mk("keeper", true); |
test/following.test.ts +6
| @@ -45,6 +45,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts"; | ||
| 45 | 45 | import { resolveOid } from "../src/git/merge.ts"; |
| 46 | 46 | import { repoDir } from "../src/git/repos.ts"; |
| 47 | 47 | import { serve, type Servers } from "../src/http/server.ts"; |
| 48 | import { promote } from "./helpers/members.ts"; | |
| 48 | 49 | |
| 49 | 50 | describe("a fork that follows its parent", () => { |
| 50 | 51 | let dataDir: string; |
| @@ -104,6 +105,7 @@ describe("a fork that follows its parent", () => { | ||
| 104 | 105 | }); |
| 105 | 106 | if (!b.ok) throw new Error(b.error); |
| 106 | 107 | bob = b.value; |
| 108 | promote(app.db, bob); | |
| 107 | 109 | bobToken = createToken(app.db, bob.id, "t", "rw").token; |
| 108 | 110 | const made = await createRepo(app, alice, { |
| 109 | 111 | name: "game", |
| @@ -432,6 +434,7 @@ describe("a fork that follows its parent", () => { | ||
| 432 | 434 | password: "a-good-passphrase", |
| 433 | 435 | }); |
| 434 | 436 | if (!c.ok) throw new Error(c.error); |
| 437 | promote(app.db, c.value); | |
| 435 | 438 | const forked = await forkRepo(app, c.value, fresh(parent), "game-carol"); |
| 436 | 439 | if (!forked.ok) throw new Error(String(forked.error)); |
| 437 | 440 | const carols = forked.value; |
| @@ -470,6 +473,7 @@ describe("a fork that follows its parent", () => { | ||
| 470 | 473 | password: "a-good-passphrase", |
| 471 | 474 | }); |
| 472 | 475 | if (!d.ok) throw new Error(d.error); |
| 476 | promote(app.db, d.value); | |
| 473 | 477 | const forked = await forkRepo(app, d.value, fresh(parent), "game-dan"); |
| 474 | 478 | if (!forked.ok) throw new Error(String(forked.error)); |
| 475 | 479 | const dans = forked.value; |
| @@ -699,6 +703,7 @@ describe("a fork that follows its parent", () => { | ||
| 699 | 703 | password: "a-good-passphrase", |
| 700 | 704 | }); |
| 701 | 705 | if (!f.ok) throw new Error(f.error); |
| 706 | promote(app.db, f.value); | |
| 702 | 707 | const grand = await forkRepo(app, f.value, fresh(fork), "game-fay"); |
| 703 | 708 | if (!grand.ok) throw new Error(String(grand.error)); |
| 704 | 709 | |
| @@ -921,6 +926,7 @@ describe("a landed pull moves the forks, like the push it amounts to", () => { | ||
| 921 | 926 | people.push(made.value); |
| 922 | 927 | } |
| 923 | 928 | [alice, bob, carol] = people as [User, User, User]; |
| 929 | promote(app.db, bob, carol); | |
| 924 | 930 | const made = await createRepo(app, alice, { |
| 925 | 931 | name: "engine", |
| 926 | 932 | visibility: "public", |
test/fork-counts.test.ts +2
| @@ -21,6 +21,7 @@ import { lineageOf } from "../src/core/following.ts"; | ||
| 21 | 21 | import { canRead, type Repo } from "../src/core/repos.ts"; |
| 22 | 22 | import { createUser, type User } from "../src/core/users.ts"; |
| 23 | 23 | import { serve, type Servers } from "../src/http/server.ts"; |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 24 | 25 | |
| 25 | 26 | describe("fork counts hold only the forks the viewer may read", () => { |
| 26 | 27 | let dataDir: string; |
| @@ -55,6 +56,7 @@ describe("fork counts hold only the forks the viewer may read", () => { | ||
| 55 | 56 | } |
| 56 | 57 | bob = users["bob"]!; |
| 57 | 58 | carol = users["carol"]!; |
| 59 | promote(app.db, bob, carol); | |
| 58 | 60 | const made = await createRepo(app, users["alice"]!, { |
| 59 | 61 | name: "lib", |
| 60 | 62 | visibility: "public", |
test/fork-flow.test.ts +3
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | type FakeState, |
| 16 | 16 | } from "./fake-origin.ts"; |
| 17 | 17 | import { proveAll } from "./helpers/verified.ts"; |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 18 | 19 | |
| 19 | 20 | /** |
| 20 | 21 | * The fork flow: grab a repository you cannot push to, branch, edit, and |
| @@ -165,6 +166,7 @@ describe("the fork flow", () => { | ||
| 165 | 166 | await mkAccount(originServers, UPSTREAM_OWNER); |
| 166 | 167 | originToken = await mkAccount(originServers, ORIGIN_LOGIN); |
| 167 | 168 | proveAll(originApp); |
| 169 | promote(originApp.db); | |
| 168 | 170 | |
| 169 | 171 | await seedUpstream("gears"); |
| 170 | 172 | await seedUpstream("widgets"); |
| @@ -232,6 +234,7 @@ describe("the fork flow", () => { | ||
| 232 | 234 | aliceToken = await mkAccount(servers, "alice"); |
| 233 | 235 | bobToken = await mkAccount(servers, "bob"); |
| 234 | 236 | proveAll(app); |
| 237 | promote(app.db); | |
| 235 | 238 | |
| 236 | 239 | forgejoOrigin = await connect(forgejo); |
| 237 | 240 | githubOrigin = await connect(github, "github"); |
test/fork-pull-approval.test.ts +2
| @@ -23,6 +23,7 @@ import { listRuns } from "../src/core/runs.ts"; | ||
| 23 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 24 | import { serve, type Servers } from "../src/http/server.ts"; |
| 25 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| 26 | import { promote } from "./helpers/members.ts"; | |
| 26 | 27 | |
| 27 | 28 | quietLogs(); |
| 28 | 29 | |
| @@ -127,6 +128,7 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 127 | 128 | people.push(made.value); |
| 128 | 129 | } |
| 129 | 130 | [alice, carol] = people as [User, User]; |
| 131 | promote(app.db, carol); | |
| 130 | 132 | const agent = await createAgent(app, alice, { username: "bot" }); |
| 131 | 133 | if (!agent.ok) throw new Error(String(agent.error)); |
| 132 | 134 | bot = agent.value.user; |
test/forks.test.ts +2
| @@ -19,6 +19,7 @@ import { commitFiles } from "../src/git/write.ts"; | ||
| 19 | 19 | import { resolveOid } from "../src/git/merge.ts"; |
| 20 | 20 | import { repoDir } from "../src/git/repos.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 22 | 23 | |
| 23 | 24 | /** |
| 24 | 25 | * The invariants, one line each (the old file proved them through git over |
| @@ -91,6 +92,7 @@ describe("forks", () => { | ||
| 91 | 92 | adminIfFirst: name === "alice", |
| 92 | 93 | }); |
| 93 | 94 | users[name] = must(made); |
| 95 | promote(app.db, users[name]!); | |
| 94 | 96 | cookies[name] = |
| 95 | 97 | `forge_session=${createSession(app.db, users[name]!.id).token}; forge_mode=dev`; |
| 96 | 98 | } |
test/front.test.ts +4
| @@ -43,6 +43,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 43 | 43 | import { startLooks } from "../src/looks/service.ts"; |
| 44 | 44 | import { quietLogs } from "./helpers/globals.ts"; |
| 45 | 45 | import { proveAll } from "./helpers/verified.ts"; |
| 46 | import { promote } from "./helpers/members.ts"; | |
| 46 | 47 | |
| 47 | 48 | quietLogs(); |
| 48 | 49 | |
| @@ -235,6 +236,7 @@ describe("the front page", () => { | ||
| 235 | 236 | adminIfFirst: true, |
| 236 | 237 | }); |
| 237 | 238 | if (!made.ok) throw new Error(made.error); |
| 239 | promote(app.db, made.value); | |
| 238 | 240 | return made.value; |
| 239 | 241 | }; |
| 240 | 242 | admin = await user("owner"); |
| @@ -637,6 +639,7 @@ describe("the front page", () => { | ||
| 637 | 639 | }); |
| 638 | 640 | if (!sus.ok) throw new Error(sus.error); |
| 639 | 641 | proveAll(app); |
| 642 | promote(app.db, sus.value); | |
| 640 | 643 | await make(sus.value, "sus-app"); |
| 641 | 644 | expect((await put(adminToken, { pinned: ["suspended-maker/sus-app"], hidden: [] })).status).toBe(200); |
| 642 | 645 | forgetFrontScores(app); |
| @@ -714,6 +717,7 @@ describe("the front page", () => { | ||
| 714 | 717 | const forked = await make(other, "weighed-forks"); |
| 715 | 718 | const forkerA = await createUser(app.db, { trusted: true, username: "forker-a", email: "[email protected]", password: "a-good-passphrase" }); |
| 716 | 719 | if (!forkerA.ok) throw new Error(forkerA.error); |
| 720 | promote(app.db, forkerA.value); | |
| 717 | 721 | const forkA = await createRepo(app, forkerA.value, { name: "my-weighed", visibility: "public" }); |
| 718 | 722 | if (!forkA.ok) throw new Error(String(forkA.error)); |
| 719 | 723 | 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"; | ||
| 23 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 24 | import { serve, type Servers } from "../src/http/server.ts"; |
| 25 | 25 | import { NEW_FOR_MS } from "../src/actions/accounts.ts"; |
| 26 | import { promote } from "./helpers/members.ts"; | |
| 26 | 27 | |
| 27 | 28 | describe("guard_new_accounts", () => { |
| 28 | 29 | let dataDir: string; |
| @@ -54,6 +55,7 @@ describe("guard_new_accounts", () => { | ||
| 54 | 55 | ...(opts.adminIfFirst === true ? { adminIfFirst: true as const } : {}), |
| 55 | 56 | }); |
| 56 | 57 | if (!made.ok) throw new Error(made.error); |
| 58 | promote(app.db, made.value); | |
| 57 | 59 | if (opts.backdate) |
| 58 | 60 | app.db |
| 59 | 61 | .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"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | 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"; | |
| 13 | 19 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 14 | 20 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 15 | 21 | import { newApp } from "../src/actions/apps.ts"; |
| @@ -24,7 +30,7 @@ import { cancelRun, createRun, listRuns } from "../src/core/runs.ts"; | ||
| 24 | 30 | import { addComment } from "../src/actions/issues.ts"; |
| 25 | 31 | import { requeueRepoRun } from "../src/actions/runs.ts"; |
| 26 | 32 | 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"; | |
| 28 | 34 | import { getIssueById } from "../src/core/issues.ts"; |
| 29 | 35 | import { convergeFork } from "../src/crew/converge.ts"; |
| 30 | 36 | import { convergeWords } from "../src/actions/follow.ts"; |
| @@ -95,8 +101,6 @@ describe("a guest cannot own, copy or run", () => { | ||
| 95 | 101 | helper = agent.value.user; |
| 96 | 102 | const crewed = addToCrew(app, bob, lab, "helper"); |
| 97 | 103 | 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); | |
| 100 | 104 | }, 60_000); |
| 101 | 105 | |
| 102 | 106 | afterAll(async () => { |
| @@ -183,11 +187,12 @@ describe("a guest cannot own, copy or run", () => { | ||
| 183 | 187 | expect(hidden.status).toBe(404); |
| 184 | 188 | }); |
| 185 | 189 | |
| 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 () => { | |
| 187 | 191 | 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); | |
| 191 | 196 | }); |
| 192 | 197 | |
| 193 | 198 | 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", () => { | ||
| 257 | 262 | .get(lab.id)!.n; |
| 258 | 263 | expect(assigned).toBe(0); |
| 259 | 264 | }); |
| 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 | }); | |
| 260 | 321 | }); |
test/hand-decider.test.ts +2
| @@ -31,6 +31,7 @@ import { createForgeApp } from "../src/tui/forge.ts"; | ||
| 31 | 31 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 32 | 32 | import { settleApp } from "./helpers/tui.ts"; |
| 33 | 33 | import { quietLogs } from "./helpers/globals.ts"; |
| 34 | import { promote } from "./helpers/members.ts"; | |
| 34 | 35 | |
| 35 | 36 | quietLogs(); |
| 36 | 37 | |
| @@ -101,6 +102,7 @@ describe("let the decider answer a hand-over", () => { | ||
| 101 | 102 | }); |
| 102 | 103 | cookie = `${(signin.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`; |
| 103 | 104 | alice = getUserByName(app.db, "alice")!; |
| 105 | promote(app.db, alice); | |
| 104 | 106 | const r = await createRepo(app, alice, { name: "shelf", visibility: "public" }); |
| 105 | 107 | if (!r.ok) throw new Error(String(r.error)); |
| 106 | 108 | 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 @@ | ||
| 6 | 6 | import { installCheapHash } from "./cheap-hash.ts"; |
| 7 | 7 | |
| 8 | 8 | 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"; | ||
| 19 | 19 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | import { getRepo } from "../src/core/repos.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 22 | 23 | |
| 23 | 24 | describe("the manifest: what you can do here, as who you are", () => { |
| 24 | 25 | let dataDir: string; |
| @@ -50,6 +51,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 50 | 51 | if (!made.ok) throw new Error(made.error); |
| 51 | 52 | users[name] = made.value; |
| 52 | 53 | } |
| 54 | promote(app.db, ...Object.values(users)); | |
| 53 | 55 | const alice = users["alice"]!; |
| 54 | 56 | const org = createOrg(app.db, "acme", alice.id); |
| 55 | 57 | 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"; | ||
| 21 | 21 | import { createUser, type User } from "../src/core/users.ts"; |
| 22 | 22 | import { readFeed } from "../src/feed/feed.ts"; |
| 23 | 23 | import { serve, type Servers } from "../src/http/server.ts"; |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 24 | 25 | |
| 25 | 26 | describe("home is the feed", () => { |
| 26 | 27 | let dataDir: string; |
| @@ -52,6 +53,7 @@ describe("home is the feed", () => { | ||
| 52 | 53 | adminIfFirst: first, |
| 53 | 54 | }); |
| 54 | 55 | if (!r.ok) throw new Error(r.error); |
| 56 | promote(app.db, r.value); | |
| 55 | 57 | return r.value; |
| 56 | 58 | }; |
| 57 | 59 | await mk("keeper", true); |
test/home-walk.test.ts +2
| @@ -15,6 +15,7 @@ import { createSession } from "../src/core/sessions.ts"; | ||
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { share } from "../src/feed/share.ts"; |
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 18 | 19 | |
| 19 | 20 | let dataDir: string; |
| 20 | 21 | let app: App; |
| @@ -42,6 +43,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app | ||
| 42 | 43 | adminIfFirst: first, |
| 43 | 44 | }); |
| 44 | 45 | if (!r.ok) throw new Error(r.error); |
| 46 | promote(app.db, r.value); | |
| 45 | 47 | return r.value; |
| 46 | 48 | }; |
| 47 | 49 | const root = await mk("keeper", true); |
test/live.test.ts +2
| @@ -13,6 +13,7 @@ import { createSession } from "../src/core/sessions.ts"; | ||
| 13 | 13 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | 14 | import { liveClientJs } from "../src/web/live.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 16 | 17 | |
| 17 | 18 | async function readEvents( |
| 18 | 19 | res: Response, |
| @@ -67,6 +68,7 @@ describe("the viewer's stream", () => { | ||
| 67 | 68 | }); |
| 68 | 69 | if (!a.ok) throw new Error(a.error); |
| 69 | 70 | alice = a.value; |
| 71 | promote(app.db, alice); | |
| 70 | 72 | const session = createSession(app.db, alice.id); |
| 71 | 73 | cookie = `forge_session=${session.token}`; |
| 72 | 74 | }); |
test/make-it-yours.test.ts +6
| @@ -24,6 +24,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 24 | 24 | import { createUser, getUserById, type User } from "../src/core/users.ts"; |
| 25 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 26 | 26 | import { quietLogs } from "./helpers/globals.ts"; |
| 27 | import { promote } from "./helpers/members.ts"; | |
| 27 | 28 | |
| 28 | 29 | quietLogs(); |
| 29 | 30 | |
| @@ -62,6 +63,7 @@ describe("make it yours", () => { | ||
| 62 | 63 | adminIfFirst: first, |
| 63 | 64 | }); |
| 64 | 65 | if (!r.ok) throw new Error(r.error); |
| 66 | promote(app.db, r.value); | |
| 65 | 67 | return r.value; |
| 66 | 68 | }; |
| 67 | 69 | alice = await mk("alice", true); |
| @@ -258,6 +260,7 @@ describe("make it yours", () => { | ||
| 258 | 260 | password: "a-good-passphrase", |
| 259 | 261 | }); |
| 260 | 262 | if (!r.ok) throw new Error(r.error); |
| 263 | promote(app.db, r.value); | |
| 261 | 264 | return r.value; |
| 262 | 265 | }; |
| 263 | 266 | // A private repository, and a public one whose app is private. |
| @@ -332,6 +335,7 @@ describe("make it yours", () => { | ||
| 332 | 335 | password: "a-good-passphrase", |
| 333 | 336 | }); |
| 334 | 337 | if (!frank.ok) throw new Error(frank.error); |
| 338 | promote(app.db, frank.value); | |
| 335 | 339 | const plan = await makeItYours(app, frank.value, garden, { dryRun: true }); |
| 336 | 340 | expect(plan.ok && plan.value.visibility).toBe("private"); |
| 337 | 341 | const made = await makeItYours(app, frank.value, garden, { dryRun: false }); |
| @@ -365,6 +369,7 @@ describe("make it yours", () => { | ||
| 365 | 369 | password: "a-good-passphrase", |
| 366 | 370 | }); |
| 367 | 371 | if (!gina.ok) throw new Error(gina.error); |
| 372 | promote(app.db, gina.value); | |
| 368 | 373 | const token = createToken(app.db, gina.value.id, "rw", "rw").token; |
| 369 | 374 | for (let n = 0; n < COPIES_PER_DAY; n++) { |
| 370 | 375 | const src = await newApp(app, alice, { name: `bed-${n}`, visibility: "public" }); |
| @@ -403,6 +408,7 @@ describe("make it yours", () => { | ||
| 403 | 408 | password: "a-good-passphrase", |
| 404 | 409 | }); |
| 405 | 410 | if (!r.ok) throw new Error(r.error); |
| 411 | promote(app.db, r.value); | |
| 406 | 412 | return r.value; |
| 407 | 413 | }; |
| 408 | 414 | const source = async (name: string) => { |
test/map.test.ts +2
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | wrapTitle, |
| 33 | 33 | type Layout, |
| 34 | 34 | } from "../src/web/pages/map.ts"; |
| 35 | import { promote } from "./helpers/members.ts"; | |
| 35 | 36 | |
| 36 | 37 | // ---- the layout, on paper ---- |
| 37 | 38 | |
| @@ -197,6 +198,7 @@ describe("the map, per app and across the forge", () => { | ||
| 197 | 198 | adminIfFirst: admin, |
| 198 | 199 | }); |
| 199 | 200 | if (!made.ok) throw new Error(made.error); |
| 201 | promote(app.db, made.value); | |
| 200 | 202 | return made.value; |
| 201 | 203 | }; |
| 202 | 204 | const repoOf = async ( |
test/mcp.test.ts +3 −1
| @@ -25,6 +25,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 25 | 25 | import { savePlacement } from "../src/core/placements.ts"; |
| 26 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 27 | import { limitChanges, limitValue } from "../src/settings/limits.ts"; |
| 28 | import { promote } from "./helpers/members.ts"; | |
| 28 | 29 | |
| 29 | 30 | describe("the forge over MCP", () => { |
| 30 | 31 | let dataDir: string; |
| @@ -89,6 +90,7 @@ describe("the forge over MCP", () => { | ||
| 89 | 90 | }); |
| 90 | 91 | if (!b.ok) throw new Error(b.error); |
| 91 | 92 | bob = b.value; |
| 93 | promote(app.db, bob); | |
| 92 | 94 | rw = createToken(app.db, alice.id, "rw", "rw").token; |
| 93 | 95 | ro = createToken(app.db, alice.id, "ro", "ro").token; |
| 94 | 96 | bobRw = createToken(app.db, bob.id, "rw", "rw").token; |
| @@ -933,6 +935,7 @@ describe("the forge over MCP", () => { | ||
| 933 | 935 | }); |
| 934 | 936 | |
| 935 | 937 | 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()); | |
| 936 | 939 | const guest = (await tool(bobRw, "whoami_can")).value; |
| 937 | 940 | expect(guest.role).toBe("guest"); |
| 938 | 941 | 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", () => { | ||
| 940 | 943 | const member = (await tool(bobRw, "whoami_can")).value; |
| 941 | 944 | expect(member.role).toBe("member"); |
| 942 | 945 | 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()); | |
| 944 | 946 | const admin = (await tool(ro, "whoami_can")).value; |
| 945 | 947 | expect(admin.role).toBe("admin"); |
| 946 | 948 | expect(admin.can.administer).toBe(true); |
test/migrate.test.ts +2
| @@ -43,6 +43,7 @@ import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | ||
| 43 | 43 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 44 | 44 | import { prefill } from "../src/web/pages/migrate-prefill.ts"; |
| 45 | 45 | import { quietLogs } from "./helpers/globals.ts"; |
| 46 | import { promote } from "./helpers/members.ts"; | |
| 46 | 47 | |
| 47 | 48 | quietLogs(); |
| 48 | 49 | |
| @@ -112,6 +113,7 @@ describe("migrate", () => { | ||
| 112 | 113 | adminIfFirst, |
| 113 | 114 | }); |
| 114 | 115 | if (!made.ok) throw new Error(String(made.error)); |
| 116 | promote(app.db, made.value); | |
| 115 | 117 | return made.value; |
| 116 | 118 | }; |
| 117 | 119 |
test/model-grant.test.ts +2
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | stopStandInCrew, |
| 25 | 25 | type StandInCrew, |
| 26 | 26 | } from "./helpers/crew-standin.ts"; |
| 27 | import { promote } from "./helpers/members.ts"; | |
| 27 | 28 | |
| 28 | 29 | describe("model budget: zero, an own subscription, a capped grant", () => { |
| 29 | 30 | let standIn: StandInCrew; |
| @@ -54,6 +55,7 @@ describe("model budget: zero, an own subscription, a capped grant", () => { | ||
| 54 | 55 | }); |
| 55 | 56 | if (!made.ok) throw new Error(made.error); |
| 56 | 57 | const user = made.value; |
| 58 | promote(app.db, user); | |
| 57 | 59 | const r = await createRepo(app, user, { name: "yard", visibility: "private" }); |
| 58 | 60 | if (!r.ok) throw new Error(String(r.error)); |
| 59 | 61 | const seeded = await commitToBranch(app, user, r.value, { |
test/parity.test.ts +2
| @@ -18,6 +18,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 18 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | 19 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 20 | 20 | import { proveAll } from "./helpers/verified.ts"; |
| 21 | import { promote } from "./helpers/members.ts"; | |
| 21 | 22 | |
| 22 | 23 | const gitEnv = gitClientEnv({ |
| 23 | 24 | GIT_AUTHOR_NAME: "A", |
| @@ -123,6 +124,7 @@ describe("parity: full control through actions and the API", () => { | ||
| 123 | 124 | expect(res.status).toBe(201); |
| 124 | 125 | } |
| 125 | 126 | proveAll(app); |
| 127 | promote(app.db); | |
| 126 | 128 | aliceRw = await mint("alice", "rw"); |
| 127 | 129 | aliceAdmin = await mint("alice", "admin"); |
| 128 | 130 | aliceRo = await mint("alice", "ro"); |
test/proposal-token.test.ts +2
| @@ -32,6 +32,7 @@ import { settleApp } from "./helpers/tui.ts"; | ||
| 32 | 32 | import { proposalLimiter } from "../src/http/proposal_gate.ts"; |
| 33 | 33 | import { serve, type Servers } from "../src/http/server.ts"; |
| 34 | 34 | import { quietLogs } from "./helpers/globals.ts"; |
| 35 | import { promote } from "./helpers/members.ts"; | |
| 35 | 36 | |
| 36 | 37 | quietLogs(); |
| 37 | 38 | |
| @@ -165,6 +166,7 @@ describe("over HTTP", () => { | ||
| 165 | 166 | people.push(made.value); |
| 166 | 167 | } |
| 167 | 168 | [alice, carol] = people as [User, User]; |
| 169 | promote(app.db, carol); | |
| 168 | 170 | const made = await createRepo(app, alice, { name: "lib", visibility: "public" }); |
| 169 | 171 | if (!made.ok) throw new Error(String(made.error)); |
| 170 | 172 | lib = made.value; |
test/quotas-running.test.ts +2
| @@ -28,6 +28,7 @@ import { startCrew } from "../src/crew/dispatch.ts"; | ||
| 28 | 28 | import { serve, type Servers } from "../src/http/server.ts"; |
| 29 | 29 | import { readRunLog } from "../src/runs/log.ts"; |
| 30 | 30 | import { QUOTA_KEYS, getQuota, runningLimitSentence, setQuota } from "../src/settings/quotas.ts"; |
| 31 | import { promote } from "./helpers/members.ts"; | |
| 31 | 32 | |
| 32 | 33 | async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> { |
| 33 | 34 | const deadline = Date.now() + ms; |
| @@ -52,6 +53,7 @@ async function twoUsers(app: App): Promise<{ alice: User; bob: User }> { | ||
| 52 | 53 | if (!made.ok) throw new Error(made.error); |
| 53 | 54 | users.push(made.value); |
| 54 | 55 | } |
| 56 | promote(app.db, ...users); | |
| 55 | 57 | return { alice: users[0]!, bob: users[1]! }; |
| 56 | 58 | } |
| 57 | 59 |
test/quotas-storage.test.ts +6
| @@ -27,6 +27,7 @@ import { setQuota } from "../src/settings/quotas.ts"; | ||
| 27 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | 28 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 29 | 29 | import { proveAll } from "./helpers/verified.ts"; |
| 30 | import { promote } from "./helpers/members.ts"; | |
| 30 | 31 | |
| 31 | 32 | describe("storage quota", () => { |
| 32 | 33 | let dataDir: string; |
| @@ -50,6 +51,7 @@ describe("storage quota", () => { | ||
| 50 | 51 | carolId = carol.value.id; |
| 51 | 52 | carolToken = createToken(app.db, carolId, "t", "admin").token; |
| 52 | 53 | proveAll(app); |
| 54 | promote(app.db); | |
| 53 | 55 | expect(ada.value.isAdmin).toBe(true); |
| 54 | 56 | expect(bob.value.isAdmin).toBe(false); |
| 55 | 57 | adaId = ada.value.id; |
| @@ -240,6 +242,7 @@ describe("storage quota", () => { | ||
| 240 | 242 | const dave = await signUp(app, { username: "dave", email: "[email protected]", password: "dave-passphrase" }); |
| 241 | 243 | if (!dave.ok) throw new Error("signup"); |
| 242 | 244 | proveAll(app); |
| 245 | promote(app.db); | |
| 243 | 246 | const daveToken = createToken(app.db, dave.value.id, "t", "admin").token; |
| 244 | 247 | const state = newFakeState("forgejo", "someone", "tok"); |
| 245 | 248 | state.repos.push({ |
| @@ -282,6 +285,7 @@ describe("storage quota", () => { | ||
| 282 | 285 | const gina = await signUp(app, { username: "gina", email: "[email protected]", password: "gina-passphrase" }); |
| 283 | 286 | if (!gina.ok) throw new Error("signup"); |
| 284 | 287 | proveAll(app); |
| 288 | promote(app.db); | |
| 285 | 289 | const token = createToken(app.db, gina.value.id, "t", "admin").token; |
| 286 | 290 | const state = newFakeState("forgejo", "someone", "tok"); |
| 287 | 291 | state.repos.push({ |
| @@ -320,6 +324,7 @@ describe("storage quota", () => { | ||
| 320 | 324 | const made = await signUp(app, { username, email: `${username}@f.dev`, password: `${username}-passphrase` }); |
| 321 | 325 | if (!made.ok) throw new Error("signup"); |
| 322 | 326 | proveAll(app); |
| 327 | promote(app.db); | |
| 323 | 328 | setQuota(app.db, "storage", 2, adaId); |
| 324 | 329 | const token = createToken(app.db, made.value.id, "t", "admin").token; |
| 325 | 330 | expect((await push(username, token, "data", 1_500_000)).code).toBe(0); |
| @@ -363,6 +368,7 @@ describe("storage quota", () => { | ||
| 363 | 368 | const gwen = await signUp(app, { username: "gwen", email: "[email protected]", password: "gwen-passphrase" }); |
| 364 | 369 | if (!gwen.ok) throw new Error("signup"); |
| 365 | 370 | proveAll(app); |
| 371 | promote(app.db); | |
| 366 | 372 | const org = createOrg(app.db, "guild", gwen.value.id); |
| 367 | 373 | if (!org.ok) throw new Error("org"); |
| 368 | 374 | setQuota(app.db, "storage", 2, adaId); |
test/quotas.test.ts +6
| @@ -25,6 +25,7 @@ import { createForgeApp } from "../src/tui/forge.ts"; | ||
| 25 | 25 | import { forkScreen, newRepoScreen } from "../src/tui/screens/newrepo.ts"; |
| 26 | 26 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 27 | 27 | import { settleApp } from "./helpers/tui.ts"; |
| 28 | import { promote } from "./helpers/members.ts"; | |
| 28 | 29 | |
| 29 | 30 | const NONE = { repositories: null, apps: null, storage: null, previews: null, runs: null, checks: null }; |
| 30 | 31 | |
| @@ -48,6 +49,7 @@ describe("quotas", () => { | ||
| 48 | 49 | adminIfFirst: username === "alice", |
| 49 | 50 | }); |
| 50 | 51 | if (!made.ok) throw new Error(made.error); |
| 52 | promote(app.db, made.value); | |
| 51 | 53 | if (username === "alice") alice = made.value; |
| 52 | 54 | else bob = made.value; |
| 53 | 55 | } |
| @@ -254,6 +256,7 @@ describe("quotas", () => { | ||
| 254 | 256 | }); |
| 255 | 257 | if (!made.ok) throw new Error(made.error); |
| 256 | 258 | const carol = made.value; |
| 259 | promote(app.db, carol); | |
| 257 | 260 | const source = await newApp(app, alice, { name: "seed", visibility: "public" }); |
| 258 | 261 | if (!source.ok) throw new Error(String(source.error)); |
| 259 | 262 | await api(admin, "PATCH", { quotas: { apps: 1 } }); |
| @@ -352,6 +355,7 @@ describe("the repositories limit", () => { | ||
| 352 | 355 | if (!made.ok) throw new Error(made.error); |
| 353 | 356 | users.push(made.value); |
| 354 | 357 | } |
| 358 | promote(app.db, ...users); | |
| 355 | 359 | freshUser = users[1]!; |
| 356 | 360 | boss = users[0]!; |
| 357 | 361 | admin = createToken(app.db, users[0]!.id, "t", "admin").token; |
| @@ -454,6 +458,7 @@ describe("the repositories limit", () => { | ||
| 454 | 458 | }); |
| 455 | 459 | if (!made.ok) throw new Error(made.error); |
| 456 | 460 | const exact = made.value; |
| 461 | promote(app.db, exact); | |
| 457 | 462 | expect((await setLimit(2)).status).toBe(200); |
| 458 | 463 | for (const name of ["x1", "x2"]) expect((await createRepo(app, exact, { name })).ok).toBe(true); |
| 459 | 464 | |
| @@ -503,6 +508,7 @@ describe("the repositories limit", () => { | ||
| 503 | 508 | }); |
| 504 | 509 | if (!made.ok) throw new Error(made.error); |
| 505 | 510 | const doors = made.value; |
| 511 | promote(app.db, doors); | |
| 506 | 512 | const source = await newApp(app, boss, { name: "doorsrc", visibility: "public" }); |
| 507 | 513 | if (!source.ok) throw new Error(String(source.error)); |
| 508 | 514 | expect((await setLimit(2)).status).toBe(200); |
test/scale.test.ts +3
| @@ -53,6 +53,7 @@ import { | ||
| 53 | 53 | } from "../src/git/read.ts"; |
| 54 | 54 | import { quietLogs } from "./helpers/globals.ts"; |
| 55 | 55 | import type { User } from "../src/core/users.ts"; |
| 56 | import { promote } from "./helpers/members.ts"; | |
| 56 | 57 | |
| 57 | 58 | quietLogs(); |
| 58 | 59 | |
| @@ -91,6 +92,7 @@ describe("the index carries the hot queries", () => { | ||
| 91 | 92 | if (!a.ok || !b.ok) throw new Error("seed users"); |
| 92 | 93 | alice = a.value; |
| 93 | 94 | bob = b.value; |
| 95 | promote(app.db, bob); | |
| 94 | 96 | |
| 95 | 97 | const made = await createRepo(app, alice, { |
| 96 | 98 | name: "public-one", |
| @@ -422,6 +424,7 @@ describe("visibility is enforced in SQL, not after the fact", () => { | ||
| 422 | 424 | if (!a.ok || !b.ok) throw new Error("seed"); |
| 423 | 425 | alice = a.value; |
| 424 | 426 | bob = b.value; |
| 427 | promote(app.db, bob); | |
| 425 | 428 | const one = await createRepo(app, bob, { |
| 426 | 429 | name: "open-book", |
| 427 | 430 | visibility: "public", |
test/standing-every-process.test.ts +2
| @@ -21,6 +21,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 21 | 21 | import { holdOpen, sweepStanding } from "../src/http/caller.ts"; |
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | 23 | import { forgeSshSession } from "../src/tui/ssh.ts"; |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 24 | 25 | |
| 25 | 26 | const KEY_LINE = |
| 26 | 27 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo sam"; |
| @@ -46,6 +47,7 @@ describe("a suspension holds in every process serving the forge", () => { | ||
| 46 | 47 | adminIfFirst, |
| 47 | 48 | }); |
| 48 | 49 | if (!made.ok) throw new Error(made.error); |
| 50 | promote(fresh.db, made.value); | |
| 49 | 51 | return made.value; |
| 50 | 52 | }; |
| 51 | 53 | /** 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"; | ||
| 26 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 27 | import { askLinesFor } from "../src/web/studio/asks.ts"; |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | import { promote } from "./helpers/members.ts"; | |
| 29 | 30 | |
| 30 | 31 | quietLogs(); |
| 31 | 32 | |
| @@ -82,6 +83,7 @@ describe("a standing rule per repository", () => { | ||
| 82 | 83 | }); |
| 83 | 84 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 84 | 85 | alice = getUserByName(app.db, "alice")!; |
| 86 | promote(app.db, alice); | |
| 85 | 87 | const r = await createRepo(app, alice, { name: "shelf", visibility: "public" }); |
| 86 | 88 | if (!r.ok) throw new Error(String(r.error)); |
| 87 | 89 | repo = r.value; |
test/studio-changes.test.ts +8
| @@ -31,6 +31,7 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | ||
| 31 | 31 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 32 | 32 | import { serve, type Servers } from "../src/http/server.ts"; |
| 33 | 33 | import { PNG, serving } from "./helpers/studio.ts"; |
| 34 | import { promote } from "./helpers/members.ts"; | |
| 34 | 35 | |
| 35 | 36 | describe("the studio, continued", () => { |
| 36 | 37 | let dataDir: string; |
| @@ -66,6 +67,7 @@ describe("the studio, continued", () => { | ||
| 66 | 67 | adminIfFirst: true, |
| 67 | 68 | }); |
| 68 | 69 | if (!a.ok) throw new Error(a.error); |
| 70 | promote(app.db, a.value); | |
| 69 | 71 | alice = a.value; |
| 70 | 72 | cookie = ( |
| 71 | 73 | ( |
| @@ -549,6 +551,7 @@ describe("the studio, continued", () => { | ||
| 549 | 551 | password: "a-good-passphrase", |
| 550 | 552 | }); |
| 551 | 553 | if (!stranger.ok) throw new Error(stranger.error); |
| 554 | promote(app.db, stranger.value); | |
| 552 | 555 | const strangerCookie = ( |
| 553 | 556 | ( |
| 554 | 557 | await fetch(`${base}/signin`, { |
| @@ -772,6 +775,7 @@ describe("the studio, continued", () => { | ||
| 772 | 775 | password: "a-good-passphrase", |
| 773 | 776 | }); |
| 774 | 777 | if (!bob.ok) throw new Error(bob.error); |
| 778 | promote(app.db, bob.value); | |
| 775 | 779 | const hidden = await createRepo(app, bob.value, { |
| 776 | 780 | name: "hidden", |
| 777 | 781 | visibility: "private", |
| @@ -861,6 +865,7 @@ describe("the studio, continued", () => { | ||
| 861 | 865 | password: "a-good-passphrase", |
| 862 | 866 | }); |
| 863 | 867 | if (!carol.ok) throw new Error(carol.error); |
| 868 | promote(app.db, carol.value); | |
| 864 | 869 | const carolCookie = ( |
| 865 | 870 | ( |
| 866 | 871 | await fetch(`${base}/signin`, { |
| @@ -1080,6 +1085,7 @@ describe("the studio, continued", () => { | ||
| 1080 | 1085 | password: "a-good-passphrase", |
| 1081 | 1086 | }); |
| 1082 | 1087 | if (!erin.ok) throw new Error(erin.error); |
| 1088 | promote(app.db, erin.value); | |
| 1083 | 1089 | const erinCookie = ( |
| 1084 | 1090 | ( |
| 1085 | 1091 | await fetch(`${base}/signin`, { |
| @@ -1136,6 +1142,7 @@ describe("the studio, continued", () => { | ||
| 1136 | 1142 | password: "a-good-passphrase", |
| 1137 | 1143 | }); |
| 1138 | 1144 | if (!owner.ok) throw new Error(owner.error); |
| 1145 | promote(app2.db, owner.value); | |
| 1139 | 1146 | const plain = await createRepo(app2, owner.value, { |
| 1140 | 1147 | name: "just-code", |
| 1141 | 1148 | visibility: "public", |
| @@ -1182,6 +1189,7 @@ describe("the studio, continued", () => { | ||
| 1182 | 1189 | password: "a-good-passphrase", |
| 1183 | 1190 | }); |
| 1184 | 1191 | if (!made.ok) throw new Error(made.error); |
| 1192 | promote(app2.db, made.value); | |
| 1185 | 1193 | const pia = made.value; |
| 1186 | 1194 | const appOf = async (name: string) => { |
| 1187 | 1195 | const created = await createRepo(app2, pia, { |
test/studio-door.test.ts +15
| @@ -48,6 +48,7 @@ import { liveClientJs } from "../src/web/live.ts"; | ||
| 48 | 48 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 49 | 49 | import { serve, type Servers } from "../src/http/server.ts"; |
| 50 | 50 | import { serving } from "./helpers/studio.ts"; |
| 51 | import { promote } from "./helpers/members.ts"; | |
| 51 | 52 | |
| 52 | 53 | describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => { |
| 53 | 54 | let dataDir: string; |
| @@ -90,6 +91,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 90 | 91 | adminIfFirst: true, |
| 91 | 92 | }); |
| 92 | 93 | if (!dan.ok) throw new Error(dan.error); |
| 94 | promote(app.db, dan.value); | |
| 93 | 95 | const cookie = await signIn("dan", "a-good-passphrase"); |
| 94 | 96 | |
| 95 | 97 | 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)" | ||
| 149 | 151 | password: "a-good-passphrase", |
| 150 | 152 | }); |
| 151 | 153 | if (!gus.ok) throw new Error(gus.error); |
| 154 | promote(app.db, gus.value); | |
| 152 | 155 | const plainOnly = await createRepo(app, gus.value, { |
| 153 | 156 | name: "notebook", |
| 154 | 157 | visibility: "public", |
| @@ -173,6 +176,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 173 | 176 | password: "a-good-passphrase", |
| 174 | 177 | }); |
| 175 | 178 | if (!eve.ok) throw new Error(eve.error); |
| 179 | promote(app.db, eve.value); | |
| 176 | 180 | const cookie = await signIn("eve", "a-good-passphrase"); |
| 177 | 181 | |
| 178 | 182 | 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)" | ||
| 208 | 212 | password: "a-good-passphrase", |
| 209 | 213 | }); |
| 210 | 214 | if (!hal.ok) throw new Error(hal.error); |
| 215 | promote(app.db, hal.value); | |
| 211 | 216 | const ownerCookie = await signIn("hal", "a-good-passphrase"); |
| 212 | 217 | const made = await createRepo(app, hal.value, { |
| 213 | 218 | name: "ledger", |
| @@ -231,6 +236,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 231 | 236 | password: "a-good-passphrase", |
| 232 | 237 | }); |
| 233 | 238 | if (!ivy.ok) throw new Error(ivy.error); |
| 239 | promote(app.db, ivy.value); | |
| 234 | 240 | const strangerCookie = await signIn("ivy", "a-good-passphrase"); |
| 235 | 241 | |
| 236 | 242 | const visit = (cookie?: string) => |
| @@ -257,6 +263,7 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 257 | 263 | password: "a-good-passphrase", |
| 258 | 264 | }); |
| 259 | 265 | if (!fay.ok) throw new Error(fay.error); |
| 266 | promote(app.db, fay.value); | |
| 260 | 267 | const cookie = await signIn("fay", "a-good-passphrase"); |
| 261 | 268 | |
| 262 | 269 | const org = await createOrg(app, fay.value, "vega"); |
| @@ -373,6 +380,7 @@ describe("every open pull's preview on the studio page", () => { | ||
| 373 | 380 | adminIfFirst: true, |
| 374 | 381 | }); |
| 375 | 382 | if (!a.ok) throw new Error(a.error); |
| 383 | promote(app.db, a.value); | |
| 376 | 384 | cookie = ( |
| 377 | 385 | ( |
| 378 | 386 | await fetch(`${base}/signin`, { |
| @@ -528,6 +536,7 @@ describe("the studio home keeps itself current", () => { | ||
| 528 | 536 | adminIfFirst: true, |
| 529 | 537 | }); |
| 530 | 538 | if (!a.ok) throw new Error(a.error); |
| 539 | promote(app.db, a.value); | |
| 531 | 540 | alice = a.value; |
| 532 | 541 | cookie = ( |
| 533 | 542 | ( |
| @@ -778,12 +787,14 @@ describe("the studio head says why the app is not up", () => { | ||
| 778 | 787 | adminIfFirst: true, |
| 779 | 788 | }); |
| 780 | 789 | if (!a.ok) throw new Error(a.error); |
| 790 | promote(app.db, a.value); | |
| 781 | 791 | const b = await createUser(app.db, { trusted: true, |
| 782 | 792 | username: "bob", |
| 783 | 793 | email: "[email protected]", |
| 784 | 794 | password: "a-good-passphrase", |
| 785 | 795 | }); |
| 786 | 796 | if (!b.ok) throw new Error(b.error); |
| 797 | promote(app.db, b.value); | |
| 787 | 798 | writer = await signIn("alice"); |
| 788 | 799 | reader = await signIn("bob"); |
| 789 | 800 | const made = await createRepo(app, a.value, { |
| @@ -1043,12 +1054,14 @@ describe("the studio app page's terminal", () => { | ||
| 1043 | 1054 | adminIfFirst: true, |
| 1044 | 1055 | }); |
| 1045 | 1056 | if (!a.ok) throw new Error(a.error); |
| 1057 | promote(app.db, a.value); | |
| 1046 | 1058 | const b = await createUser(app.db, { trusted: true, |
| 1047 | 1059 | username: "bob", |
| 1048 | 1060 | email: "[email protected]", |
| 1049 | 1061 | password: "a-good-passphrase", |
| 1050 | 1062 | }); |
| 1051 | 1063 | if (!b.ok) throw new Error(b.error); |
| 1064 | promote(app.db, b.value); | |
| 1052 | 1065 | writer = await signIn("alice"); |
| 1053 | 1066 | reader = await signIn("bob"); |
| 1054 | 1067 | const made = await createRepo(app, a.value, { |
| @@ -1198,12 +1211,14 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1198 | 1211 | adminIfFirst: true, |
| 1199 | 1212 | }); |
| 1200 | 1213 | if (!a.ok) throw new Error(a.error); |
| 1214 | promote(app.db, a.value); | |
| 1201 | 1215 | const c = await createUser(app.db, { trusted: true, |
| 1202 | 1216 | username: "carol", |
| 1203 | 1217 | email: "[email protected]", |
| 1204 | 1218 | password: "a-good-passphrase", |
| 1205 | 1219 | }); |
| 1206 | 1220 | if (!c.ok) throw new Error(c.error); |
| 1221 | promote(app.db, c.value); | |
| 1207 | 1222 | owner = await signIn("alice"); |
| 1208 | 1223 | stranger = await signIn("carol"); |
| 1209 | 1224 | const made = await createRepo(app, a.value, { |
test/studio-search.test.ts +12
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 38 | 38 | import { serve, type Servers } from "../src/http/server.ts"; |
| 39 | 39 | import { serving } from "./helpers/studio.ts"; |
| 40 | import { promote } from "./helpers/members.ts"; | |
| 40 | 41 | |
| 41 | 42 | describe("finding things in the studio: /search", () => { |
| 42 | 43 | let dataDir: string; |
| @@ -61,6 +62,7 @@ describe("finding things in the studio: /search", () => { | ||
| 61 | 62 | password: "a-good-passphrase", |
| 62 | 63 | }); |
| 63 | 64 | if (!a.ok) throw new Error(a.error); |
| 65 | promote(app.db, a.value); | |
| 64 | 66 | alice = a.value; |
| 65 | 67 | aliceCookie = ( |
| 66 | 68 | ( |
| @@ -121,6 +123,7 @@ describe("finding things in the studio: /search", () => { | ||
| 121 | 123 | password: "a-good-passphrase", |
| 122 | 124 | }); |
| 123 | 125 | if (!bob.ok) throw new Error(bob.error); |
| 126 | promote(app.db, bob.value); | |
| 124 | 127 | const secret = await createRepo(app, bob.value, { |
| 125 | 128 | name: "secret", |
| 126 | 129 | visibility: "private", |
| @@ -266,6 +269,7 @@ describe("finding things in the studio: /search", () => { | ||
| 266 | 269 | password: "a-good-passphrase", |
| 267 | 270 | }); |
| 268 | 271 | if (!carol.ok) throw new Error(carol.error); |
| 272 | promote(app.db, carol.value); | |
| 269 | 273 | const carolCookie = ( |
| 270 | 274 | ( |
| 271 | 275 | await fetch(`${base}/signin`, { |
| @@ -362,6 +366,7 @@ describe("the studio home says what the crew cost you today", () => { | ||
| 362 | 366 | adminIfFirst: true, |
| 363 | 367 | }); |
| 364 | 368 | if (!a.ok) throw new Error(a.error); |
| 369 | promote(app.db, a.value); | |
| 365 | 370 | alice = a.value; |
| 366 | 371 | const c = await createUser(app.db, { trusted: true, |
| 367 | 372 | username: "carol", |
| @@ -369,6 +374,7 @@ describe("the studio home says what the crew cost you today", () => { | ||
| 369 | 374 | password: "a-good-passphrase", |
| 370 | 375 | }); |
| 371 | 376 | if (!c.ok) throw new Error(c.error); |
| 377 | promote(app.db, c.value); | |
| 372 | 378 | aliceCookie = await signIn("alice"); |
| 373 | 379 | carolCookie = await signIn("carol"); |
| 374 | 380 | |
| @@ -508,6 +514,7 @@ describe("every studio page's headings form one outline", () => { | ||
| 508 | 514 | adminIfFirst: true, |
| 509 | 515 | }); |
| 510 | 516 | if (!a.ok) throw new Error(a.error); |
| 517 | promote(app.db, a.value); | |
| 511 | 518 | alice = a.value; |
| 512 | 519 | cookie = ( |
| 513 | 520 | ( |
| @@ -707,6 +714,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 707 | 714 | adminIfFirst: true, |
| 708 | 715 | }); |
| 709 | 716 | if (!a.ok) throw new Error(a.error); |
| 717 | promote(app.db, a.value); | |
| 710 | 718 | cookie = ( |
| 711 | 719 | ( |
| 712 | 720 | 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 | ||
| 1024 | 1032 | adminIfFirst: true, |
| 1025 | 1033 | }); |
| 1026 | 1034 | if (!a.ok) throw new Error(a.error); |
| 1035 | promote(app.db, a.value); | |
| 1027 | 1036 | alice = a.value; |
| 1028 | 1037 | ownerCookie = await signIn("ivy"); |
| 1029 | 1038 | |
| @@ -1192,6 +1201,7 @@ describe("the studio's empty lists", () => { | ||
| 1192 | 1201 | adminIfFirst: true, |
| 1193 | 1202 | }); |
| 1194 | 1203 | if (!a.ok) throw new Error(a.error); |
| 1204 | promote(app.db, a.value); | |
| 1195 | 1205 | alice = a.value; |
| 1196 | 1206 | cookie = ( |
| 1197 | 1207 | ( |
| @@ -1302,12 +1312,14 @@ describe("the studio can change what an app says it is (#230)", () => { | ||
| 1302 | 1312 | adminIfFirst: true, |
| 1303 | 1313 | }); |
| 1304 | 1314 | if (!alice.ok) throw new Error(alice.error); |
| 1315 | promote(app.db, alice.value); | |
| 1305 | 1316 | const carol = await createUser(app.db, { trusted: true, |
| 1306 | 1317 | username: "carol", |
| 1307 | 1318 | email: "[email protected]", |
| 1308 | 1319 | password: "a-good-passphrase", |
| 1309 | 1320 | }); |
| 1310 | 1321 | if (!carol.ok) throw new Error(carol.error); |
| 1322 | promote(app.db, carol.value); | |
| 1311 | 1323 | ownerCookie = await signIn("alice"); |
| 1312 | 1324 | readerCookie = await signIn("carol"); |
| 1313 | 1325 | const made = await createRepo(app, alice.value, { |
test/thought-stream.test.ts +2
| @@ -16,6 +16,7 @@ import { serve } from "../src/http/server.ts"; | ||
| 16 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 17 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| 18 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 19 | import { promote } from "./helpers/members.ts"; | |
| 19 | 20 | |
| 20 | 21 | quietLogs(); |
| 21 | 22 | const DAY = 24 * 60 * 60 * 1000; |
| @@ -37,6 +38,7 @@ describe("thought stream", () => { | ||
| 37 | 38 | password: "a-good-passphrase", adminIfFirst: first, |
| 38 | 39 | }); |
| 39 | 40 | if (!u.ok) throw new Error(u.error); |
| 41 | promote(app.db, u.value); | |
| 40 | 42 | return u.value; |
| 41 | 43 | }; |
| 42 | 44 | // 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"; | ||
| 17 | 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 18 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 19 | 19 | import { settleApp } from "./helpers/tui.ts"; |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 20 | 21 | |
| 21 | 22 | quietLogs(); |
| 22 | 23 | |
| @@ -69,6 +70,7 @@ beforeAll(async () => { | ||
| 69 | 70 | password: "a-good-passphrase", adminIfFirst: first, |
| 70 | 71 | }); |
| 71 | 72 | if (!r.ok) throw new Error(r.error); |
| 73 | promote(app.db, r.value); | |
| 72 | 74 | return r.value; |
| 73 | 75 | }; |
| 74 | 76 | carol = await mk("carol", true); |
test/tui-onyou.test.ts +2
| @@ -27,6 +27,7 @@ import { createForgeApp } from "../src/tui/forge.ts"; | ||
| 27 | 27 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | 29 | import { settleApp } from "./helpers/tui.ts"; |
| 30 | import { promote } from "./helpers/members.ts"; | |
| 30 | 31 | |
| 31 | 32 | quietLogs(); |
| 32 | 33 | |
| @@ -86,6 +87,7 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 86 | 87 | alice = a.value; |
| 87 | 88 | bob = b.value; |
| 88 | 89 | markVerified(forge.db, bob.id, new Date().toISOString()); |
| 90 | promote(forge.db, bob); | |
| 89 | 91 | const mk = async (name: string) => { |
| 90 | 92 | const r = await createRepo(forge, alice, { name, visibility: "public" }); |
| 91 | 93 | 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"; | ||
| 13 | 13 | import { missing } from "./helpers/tools.ts"; |
| 14 | 14 | import { sendMail, type Mail } from "../src/kernel/mail.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 16 | 17 | |
| 17 | 18 | // A fresh outsider signs up and, until the link in its email is followed, |
| 18 | 19 | // can read but cannot make or cost anything. |
| @@ -126,6 +127,7 @@ describe("verified email", () => { | ||
| 126 | 127 | |
| 127 | 128 | test("following the link verifies, and the account can create at once", async () => { |
| 128 | 129 | const { user, token, link } = await join_("grace"); |
| 130 | promote(app.db, user); | |
| 129 | 131 | expect(emailVerified(app.db, user.id)).toBe(false); |
| 130 | 132 | const before = await api("/api/v1/repos", token, { |
| 131 | 133 | method: "POST", |
test/web-workflows.test.ts +4
| @@ -41,6 +41,7 @@ import { clearViewCache } from "../src/view/cache.ts"; | ||
| 41 | 41 | import { quietLogs } from "./helpers/globals.ts"; |
| 42 | 42 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 43 | 43 | import { proveAll } from "./helpers/verified.ts"; |
| 44 | import { promote } from "./helpers/members.ts"; | |
| 44 | 45 | |
| 45 | 46 | quietLogs(); |
| 46 | 47 | |
| @@ -122,6 +123,7 @@ describe("fork at the origin, and propose a change through it", () => { | ||
| 122 | 123 | redirect: "manual", |
| 123 | 124 | }); |
| 124 | 125 | proveAll(app); |
| 126 | promote(app.db); | |
| 125 | 127 | return (response.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 126 | 128 | }; |
| 127 | 129 | |
| @@ -137,6 +139,7 @@ describe("fork at the origin, and propose a change through it", () => { | ||
| 137 | 139 | }), |
| 138 | 140 | }); |
| 139 | 141 | proveAll(upApp); |
| 142 | promote(upApp.db); | |
| 140 | 143 | const made = await fetch(`${upServers.url}/api/v1/tokens`, { |
| 141 | 144 | method: "POST", |
| 142 | 145 | headers: { "content-type": "application/json" }, |
| @@ -573,6 +576,7 @@ describe("verdicts, threads, and the gate they open", () => { | ||
| 573 | 576 | }); |
| 574 | 577 | cookies[username] = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 575 | 578 | proveAll(app); |
| 579 | promote(app.db); | |
| 576 | 580 | const token = await fetch(`${base}/api/v1/tokens`, { |
| 577 | 581 | method: "POST", |
| 578 | 582 | headers: { "content-type": "application/json" }, |
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.