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

Roles, part 2: a guest cannot own a repository or start a crew run, and every older refusal keeps its own reason #2333

Merged opened this

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

Merged

scout/forge:scout/2302-roles-part-2-a-guest-cannot-own-a-reposi → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/2302-roles-part-2-a-guest-cannot-own-a-reposi was merged into main as fbe3050.

Review

Sign in to review this change.
27 files changed +402 −15
README.md +1 −1
@@ -747,7 +747,7 @@ the work repositories and nothing else. Two on one grant take turns, and
747747 one the provider rests hands its runs to the other until the reset.
748748 **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks.
749749
750 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. For now roles are shown and recorded; nothing is refused because of one. 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); `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).
751751
752752 **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent.
753753 **Sandbox.** An agent's shell is held to a policy — the network is the forge
docs/ROLES.md +13 −2
@@ -5,8 +5,19 @@ An account is a **guest**, a **member** or an **admin**. Admin is the
55 guest. `src/core/roles.ts` holds the one table below, and `test/roles.test.ts`
66 fails if this copy differs from it.
77
8 Part 1 (#2301) shows and records roles. Nothing is refused because of a role
9 yet: the table says what each role is meant to do once later parts enforce it.
8 Part 1 (#2301) shows and records roles. Part 2 (#2302) enforces two rows of
9 the table: a guest cannot create or fork a repository, make an app their own
10 (both are "make an app their own"), or start a crew run (assign an agent, queue
11 a run again, ask the crew to resolve a conflict). The refusal reads "the owner
12 has not made this account a member", on the page, in the API (`403 not_member`),
13 in MCP and in the terminal. It is asked last: an unverified account is told to
14 verify, a suspended one is refused, an agent and a stranger to a private
15 repository meet their own reasons first. A comment from a guest is still a
16 comment; it just starts no run. Agents and organizations are not guests.
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.
1021
1122 On `/settings/people` an admin promotes a guest to member, with a starter
1223 model allowance (dollars a month, default none, through the grant of #1145),
src/actions/follow.ts +3
@@ -5,6 +5,7 @@
55 * came from. The doors only word the answer.
66 */
77
8 import { NOT_A_MEMBER } from "../core/roles.ts";
89 import type { App } from "../app.ts";
910 import { DIRECTION_NOTE } from "../crew/direct.ts";
1011 import { convergeFork, type Converge } from "../crew/converge.ts";
@@ -104,6 +105,8 @@ export function convergeWords(
104105 };
105106 case "forbidden":
106107 return { tone: "danger", message: DIRECTION_NOTE };
108 case "not_member":
109 return { tone: "danger", message: `Not started: ${NOT_A_MEMBER}.` };
107110 case "ceiling":
108111 return {
109112 tone: "danger",
src/actions/issues.ts +5 −1
@@ -20,6 +20,7 @@
2020
2121 import { canSeeSealed, mayView, setSealed } from "../core/sealed.ts";
2222 import { requireVerified } from "./verify.ts";
23 import { requireAbility } from "./roles.ts";
2324 import type { App } from "../app.ts";
2425 import { crewRole, isCrew } from "../core/agents.ts";
2526 import { moveBlockersToChildren } from "../core/dependencies.ts";
@@ -294,7 +295,7 @@ export function assignIssue(
294295 variantGroup?: string,
295296 ): Result<
296297 Issue,
297 AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified"
298 AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | "not_member"
298299 > {
299300 const writer = requireWriter(app, actor, repo);
300301 if (!writer.ok) return writer;
@@ -312,6 +313,9 @@ export function assignIssue(
312313 // A run costs: only a proven account may start one.
313314 if (assignee.kind === "agent" && !requireVerified(app, writer.value).ok)
314315 return err("unverified");
316 // ...and only a member's allowance pays for it.
317 if (assignee.kind === "agent" && !requireAbility(app, writer.value, "run_crew").ok)
318 return err("not_member");
315319 }
316320 const updated = setAssignee(app.db, issue.id, assignee?.id ?? null);
317321 if (!updated) return ok(issue);
src/actions/make_it_yours.ts +10
@@ -41,6 +41,8 @@ import {
4141 } from "../core/repos.ts";
4242 import { isActive, type User } from "../core/users.ts";
4343 import { err, ok, type Result } from "../kernel/result.ts";
44 import { requireVerified } from "./verify.ts";
45 import { requireAbility } from "./roles.ts";
4446 import { requireAppRoom, appLimitWords } from "./app_limit.ts";
4547 import { enableApp } from "./apps.ts";
4648 import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts";
@@ -67,6 +69,8 @@ export type MakeItYoursError =
6769 | { readonly kind: "not_found" }
6870 | { readonly kind: "unauthorized" }
6971 | { readonly kind: "unverified" }
72 /** A guest: the owner has not made this account a member. */
73 | { readonly kind: "not_member" }
7074 | { readonly kind: "own" }
7175 /** Today's copies are made; another may be made from `resetsAt`. */
7276 | { readonly kind: "limit"; readonly resetsAt: string }
@@ -149,6 +153,10 @@ export async function makeItYours(
149153 message: "an agent does not make apps its own",
150154 });
151155 if (source.ownerId === actor.id) return err({ kind: "own" });
156 // The older refusals have spoken; what is left is the role. An account
157 // that is unverified and a guest is told to verify first.
158 if (!requireAbility(app, actor, "make_it_yours").ok)
159 return err({ kind: requireVerified(app, actor).ok ? "not_member" : "unverified" });
152160 const dryRun = input.dryRun !== false;
153161 const name = input.name?.trim() || source.name;
154162 const visibility = input.visibility ?? "private";
@@ -226,6 +234,8 @@ export async function makeItYours(
226234 return err({ kind: "unauthorized" });
227235 case "unverified":
228236 return err({ kind: "unverified" });
237 case "not_member":
238 return err({ kind: "not_member" });
229239 case "name_taken":
230240 return err({
231241 kind: "invalid",
src/actions/migrate.ts +3
@@ -17,6 +17,7 @@
1717 * thing: make it run here, adapting rather than rewriting.
1818 */
1919
20 import { NOT_A_MEMBER } from "../core/roles.ts";
2021 import { requireVerified } from "./verify.ts";
2122 import type { App } from "../app.ts";
2223 import { getAgentByName } from "../core/agents.ts";
@@ -197,6 +198,8 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories
197198 return "Verify your email first: follow the link we sent, or ask for another under Settings.";
198199 case "refused":
199200 return "New accounts cannot create repositories yet.";
201 case "not_member":
202 return `Not made: ${NOT_A_MEMBER}.`;
200203 case "repository_limit":
201204 return `Not made: ${limit}.`;
202205 case "storage_limit":
src/actions/proposals.ts +4
@@ -18,6 +18,8 @@ import { ensureDirectionIssue } from "../crew/autonomy.ts";
1818 import { queueAgentRun } from "../crew/dispatch.ts";
1919 import { err, ok, type Result } from "../kernel/result.ts";
2020 import { MAX_BODY } from "../core/issues.ts";
21 import { NOT_A_MEMBER } from "../core/roles.ts";
22 import { requireAbility } from "./roles.ts";
2123 import { assignIssue, addComment } from "./issues.ts";
2224 import { openPlan, parsePlan, type Planned } from "./plan.ts";
2325
@@ -90,6 +92,8 @@ export function redirectProposal(
9092 const found = mine(app, actor, id, ["pending", "later"]);
9193 if (!found.ok) return found;
9294 const { proposal, repo } = found.value;
95 // The run it starts is a member's: refused before anything is written.
96 if (!requireAbility(app, found.value.actor, "run_crew").ok) return fail("forbidden", NOT_A_MEMBER);
9397 const planner = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner")?.agent;
9498 if (planner === undefined) return fail("invalid", "no planner on this crew to hand it to");
9599 const direction = ensureDirectionIssue(app, found.value.actor, repo);
src/actions/repos.ts +7 −1
@@ -5,6 +5,7 @@
55 */
66
77 import { requireVerified } from "./verify.ts";
8 import { requireAbility } from "./roles.ts";
89 import type { App } from "../app.ts";
910 import { canSeeSealed } from "../core/sealed.ts";
1011 import { capacityValue } from "../core/capacity.ts";
@@ -72,6 +73,7 @@ export type CreateRepoActionError =
7273 | "unauthorized"
7374 | "forbidden_owner"
7475 | "refused"
76 | "not_member"
7577 | "repository_limit"
7678 | "storage_limit";
7779
@@ -142,6 +144,8 @@ export async function createRepo(
142144 if (user.value.kind === "agent") return err("forbidden_owner");
143145 const guarded = requireNewAccountGuard(app, user.value);
144146 if (!guarded.ok) return guarded;
147 const member = requireAbility(app, user.value, "make_it_yours");

The checks here are right, but the push-to-create callers of createRepo (src/http/server.ts smart HTTP, src/tui/ssh.ts) never map not_member. A guest pushing a new repository gets a 404 / not-found with no reason. Map it there with NOT_A_MEMBER, and add a test.

148 if (!member.ok) return member;
145149 const ownerId = resolveOwner(app, user.value, input.owner ?? "");
146150 if (ownerId === undefined) return err("forbidden_owner");
147151 if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined)
@@ -179,13 +183,15 @@ export async function forkRepo(
179183 parent: Repo,
180184 name?: string,
181185 options: { visibility?: "public" | "private"; now?: number } = {},
182 ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit" | "storage_limit">> {
186 ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "not_member" | "repository_limit" | "storage_limit">> {
183187 const user = requireParticipant(app, actor, parent);
184188 if (!user.ok) return user;
185189 const proven = requireVerified(app, user.value);
186190 if (!proven.ok) return proven;
187191 const guarded = requireNewAccountGuard(app, user.value);
188192 if (!guarded.ok) return guarded;
193 const member = requireAbility(app, user.value, "make_it_yours");
194 if (!member.ok) return member;
189195 if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit");
190196 if ((await storageRefusal(app, user.value, user.value.id)) !== undefined)
191197 return err("storage_limit");
src/actions/roles.ts +8 −2
@@ -1,16 +1,22 @@
11 /**
22 * Promote a guest to member, or demote a member, with a starter model
33 * allowance on the way up. A forge admin who is a person only; the change is
4 * an event on the outbox. Records the role; refuses nothing because of it.
4 * an event on the outbox. `requireAbility` is the one place the other actions
5 * ask what a role may do, after every older refusal has had its say.
56 */
67
78 import type { App } from "../app.ts";
89 import { setModelGrant, validGrant } from "../core/model_grant.ts";
9 import { roleOf, setMember } from "../core/roles.ts";
10 import { lacks, roleOf, setMember, type Ability } from "../core/roles.ts";
1011 import { getUserByName, type User } from "../core/users.ts";
1112 import { err, ok, type Result } from "../kernel/result.ts";
1213 import { requireUser } from "./access.ts";
1314
15 /** `user` unless the table withholds `ability` from their role: then `not_member`. */
16 export function requireAbility(app: App, user: User, ability: Ability): Result<User, "not_member"> {
17 return lacks(app.db, user, ability) ? err("not_member") : ok(user);
18 }
19
1420 export type RoleError = "not_found" | "not_allowed" | "invalid_amount";
1521
1622 export function setRole(
src/actions/runs.ts +4 −1
@@ -10,6 +10,7 @@
1010
1111 import { mayViewId } from "../core/sealed.ts";
1212 import { requireVerified } from "./verify.ts";
13 import { requireAbility } from "./roles.ts";
1314 import type { App } from "../app.ts";
1415 import { steerRun } from "../crew/runner.ts";
1516 import { getIssueById } from "../core/issues.ts";
@@ -200,7 +201,7 @@ export function requeueRepoRun(
200201 actor: User | undefined,
201202 repo: Repo,
202203 runId: string,
203 ): Result<Run, AccessError | "not_agent" | "not_finished" | "no_task" | "not_queued"> {
204 ): Result<Run, AccessError | "not_agent" | "not_finished" | "no_task" | "not_queued" | "not_member"> {
204205 const writer = requireWriter(app, actor, repo);
205206 if (!writer.ok) return writer;
206207 // A run costs money; like assigning, queueing one is a person's call.
@@ -208,6 +209,8 @@ export function requeueRepoRun(
208209 const run = getRun(app.db, runId);
209210 if (!run || run.repoId !== repo.id) return err("not_found");
210211 if (run.kind !== "agent") return err("not_agent");
212 const member = requireAbility(app, writer.value, "run_crew");
213 if (!member.ok) return member;
211214 if (run.state !== "cancelled" && run.state !== "failed")
212215 return err("not_finished");
213216 const task = taskOf(app, run);
src/core/capacity.ts +9 −1
@@ -21,7 +21,8 @@ export type CapacityKey =
2121 | "checks.shards"
2222 | "children.max"
2323 | "cells.egress"
24 | "guard_new_accounts";
24 | "guard_new_accounts"
25 | "guard_guests";
2526
2627 interface Definition {
2728 /** What the settings page and the API call it. */
@@ -80,6 +81,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
8081 fallback: "off",
8182 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.",
8283 },
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 },
8391 };
8492
8593 export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[];
src/core/roles.ts +22 −2
@@ -2,11 +2,13 @@
22 * Roles: an account is a guest, a member or an admin. Admin is the `is_admin`
33 * flag, a member is a row in `member`, everyone else is a guest. `CAN` is the
44 * one table of what each role may do; `docs/ROLES.md` repeats it and a test
5 * fails when they differ. Part 1 records and shows roles — nothing is
6 * refused because of one yet. Pure data; who may promote is the action's.
5 * fails when they differ. A guest is refused what the table withholds:
6 * owning a repository, making an app one's own, starting a run
7 * (`requireAbility` in `actions/roles.ts`). Pure data; who may promote is the action's.
78 */
89
910 import type { Database } from "bun:sqlite";
11 import { capacityValue } from "./capacity.ts";
1012
1113 export const ROLES = ["guest", "member", "admin"] as const;
1214 export type Role = (typeof ROLES)[number];
@@ -44,6 +46,24 @@ export function rolesTable(): string {
4446 return [`| Ability | ${ROLES.join(" | ")} |`, `| --- | ${ROLES.map(() => "---").join(" | ")} |`, ...rows].join("\n");
4547 }
4648
49 /** The words a guest is refused with, on every surface. */
50 export const NOT_A_MEMBER = "the owner has not made this account a member";
51
52 /**
53 * Whether the table withholds `ability` from this account. Only a person
54 * 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).
57 */
58 export function lacks(
59 db: Database,
60 user: { id: string; isAdmin: boolean; kind: string },
61 ability: Ability,
62 ): boolean {
63 if (user.kind !== "user" || capacityValue(db, "guard_guests") === "off") return false;
64 return !can(roleOf(db, user), ability);
65 }
66
4767 export function roleOf(db: Database, user: { id: string; isAdmin: boolean }): Role {
4868 if (user.isAdmin) return "admin";
4969 const row = db.query<{ n: number }, [string]>("SELECT 1 AS n FROM member WHERE user_id = ?1").get(user.id);
src/crew/converge.ts +5
@@ -14,6 +14,7 @@
1414 * Nothing here ever moves the fork's default branch by itself.
1515 */
1616
17 import { requireAbility } from "../actions/roles.ts";
1718 import { mayDirectCrew } from "./direct.ts";
1819 import type { App } from "../app.ts";
1920 import { openIssue } from "../actions/issues.ts";
@@ -67,6 +68,8 @@ export type Converge =
6768 | { kind: "nobody" }
6869 /** The person asking may not direct the crew here (`direct.ts`). */
6970 | { kind: "forbidden" }
71 /** A guest: the run is a member's to start, refused before anything is written. */
72 | { kind: "not_member" }
7073 | { kind: "ceiling" };
7174
7275 /** The fork's open converge issue, if the parent's updates are in hand. */
@@ -144,6 +147,8 @@ export async function convergeFork(
144147 // fork's own autonomy, which its owner turned on.
145148 if (actor !== undefined && !mayDirectCrew(app.db, fork, actor))
146149 return { kind: "forbidden" };
150 if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok)
151 return { kind: "not_member" };
147152 const relation = await forkRelation(app, fork, parent);
148153 if (relation === undefined || relation.state !== "diverged" || relation.clean)
149154 return { kind: "nothing", relation };
src/crew/dispatch.ts +5
@@ -53,6 +53,7 @@ import {
5353 type Run,
5454 } from "../core/runs.ts";
5555 import { getUserById, type User } from "../core/users.ts";
56 import { lacks } from "../core/roles.ts";
5657 import { createLog } from "../kernel/log.ts";
5758 import { issueOfBranch, type RunReason } from "./brief.ts";
5859 import { detectClaude } from "./claude.ts";
@@ -289,6 +290,10 @@ export function queueAgentRun(
289290 ): Run | undefined {
290291 // A suspended or removed owner's repositories run nothing, whoever asks.
291292 if (!accountActive(app.db, repo.ownerId)) return undefined;
293 // A guest's word starts no run (a comment is still a comment); the
294 // actions that start one by name refuse out loud before they get here.
295 const asker = actorId === undefined ? undefined : getUserById(app.db, actorId);
296 if (asker !== undefined && lacks(app.db, asker, "run_crew")) return undefined;

A silent gate with no test. Add one where a guest's @-mention comment posts but listRuns stays empty, and that a member's starts a run.

This gate refuses quietly, and some callers write state before they get here. converge (src/crew/converge.ts:188) assigns the issue to an agent first. The refusal comes back as "ceiling", and then sweep() queues the run with no actor, so the guest's run goes ahead after all. Refuse with requireAbility in the action that is asked (converge), before it assigns anything, and test that path.

292297 // The day's ceiling gates every run the crew starts on its own, not only
293298 // the picks: a review, a feedback run or a conflict run past the ceiling
294299 // is money the person did not agree to. A person's own assignment is not
src/crew/proposal.ts +4
@@ -6,6 +6,7 @@
66 * the resolution to the branch and its report becomes the note on the pull.
77 */
88
9 import { requireAbility } from "../actions/roles.ts";
910 import type { App } from "../app.ts";
1011 import { getIssueById, listComments } from "../core/issues.ts";
1112 import { pullDir, pullStatus, type Pull } from "../core/pulls.ts";
@@ -25,6 +26,7 @@ export type ProposeError =
2526 | "no_conflict"
2627 | "no_agent"
2728 | "busy"
29 | "not_member"
2830 | "not_queued";
2931
3032 /** What the brief carries at most: a hand's worth, never a whole history. */
@@ -47,6 +49,7 @@ export async function proposeResolution(
4749 ): Promise<Result<Run, ProposeError>> {
4850 if (!canWrite(app.db, repo, actor)) return err("forbidden");
4951 if (pull.state !== "open") return err("not_open");
52 if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) return err("not_member");
5053 const issue = getIssueById(app.db, pull.id);
5154 const agent = issue === undefined ? undefined : agentFor(app, issue);
5255 if (issue === undefined || agent === undefined) return err("no_agent");
@@ -214,6 +217,7 @@ export const PROPOSE_WHY: Record<ProposeError, string> = {
214217 no_conflict: "That pull has no conflicts to resolve.",
215218 no_agent: "No agent holds this pull, so the crew has no hand to ask.",
216219 busy: "The crew is already working on this pull.",
220 not_member: "Starting a run is for members: the owner has not made this account a member.",
217221 not_queued: "The crew could not take it on right now (its ceiling or breaker); try again later.",
218222 };
219223
src/fleet/verbs.ts +2
@@ -10,6 +10,7 @@
1010 * and append their own events; a verb never touches the database itself.
1111 */
1212
13 import { NOT_A_MEMBER } from "../core/roles.ts";
1314 import type { App } from "../app.ts";
1415 import {
1516 addComment,
@@ -108,6 +109,7 @@ export interface Verb {
108109 /** An action's refusal codes, in words a receipt can show. */
109110 const WORDS: Record<string, string> = {
110111 ...dependencyWords,
112 not_member: NOT_A_MEMBER,
111113 not_found: "not found",
112114 forbidden: "not allowed",
113115 unauthorized: "not signed in",
src/http/api.ts +4 −2
@@ -9,7 +9,7 @@
99 */
1010
1111 import { setRole } from "../actions/roles.ts";
12 import { listPeople } from "../core/roles.ts";
12 import { listPeople, NOT_A_MEMBER } from "../core/roles.ts";
1313 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
1414 import { storageLimitMessage } from "../core/storage.ts";
1515 import { voteVariant } from "../actions/variants.ts";
@@ -2246,7 +2246,7 @@ export function apiRoutes(router: Router<Handler>): void {
22462246 ? 200
22472247 : outcome.kind === "ceiling"
22482248 ? 429
2249 : outcome.kind === "nobody" || outcome.kind === "forbidden"
2249 : outcome.kind === "nobody" || outcome.kind === "forbidden" || outcome.kind === "not_member"
22502250 ? 403
22512251 : 409;
22522252 return json(
@@ -4455,6 +4455,8 @@ export function apiRoutes(router: Router<Handler>): void {
44554455 ? apiError("repository_limit", 403, made.error.message)
44564456 : made.error.kind === "storage_limit"
44574457 ? apiError("storage_limit", 403, made.error.message)
4458 : made.error.kind === "not_member"
4459 ? apiError("not_member", 403, NOT_A_MEMBER)
44584460 : made.error.kind === "invalid"
44594461 ? apiError("invalid", 422, made.error.message)
44604462 : made.error.kind === "own"
src/http/mcp.ts +5 −1
@@ -17,6 +17,7 @@
1717 * no page. `ro` tokens read; `rw` tokens act.
1818 */
1919
20 import { NOT_A_MEMBER } from "../core/roles.ts";
2021 import { voteVariant } from "../actions/variants.ts";
2122 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2223 import { listAccess } from "../actions/access_log.ts";
@@ -1450,6 +1451,7 @@ const TOOLS: readonly Tool[] = [
14501451 return bad("not_a_fork", "this repository follows nothing");
14511452 const outcome = await convergeFork(ctx.app, repo, parent, user);
14521453 if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE);
1454 if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER);
14531455 return good({
14541456 kind: outcome.kind,
14551457 issue: "issue" in outcome ? outcome.issue.number : null,
@@ -1702,6 +1704,8 @@ const TOOLS: readonly Tool[] = [
17021704 ? bad("invalid", made.error.message)
17031705 : made.error.kind === "own"
17041706 ? bad("own", "this app is already yours")
1707 : made.error.kind === "not_member"
1708 ? bad("not_member", NOT_A_MEMBER)
17051709 : made.error.kind === "unverified"
17061710 ? bad("unverified", "prove your email first")
17071711 : bad("not_found", "no such app or repository (owner/name)");
@@ -2342,7 +2346,7 @@ const TOOLS: readonly Tool[] = [
23422346 properties: {
23432347 key: {
23442348 type: "string",
2345 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts"],
2349 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts", "guard_guests"],
23462350 },
23472351 value: { type: "string", maxLength: 16 },
23482352 dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." },
src/http/respond.ts +3
@@ -6,6 +6,7 @@
66
77 import { bodyCapFor, readBounded } from "./limits.ts";
88 import { scopeAllows, type TokenScope } from "../core/tokens.ts";
9 import { NOT_A_MEMBER } from "../core/roles.ts";
910 import { NEW_ACCOUNT_GUARD_MESSAGE } from "../core/capacity.ts";
1011 import type { User } from "../core/users.ts";
1112 import { crossOrigin, type Ctx } from "./context.ts";
@@ -154,6 +155,8 @@ export function respondAccess(error: string, status: number): Response {
154155 // clients see no difference between the trigger and the setting.
155156 if (error === "refused")
156157 return json({ error, message: NEW_ACCOUNT_GUARD_MESSAGE }, 403);
158 if (error === "not_member")
159 return json({ error, message: NOT_A_MEMBER }, 403);
157160 if (error === "unauthorized") return unauthorized();
158161 if (error === "forbidden") return forbidden();
159162 return json({ error }, status);
src/http/server.ts +3
@@ -5,6 +5,7 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { NOT_A_MEMBER } from "../core/roles.ts";
89 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
910 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
1011 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
@@ -1374,6 +1375,8 @@ async function withGitAccess(
13741375 const ownerId = resolveOwner(ctx.app, ctx.auth.user, ctx.params.owner!) ?? ctx.auth.user.id;
13751376 return gitRefused(ctx, storageRefusedText(storageSentence(ctx.app, ownerId) ?? "at the limit"));
13761377 }
1378 else if (created.error === "not_member")
1379 return gitRefused(ctx, `Not made: ${NOT_A_MEMBER}`);
13771380 else if (created.error === "repository_limit")
13781381 return gitRefused(
13791382 ctx,
src/tui/screens/common.ts +2
@@ -7,6 +7,7 @@
77 * of them. Nothing here knows about repos or issues — that is the point.
88 */
99
10 import { NOT_A_MEMBER } from "../../core/roles.ts";
1011 import type { Ctx, Screen } from "../app/app.ts";
1112 import { binding, type Binding } from "../app/keys.ts";
1213 import type { KeyEvent } from "../term/input.ts";
@@ -814,6 +815,7 @@ export function slug(path: string): string {
814815 // ---- errors and time ----
815816
816817 const MESSAGES: Record<string, string> = {
818 not_member: NOT_A_MEMBER,
817819 not_found: "not found",
818820 signup_closed: SIGNUP_CLOSED_MESSAGE,
819821 human_check: "we could not tell you are a person; sign up on the web page",
src/tui/ssh.ts +5
@@ -10,6 +10,7 @@
1010 * to.
1111 */
1212
13 import { NOT_A_MEMBER } from "../core/roles.ts";
1314 import { holdOpen } from "../http/caller.ts";
1415 import { join } from "node:path";
1516 import type { App as Forge } from "../app.ts";
@@ -264,6 +265,10 @@ export async function serveGitCommand(
264265 );
265266 session.exit(1);
266267 return;
268 } else if (created.error === "not_member") {
269 session.writeStderr(`forge: Not made: ${NOT_A_MEMBER}${CRLF}`);
270 session.exit(1);
271 return;
267272 } else if (created.error === "repository_limit") {
268273 session.writeStderr(
269274 `forge: Not made: ${repositoryLimitMessage(forge, user, resolveOwner(forge, user, parsed.owner) ?? user.id)}${CRLF}`,
src/web/pages/repo.ts +3
@@ -8,6 +8,7 @@
88 * path segment and can never smuggle a path of its own.
99 */
1010
11 import { NOT_A_MEMBER } from "../../core/roles.ts";
1112 import { storageLimitMessage } from "../../core/storage.ts";
1213 import { canRead, listForks, type Repo } from "../../core/repos.ts";
1314 import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts";
@@ -196,6 +197,8 @@ export async function forkSubmit(ctx: Ctx): Promise<Response> {
196197 const reason =
197198 forked.error === "name_taken"
198199 ? "You already have a repository by that name."
200 : forked.error === "not_member"
201 ? `Not made: ${NOT_A_MEMBER}.`
199202 : forked.error === "repository_limit"
200203 ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`
201204 : forked.error === "storage_limit"
src/web/pages/site.ts +2
@@ -8,6 +8,7 @@
88 * typed still in it. A dead end that loses input is a bug, not a message.
99 */
1010
11 import { NOT_A_MEMBER } from "../../core/roles.ts";
1112 import { storageLimitMessage } from "../../core/storage.ts";
1213 import { limitValue } from "../../settings/limits.ts";
1314 import { signOutHere } from "../../actions/signins.ts";
@@ -740,6 +741,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> {
740741 if (created.error === "forbidden_owner" || created.error === "unauthorized")
741742 return new Response("forbidden", { status: 403 });
742743 const messages: Record<string, string> = {
744 not_member: `Not made: ${NOT_A_MEMBER}.`,
743745 repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`,
744746 storage_limit: `Not made: ${storageLimitMessage(ctx.app, resolveOwner(ctx.app, ctx.auth.user, values.owner) ?? ctx.auth.user.id)}.`,
745747 name_taken: "That owner already has a repository by that name.",
src/web/studio/make_it_yours.ts +6 −1
@@ -18,6 +18,7 @@ import { field, readForm } from "../form.ts";
1818 import { html, when } from "../html.ts";
1919 import { flashTo, page, redirect } from "../layout.ts";
2020 import { btn, card } from "../ui.ts";
21 import { NOT_A_MEMBER } from "../../core/roles.ts";
2122
2223 // GET /:owner/:name/make-it-yours
2324 export async function makeItYoursPage(ctx: Ctx): Promise<Response> {
@@ -39,7 +40,9 @@ export async function makeItYoursPage(ctx: Ctx): Promise<Response> {
3940 message:
4041 plan.error.kind === "invalid"
4142 ? plan.error.message
42 : "That could not be made yours.",
43 : plan.error.kind === "not_member"
44 ? `Not for this account: ${NOT_A_MEMBER}.`
45 : "That could not be made yours.",
4346 });
4447 }
4548 const p = plan.value;
@@ -130,6 +133,8 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> {
130133 ? made.error.message
131134 : made.error.kind === "repository_limit" || made.error.kind === "storage_limit"
132135 ? `Not made: ${made.error.message}.`
136 : made.error.kind === "not_member"
137 ? `Not made: ${NOT_A_MEMBER}.`
133138 : made.error.kind === "own"
134139 ? "This app is already yours."
135140 : made.error.kind === "unverified"
test/guests.test.ts added +260
@@ -0,0 +1,260 @@
1 /**
2 * Roles, part 2 (#2302): a guest cannot own a repository, make an app their
3 * own or start a crew run, through the page and through the API; a member
4 * can; and every older refusal keeps its own reason.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { changeCapacity } from "../src/actions/capacity.ts";
13 import { addToCrew, createAgent } from "../src/actions/crew.ts";
14 import { assignIssue, openIssue } from "../src/actions/issues.ts";
15 import { newApp } from "../src/actions/apps.ts";
16 import { createRepo, forkRepo } from "../src/actions/repos.ts";
17 import { makeItYours } from "../src/actions/make_it_yours.ts";
18 import { NOT_A_MEMBER, setMember } from "../src/core/roles.ts";
19 import { getRepoById, type Repo } from "../src/core/repos.ts";
20 import { createSession } from "../src/core/sessions.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { createUser, type User } from "../src/core/users.ts";
23 import { cancelRun, createRun, listRuns } from "../src/core/runs.ts";
24 import { addComment } from "../src/actions/issues.ts";
25 import { requeueRepoRun } from "../src/actions/runs.ts";
26 import { proposeResolution } from "../src/crew/proposal.ts";
27 import { agentFor, queueAgentRun } from "../src/crew/dispatch.ts";
28 import { getIssueById } from "../src/core/issues.ts";
29 import { convergeFork } from "../src/crew/converge.ts";
30 import { convergeWords } from "../src/actions/follow.ts";
31 import { mcpTool } from "./helpers/mcp.ts";
32 import { serve, type Servers } from "../src/http/server.ts";
33 import { quietLogs } from "./helpers/globals.ts";
34
35 quietLogs();
36
37 describe("a guest cannot own, copy or run", () => {
38 let dataDir = "";
39 let app: App;
40 let servers: Servers;
41 let alice: User;
42 let bob: User; // guest
43 let carol: User; // member
44 let dave: User; // guest, email unproven
45 let garden: Repo;
46 let lab: Repo; // bob's, with an agent on its crew
47 let helper: User;
48
49 const token = (u: User) => createToken(app.db, u.id, "rw", "rw").token;
50 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
51 const api = (path: string, u: User, body: unknown = {}) =>
52 fetch(`${servers.url}${path}`, {
53 method: "POST",
54 headers: { "content-type": "application/json", authorization: `Bearer ${token(u)}` },
55 body: JSON.stringify(body),
56 });
57 const form = (path: string, u: User, fields: Record<string, string>) =>
58 fetch(`${servers.url}${path}`, {
59 method: "POST",
60 headers: { cookie: cookie(u), origin: servers.url },
61 body: new URLSearchParams(fields),
62 redirect: "manual",
63 });
64 const mk = async (username: string, extra: { isAdmin?: boolean; trusted?: boolean } = {}) => {
65 const r = await createUser(app.db, {
66 trusted: extra.trusted ?? true,
67 username,
68 email: `${username}@forge.local`,
69 password: "a-good-passphrase",
70 ...(extra.isAdmin ? { adminIfFirst: true } : {}),
71 });
72 if (!r.ok) throw new Error(r.error);
73 return r.value;
74 };
75
76 beforeAll(async () => {
77 dataDir = await mkdtemp(join(tmpdir(), "forge-guests-"));
78 app = createApp({ dataDir, port: 0 });
79 servers = await serve(app);
80 alice = await mk("alice", { isAdmin: true });
81 bob = await mk("bob");
82 carol = await mk("carol");
83 dave = await mk("dave", { trusted: false });
84 setMember(app.db, carol.id, true, alice.id, new Date());
85 const made = await newApp(app, alice, { name: "garden", description: "g", visibility: "public" });
86 if (!made.ok) throw new Error(String(made.error));
87 garden = made.value.repo;
88 // bob owns a repository (made before the guard), with an agent on its crew.
89 const own = await createRepo(app, alice, { name: "lab", visibility: "private" });
90 if (!own.ok) throw new Error(own.error);
91 app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(bob.id, own.value.id);
92 lab = getRepoById(app.db, own.value.id)!;
93 const agent = await createAgent(app, alice, { username: "helper" });
94 if (!agent.ok) throw new Error(String(agent.error));
95 helper = agent.value.user;
96 const crewed = addToCrew(app, bob, lab, "helper");
97 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 }, 60_000);
101
102 afterAll(async () => {
103 servers.stop();
104 app.close();
105 await rm(dataDir, { recursive: true, force: true });
106 });
107
108 test("creating a repository: refused to a guest by the action, the API and the page; a member may", async () => {
109 const acted = await createRepo(app, bob, { name: "mine" });
110 expect(acted).toMatchObject({ ok: false, error: "not_member" });
111
112 const res = await api("/api/v1/repos", bob, { name: "mine" });
113 expect(res.status).toBe(403);
114 expect(await res.json()).toMatchObject({ error: "not_member", message: NOT_A_MEMBER });
115
116 const page = await form("/new", bob, { name: "mine", owner: "" });
117 expect(await page.text()).toContain(NOT_A_MEMBER);
118 expect((await api("/api/v1/repos", bob, { name: "mine" })).status).toBe(403);
119
120 expect((await api("/api/v1/repos", carol, { name: "hers" })).status).toBe(201);
121 expect((await form("/new", carol, { name: "hers-too", owner: "" })).status).toBe(303);
122 });
123
124 test("a plain fork is the same act", async () => {
125 expect(await forkRepo(app, bob, garden)).toMatchObject({ ok: false, error: "not_member" });
126 const res = await api("/api/v1/repos/alice/garden/forks", bob);
127 expect(res.status).toBe(403);
128 expect(((await res.json()) as any).message).toBe(NOT_A_MEMBER);
129 });
130
131 test("make it yours: refused to a guest, plan and making, by the action, the API and the page; a member may", async () => {
132 expect(await makeItYours(app, bob, garden, { dryRun: false })).toMatchObject({
133 ok: false,
134 error: { kind: "not_member" },
135 });
136 for (const body of [{}, { dryRun: false }]) {
137 const res = await api("/api/v1/repos/alice/garden/make-it-yours", bob, body);
138 expect(res.status).toBe(403);
139 expect(await res.json()).toMatchObject({ error: "not_member", message: NOT_A_MEMBER });
140 }
141 const posted = await form("/alice/garden/make-it-yours", bob, {});
142 expect(posted.status).toBe(303);
143 const flash = await (
144 await fetch(`${servers.url}${posted.headers.get("location")}`, {
145 headers: { cookie: [cookie(bob), ...(posted.headers.getSetCookie() ?? []).map((c) => c.split(";")[0])].join("; ") },
146 })
147 ).text();
148 expect(flash).toContain(NOT_A_MEMBER);
149
150 const ok = await api("/api/v1/repos/alice/garden/make-it-yours", carol, { dryRun: false });
151 expect(ok.status).toBe(201);
152 });
153
154 test("starting a run: assigning or requeueing an agent is refused to a guest; a member's goes through", async () => {
155 const issue = openIssue(app, bob, lab, { title: "weed", body: "" });
156 if (!issue.ok) throw new Error(String(issue.error));
157 const asked = assignIssue(app, bob, lab, issue.value, helper);
158 expect(asked).toMatchObject({ ok: false, error: "not_member" });
159 const res = await fetch(`${servers.url}/api/v1/repos/bob/lab/issues/${issue.value.number}/assignee`, {
160 method: "PUT",
161 headers: { "content-type": "application/json", authorization: `Bearer ${token(bob)}` },
162 body: JSON.stringify({ assignee: "helper" }),
163 });
164 expect(res.status).toBe(403);
165 expect(((await res.json()) as any).message).toBe(NOT_A_MEMBER);
166 expect(listRuns(app.db, lab.id, { kind: "agent", limit: 10 })).toHaveLength(0);
167
168 // Promoted, the same account may.
169 setMember(app.db, bob.id, true, alice.id, new Date());
170 expect(assignIssue(app, bob, lab, issue.value, helper).ok).toBe(true);
171 setMember(app.db, bob.id, false, alice.id, new Date());
172 });
173
174 test("older refusals keep their own reasons: unverified, agent, stranger to a private repository", async () => {
175 // Unverified (and a guest): told to verify first, not that they are no member.
176 expect(await createRepo(app, dave, { name: "d" })).toMatchObject({ ok: false, error: "unverified" });
177 const unproven = await api("/api/v1/repos/alice/garden/make-it-yours", dave, { dryRun: false });
178 expect(await unproven.json()).toMatchObject({ error: "unverified" });
179 // An agent owns nothing it was not given, and is not a guest.
180 expect(await createRepo(app, helper, { name: "h" })).toMatchObject({ ok: false, error: "forbidden_owner" });
181 // A private repository is not there for a stranger, member or not.
182 const hidden = await api("/api/v1/repos/bob/lab/make-it-yours", carol, {});
183 expect(hidden.status).toBe(404);
184 });
185
186 test("an admin is never a guest, and the guard can be turned off", async () => {
187 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);
191 });
192
193 test("push-to-create: a guest's push to a new repository is refused with the words; a member's is not", async () => {
194 const basic = (u: User) => `Basic ${btoa(`${u.username}:${token(u)}`)}`;
195 const adv = (u: User, repo: string) =>
196 fetch(`${servers.url}/${u.username}/${repo}.git/info/refs?service=git-receive-pack`, {
197 headers: { authorization: basic(u) },
198 });
199 const refused = await (await adv(bob, "pushed")).text();
200 expect(refused).toContain(`Not made: ${NOT_A_MEMBER}`);
201 const allowed = await adv(carol, "pushed");
202 expect(await allowed.text()).not.toContain(NOT_A_MEMBER);
203 });
204
205 test("MCP make_it_yours: not_member for a guest", async () => {
206 const guest = await mcpTool(servers.url, token(bob), "make_it_yours", { repo: "alice/garden" });
207 expect(guest.error ?? "").toContain(NOT_A_MEMBER);
208 });
209
210 test("requeueing a run is refused to a guest, and not for the reason of membership to a member", async () => {
211 const issue = openIssue(app, alice, lab, { title: "again", body: "" });
212 if (!issue.ok) throw new Error(String(issue.error));
213 const run = createRun(app.db, { repoId: lab.id, kind: "agent", issueId: issue.value.id, actorId: helper.id, reason: "assigned" });
214 cancelRun(app.db, run.id, "test");
215 expect(requeueRepoRun(app, bob, lab, run.id)).toMatchObject({ ok: false, error: "not_member" });
216 setMember(app.db, bob.id, true, alice.id, new Date());
217 const again = requeueRepoRun(app, bob, lab, run.id);
218 expect(again.ok === false && again.error === "not_member").toBe(false);
219 setMember(app.db, bob.id, false, alice.id, new Date());
220 });
221
222 test("asking the crew to resolve a conflict is refused to a guest", async () => {
223 const pull = { state: "open", id: "none", number: 1 } as any;
224 expect(await proposeResolution(app, bob, lab, pull)).toMatchObject({ ok: false, error: "not_member" });
225 setMember(app.db, bob.id, true, alice.id, new Date());
226 const member = await proposeResolution(app, bob, lab, pull);
227 expect(member.ok === false && member.error === "not_member").toBe(false);
228 setMember(app.db, bob.id, false, alice.id, new Date());
229 });
230
231 test("a guest's word starts no run: the comment posts, the queue stays empty; a member's queues one", async () => {
232 const opened = openIssue(app, alice, lab, { title: "talk", body: "" });
233 if (!opened.ok) throw new Error(String(opened.error));
234 const issue = opened.value;
235 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(helper.id, issue.id);
236 const before = listRuns(app.db, lab.id, { kind: "agent", limit: 50 }).length;
237 const said = addComment(app, bob, lab, issue, "@helper please look");
238 expect(said.ok).toBe(true);
239 const fresh = getIssueById(app.db, issue.id)!;
240 const agent = agentFor(app, fresh)!;
241 expect(queueAgentRun(app, lab, fresh, agent, "feedback", bob.id)).toBeUndefined();
242 expect(listRuns(app.db, lab.id, { kind: "agent", limit: 50 })).toHaveLength(before);
243 setMember(app.db, bob.id, true, alice.id, new Date());
244 expect(queueAgentRun(app, lab, fresh, agent, "feedback", bob.id)).toBeDefined();
245 setMember(app.db, bob.id, false, alice.id, new Date());
246 });
247
248 test("converge: a guest is refused with the words before anything is written; no issue is left for the sweep", async () => {
249 const issues = () => app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1").get(lab.id)!.n;
250 const before = issues();
251 const out = await convergeFork(app, lab, garden, bob);
252 expect(out).toEqual({ kind: "not_member" });
253 expect(convergeWords(out, "alice/garden").message).toContain(NOT_A_MEMBER);
254 expect(issues()).toBe(before);
255 const assigned = app.db
256 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND assignee_id IS NOT NULL AND title LIKE 'Take updates%'")
257 .get(lab.id)!.n;
258 expect(assigned).toBe(0);
259 });
260 });
test/helpers/preload.ts +4
@@ -6,3 +6,7 @@
66 import { installCheapHash } from "./cheap-hash.ts";
77
88 installCheapHash();
9
10 // Most suites make plain accounts and expect them to own things; the guest
11 // guard (`guard_guests`, on in production) has its own suite that turns it on.
12 process.env["FORGE_GUARD_GUESTS"] = "off";

With this line the guard is off for every suite except guests.test.ts, so a new act that ought to refuse guests passes everywhere else. Follow-up #2359: promote the fixtures' accounts to members and remove the setting.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs